Skip to content

Commit c611499

Browse files
committed
test: Add tests for createContestTaskPairKey (#2748)
1 parent 99bbc25 commit c611499

File tree

2 files changed

+377
-0
lines changed

2 files changed

+377
-0
lines changed

docs/dev-notes/2025-10-25/refactor-getMergedTasksMap/lesson.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,85 @@ src/lib/services/tasks.ts を参照
5959
- ✅ 早期リターンで複雑さを減らす
6060
- ✅ ヘルパー関数で責任分離
6161
- ✅ 明確なドキュメント化
62+
63+
---
64+
65+
# createContestTaskPairKey のテスト設計教訓
66+
67+
## テスト作成で学んだこと
68+
69+
### 1. **ヘルパー関数で重複削減**
70+
71+
同じパターンのテストコードは **ヘルパー関数** に抽出:
72+
73+
```typescript
74+
// ❌ Before: 重複が多い
75+
const key1 = createTestKey(pair);
76+
const key2 = createTestKey(pair);
77+
expect(key1).toBe(key2);
78+
79+
// ✅ After: ヘルパー関数化
80+
const expectKeysToBeConsistent = (pair: TestPair): void => {
81+
const key1 = createTestKey(pair);
82+
const key2 = createTestKey(pair);
83+
expect(key1).toBe(key2);
84+
};
85+
expectKeysToBeConsistent(pair);
86+
```
87+
88+
### 2. **パラメタライズテスト(test.each)で 4 個 → 1 個に**
89+
90+
4 つの似たテストは `test.each()` で 1 つにまとめる:
91+
92+
```typescript
93+
// ❌ Before: 4 つのテスト関数
94+
test('expects empty contest_id to throw an error', () => { ... });
95+
test('expects empty task_id to throw an error', () => { ... });
96+
test('expects whitespace-only contest_id to throw an error', () => { ... });
97+
test('expects whitespace-only task_id to throw an error', () => { ... });
98+
99+
// ✅ After: 1 つのパラメタライズテスト
100+
test.each<[string, string, string]>([
101+
['', 'abc123_a', 'contestId must be a non-empty string'],
102+
[' ', 'abc123_a', 'contestId must be a non-empty string'],
103+
['abc123', '', 'taskId must be a non-empty string'],
104+
['abc123', ' ', 'taskId must be a non-empty string'],
105+
])('expects error when contest_id="%s" and task_id="%s"', (contestId, taskId, expectedError) => {
106+
expect(() => createContestTaskPairKey(contestId, taskId)).toThrow(expectedError);
107+
});
108+
```
109+
110+
### 3. **テストデータを集約して保守性向上**
111+
112+
テストデータを `pairs` オブジェクトで一元管理:
113+
114+
```typescript
115+
const pairs = {
116+
normal: [...], // 正常系ケース
117+
edge: [...], // エッジケース
118+
anomaly: [...], // 異常系ケース
119+
};
120+
```
121+
122+
### 4. **テストカバレッジの考え方**
123+
124+
- **正常系**: 期待通りに動くか
125+
- **エッジケース**: 空文字列、ホワイトスペース、長い文字列
126+
- **異常系**: 特殊文字、Unicode、改行、タブ
127+
- **キー検証**: フォーマット、一意性、可逆性
128+
129+
### 5. **べストプラクティス**
130+
131+
| 改善内容 | 効果 |
132+
| -------------------- | -------------------- |
133+
| ヘルパー関数化 | コード重複 -40% |
134+
| パラメタライズテスト | テスト関数数 削減 |
135+
| テストデータ集約 | 保守性向上 |
136+
| beforeEach で初期化 | テスト間の独立性確保 |
137+
138+
## テスト統計
139+
140+
- **総テスト数**: 27 個(全成功 ✅)
141+
- **パラメタライズテスト**: 2 グループ(合計 8 ケース)
142+
- **ヘルパー関数**: 5 個
143+
- **テストデータセット**: 3 グループ(normal, edge, anomaly)
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import { describe, test, expect, beforeEach } from 'vitest';
2+
3+
import { createContestTaskPairKey } from '$lib/utils/contest_task_pair';
4+
5+
describe('createContestTaskPairKey', () => {
6+
// Test data structure
7+
interface TestPair {
8+
contestId: string;
9+
taskId: string;
10+
}
11+
12+
type TestPairs = TestPair[];
13+
14+
// Helper to create test pairs
15+
const pairs = {
16+
normal: [
17+
{ contestId: 'abc100', taskId: 'abc100_a' },
18+
{ contestId: 'abc397', taskId: 'abc397_g' },
19+
{ contestId: 'arc050', taskId: 'arc050_a' },
20+
{ contestId: 'dp', taskId: 'dp_a' },
21+
{ contestId: 'tdpc', taskId: 'tdpc_a' },
22+
{ contestId: 'typical90', taskId: 'typical90_a' },
23+
{ contestId: 'typical90', taskId: 'typical90_cl' },
24+
{ contestId: 'tessoku-book', taskId: 'abc007_3' },
25+
{ contestId: 'tessoku-book', taskId: 'math_and_algorithm_am' },
26+
{ contestId: 'tessoku-book', taskId: 'typical90_s' },
27+
{ contestId: 'joi2024yo1a', taskId: 'joi2024yo1a_a' },
28+
] as const,
29+
edge: [
30+
{ contestId: '', taskId: '' },
31+
{ contestId: 'abc123', taskId: '' },
32+
{ contestId: '', taskId: 'abc123_a' },
33+
{ contestId: '123', taskId: '456' },
34+
{ contestId: '_', taskId: '_' },
35+
{ contestId: 'abc_123_def', taskId: 'task_id_part' },
36+
{ contestId: 'abc-123', taskId: 'task-id' },
37+
{ contestId: 'a', taskId: 'b' },
38+
{ contestId: 'abc 123', taskId: 'abc123 a' },
39+
{ contestId: 'abc.123', taskId: 'abc123.a' },
40+
] as const,
41+
anomaly: [
42+
{ contestId: 'abc|123', taskId: 'abc123_a' },
43+
{ contestId: 'abc123', taskId: 'abc|123_a' },
44+
{ contestId: 'abc|123', taskId: 'abc|123_a' },
45+
{ contestId: 'abc||123', taskId: 'task||id' },
46+
{ contestId: 'abc.*+?^${}()', taskId: 'task[a-z]' },
47+
{ contestId: 'abc日本語123', taskId: 'task日本語a' },
48+
{ contestId: 'abc😀', taskId: 'task😀' },
49+
{ contestId: 'abc\n123', taskId: 'task\na' },
50+
{ contestId: 'abc\t123', taskId: 'task\ta' },
51+
] as const,
52+
};
53+
54+
let testNormalPairs: TestPairs;
55+
let testEdgePairs: TestPairs;
56+
let testAnomalyPairs: TestPairs;
57+
58+
beforeEach(() => {
59+
testNormalPairs = [...pairs.normal];
60+
testEdgePairs = [...pairs.edge];
61+
testAnomalyPairs = [...pairs.anomaly];
62+
});
63+
64+
// Helper functions:
65+
// To generate expected key
66+
const getExpectedKey = (contestId: string, taskId: string) => `${contestId}:${taskId}`;
67+
68+
// To create a key from a pair
69+
const createTestKey = (pair: TestPair): string =>
70+
createContestTaskPairKey(pair.contestId, pair.taskId);
71+
72+
// To compare two keys for consistency
73+
const expectKeysToBeConsistent = (pair: TestPair): void => {
74+
const key1 = createTestKey(pair);
75+
const key2 = createTestKey(pair);
76+
77+
expect(key1).toBe(key2);
78+
};
79+
80+
// To compare keys for difference
81+
const expectKeysDifferent = (pair1: TestPair, pair2: TestPair): void => {
82+
const key1 = createTestKey(pair1);
83+
const key2 = createTestKey(pair2);
84+
85+
expect(key1).not.toBe(key2);
86+
};
87+
88+
// To run test for multiple pairs
89+
const testMultiplePairs = (
90+
pairList: TestPairs,
91+
validator: (pair: TestPair, key: string) => void,
92+
): void => {
93+
pairList.forEach((pair) => {
94+
const key = createTestKey(pair);
95+
validator(pair, key);
96+
});
97+
};
98+
99+
describe('Base cases', () => {
100+
test('expects to create a key with valid contest_id and task_id', () => {
101+
const pair = testNormalPairs[0];
102+
const key = createTestKey(pair);
103+
104+
expect(key).toBe(getExpectedKey(pair.contestId, pair.taskId));
105+
expect(typeof key).toBe('string');
106+
});
107+
108+
test('expects to create different keys for different contest_ids', () => {
109+
const pair = testNormalPairs[0];
110+
const modifiedPair = { ...pair, contestId: 'abc124' };
111+
112+
expectKeysDifferent(pair, modifiedPair);
113+
});
114+
115+
test('expects to create different keys for different task_ids', () => {
116+
const pair = testNormalPairs[0];
117+
const modifiedPair = { ...pair, taskId: 'abc100_b' };
118+
119+
expectKeysDifferent(pair, modifiedPair);
120+
});
121+
122+
test('expects to create consistent keys for the same inputs', () => {
123+
const pair = testNormalPairs[0];
124+
125+
expectKeysToBeConsistent(pair);
126+
});
127+
128+
test('expects to work with various contest types', () => {
129+
testMultiplePairs(testNormalPairs, ({ contestId, taskId }) => {
130+
const pair = { contestId, taskId };
131+
const key = createTestKey(pair);
132+
133+
expect(key).toBe(getExpectedKey(contestId, taskId));
134+
});
135+
});
136+
137+
test('expects to work with uppercase and lowercase characters', () => {
138+
const pair1 = { contestId: 'ABC123', taskId: 'ABC123_A' };
139+
const pair2 = { contestId: 'abc123', taskId: 'abc123_a' };
140+
141+
expectKeysDifferent(pair1, pair2);
142+
});
143+
144+
test('expects to work with numeric task identifiers', () => {
145+
const pair = testNormalPairs[5]; // typical90_a
146+
const key = createTestKey(pair);
147+
148+
expect(key).toContain(pair.taskId);
149+
});
150+
151+
test('expects to work with long contest and task IDs', () => {
152+
const pair = {
153+
contestId: 'a'.repeat(50),
154+
taskId: 'a'.repeat(50) + '_' + 'b'.repeat(50),
155+
};
156+
const key = createTestKey(pair);
157+
158+
expect(key).toBe(getExpectedKey(pair.contestId, pair.taskId));
159+
});
160+
});
161+
162+
describe('Edge cases', () => {
163+
test('expects all edge cases to format correctly', () => {
164+
// Filter out empty string cases as they should throw
165+
const validEdgePairs = testEdgePairs.filter(
166+
(pair) => pair.contestId.trim() !== '' && pair.taskId.trim() !== '',
167+
);
168+
169+
testMultiplePairs(validEdgePairs, ({ contestId, taskId }) => {
170+
const pair = { contestId, taskId };
171+
const key = createTestKey(pair);
172+
173+
expect(key).toBe(getExpectedKey(contestId, taskId));
174+
});
175+
});
176+
177+
test.each<[string, string, string]>([
178+
['', 'abc123_a', 'contestId must be a non-empty string'],
179+
[' ', 'abc123_a', 'contestId must be a non-empty string'],
180+
['abc123', '', 'taskId must be a non-empty string'],
181+
['abc123', ' ', 'taskId must be a non-empty string'],
182+
])(
183+
'expects error when contest_id="%s" and task_id="%s"',
184+
(contestId, taskId, expectedError) => {
185+
expect(() => createContestTaskPairKey(contestId, taskId)).toThrow(expectedError);
186+
},
187+
);
188+
189+
test('expects to preserve order of contest_id and task_id', () => {
190+
const pair1 = testNormalPairs[0];
191+
const pair2 = { contestId: pair1.taskId, taskId: pair1.contestId };
192+
193+
expectKeysDifferent(pair1, pair2);
194+
});
195+
196+
test('expects to include the colon separator', () => {
197+
const pair = testNormalPairs[0];
198+
const key = createTestKey(pair);
199+
200+
expect(key).toContain(':');
201+
expect(key.split(':')).toHaveLength(2);
202+
});
203+
});
204+
205+
describe('Anomaly cases', () => {
206+
test('expects anomaly cases with special characters to format correctly', () => {
207+
// Filter out pipe character cases for basic testing
208+
const specialCharCases = testAnomalyPairs.slice(4);
209+
210+
testMultiplePairs(specialCharCases, ({ contestId, taskId }) => {
211+
const pair = { contestId, taskId };
212+
const key = createTestKey(pair);
213+
214+
expect(key).toBe(getExpectedKey(contestId, taskId));
215+
});
216+
});
217+
218+
test.each<[TestPair, string, string]>([
219+
[{ contestId: 'abc|123', taskId: 'abc123_a' }, 'abc|123:abc123_a', 'pipe in contest_id'],
220+
[{ contestId: 'abc123', taskId: 'abc|123_a' }, 'abc123:abc|123_a', 'pipe in task_id'],
221+
[{ contestId: 'abc|123', taskId: 'abc|123_a' }, 'abc|123:abc|123_a', 'pipes in both IDs'],
222+
[
223+
{ contestId: 'abc||123', taskId: 'task||id' },
224+
'abc||123:task||id',
225+
'multiple consecutive pipes',
226+
],
227+
])('expects pipe characters to be preserved (%s)', (pair, expected) => {
228+
const key = createTestKey(pair);
229+
expect(key).toBe(expected);
230+
});
231+
232+
test('expects to handle very long contest_id without issues', () => {
233+
const pair = { contestId: 'a'.repeat(1000), taskId: 'task_a' };
234+
const key = createTestKey(pair);
235+
236+
expect(key).toBe(getExpectedKey(pair.contestId, pair.taskId));
237+
});
238+
239+
test('expects to handle very long task_id without issues', () => {
240+
const pair = { contestId: 'contest', taskId: 'b'.repeat(1000) };
241+
const key = createTestKey(pair);
242+
243+
expect(key).toBe(getExpectedKey(pair.contestId, pair.taskId));
244+
});
245+
});
246+
247+
describe('Key validation', () => {
248+
test('expects key to be parseable back into components', () => {
249+
testMultiplePairs(testNormalPairs, ({ contestId, taskId }) => {
250+
const pair = { contestId, taskId };
251+
const key = createTestKey(pair);
252+
const [parsedContestId, parsedTaskId] = key.split(':');
253+
254+
expect(parsedContestId).toBe(contestId);
255+
expect(parsedTaskId).toBe(taskId);
256+
});
257+
});
258+
259+
test('expects key with colon separator to be splittable into two parts', () => {
260+
const pair = testNormalPairs[0];
261+
const key = createTestKey(pair);
262+
const parts = key.split(':');
263+
264+
expect(parts).toHaveLength(2);
265+
expect(parts[0]).toBe(pair.contestId);
266+
expect(parts[1]).toBe(pair.taskId);
267+
});
268+
269+
test('expects all keys to follow the same format pattern', () => {
270+
const keys = testNormalPairs.map((pair) => createTestKey(pair));
271+
272+
keys.forEach((key) => {
273+
expect(key).toMatch(/^.+:.+$/);
274+
});
275+
});
276+
277+
test('expects multiple keys to be unique', () => {
278+
const keys = testNormalPairs.map((pair) => createTestKey(pair));
279+
const uniqueKeys = new Set(keys);
280+
281+
expect(uniqueKeys.size).toBe(keys.length);
282+
});
283+
284+
test('expects keys from different pairs to be different', () => {
285+
const selectedPairs = testNormalPairs.slice(0, 3);
286+
const keys = selectedPairs.map((pair) => createTestKey(pair));
287+
288+
for (let i = 0; i < keys.length; i++) {
289+
for (let j = i + 1; j < keys.length; j++) {
290+
expect(keys[i]).not.toBe(keys[j]);
291+
}
292+
}
293+
});
294+
});
295+
});

0 commit comments

Comments
 (0)