@choblue/claude-code-toolkit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ # TDD Skill - Frontend (React)
2
+
3
+ React 프론트엔드 테스트에 적용되는 규칙이다.
4
+ 공통 원칙은 `SKILL.md`를 함께 참고한다.
5
+
6
+ ---
7
+
8
+ ## 1. Testing Library 철학
9
+
10
+ > "The more your tests resemble the way your software is used, the more confidence they can give you."
11
+ > -- Testing Library 핵심 원칙
12
+
13
+ ### 사용자가 보는 대로 테스트하라
14
+ - DOM 구조나 컴포넌트 내부 상태를 테스트하지 않는다
15
+ - 사용자가 화면에서 보고, 클릭하고, 입력하는 것을 기준으로 테스트한다
16
+ - `data-testid`는 **최후의 수단**이다. 시맨틱한 쿼리를 먼저 시도한다
17
+
18
+ ```typescript
19
+ // Bad - 구현 세부사항 테스트
20
+ expect(component.state.isOpen).toBe(true);
21
+ expect(wrapper.find('.modal-class')).toHaveLength(1);
22
+
23
+ // Good - 사용자 관점 테스트
24
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
25
+ expect(screen.getByText('모달 제목')).toBeVisible();
26
+ ```
27
+
28
+ ---
29
+
30
+ ## 2. 쿼리 우선순위
31
+
32
+ Testing Library 쿼리는 접근성과 사용자 경험을 기준으로 우선순위가 있다.
33
+
34
+ | 우선순위 | 쿼리 | 용도 |
35
+ |----------|-------|------|
36
+ | 1 | `getByRole` | 접근성 역할로 조회 (button, heading, textbox 등) |
37
+ | 2 | `getByLabelText` | 폼 요소를 라벨로 조회 |
38
+ | 3 | `getByPlaceholderText` | placeholder로 조회 |
39
+ | 4 | `getByText` | 텍스트 콘텐츠로 조회 |
40
+ | 5 | `getByDisplayValue` | 현재 입력값으로 조회 |
41
+ | 6 | `getByAltText` | alt 속성으로 조회 (이미지) |
42
+ | 7 | `getByTitle` | title 속성으로 조회 |
43
+ | 8 | `getByTestId` | data-testid로 조회 (**최후의 수단**) |
44
+
45
+ ```typescript
46
+ // Best - 역할 기반
47
+ screen.getByRole('button', { name: '저장' });
48
+ screen.getByRole('heading', { level: 2 });
49
+ screen.getByRole('textbox', { name: '이메일' });
50
+
51
+ // Good - 라벨 기반
52
+ screen.getByLabelText('비밀번호');
53
+
54
+ // Acceptable - 텍스트 기반
55
+ screen.getByText('환영합니다');
56
+
57
+ // Last resort - testid 기반
58
+ screen.getByTestId('complex-chart');
59
+ ```
60
+
61
+ ---
62
+
63
+ ## 3. userEvent vs fireEvent
64
+
65
+ ### userEvent를 기본으로 사용한다
66
+
67
+ `userEvent`는 실제 사용자 상호작용을 더 정확하게 시뮬레이션한다.
68
+
69
+ | 항목 | `fireEvent` | `userEvent` |
70
+ |------|-------------|-------------|
71
+ | 이벤트 수준 | 단일 DOM 이벤트 | 사용자 행동 전체 시뮬레이션 |
72
+ | 예시: click | `click` 이벤트만 발생 | `pointerDown` → `mouseDown` → `pointerUp` → `mouseUp` → `click` |
73
+ | 예시: type | 값을 직접 설정 | 키보드 입력을 하나씩 시뮬레이션 |
74
+ | 포커스 | 자동 처리 안 됨 | 자동으로 포커스 이동 |
75
+ | 권장 여부 | 특수한 경우에만 | **기본으로 사용** |
76
+
77
+ ```typescript
78
+ import userEvent from '@testing-library/user-event';
79
+
80
+ it('should submit form with user input', async () => {
81
+ // Arrange
82
+ const user = userEvent.setup();
83
+ render(<LoginForm onSubmit={mockSubmit} />);
84
+
85
+ // Act
86
+ await user.type(screen.getByLabelText('이메일'), 'test@test.com');
87
+ await user.type(screen.getByLabelText('비밀번호'), 'password123');
88
+ await user.click(screen.getByRole('button', { name: '로그인' }));
89
+
90
+ // Assert
91
+ expect(mockSubmit).toHaveBeenCalledWith({
92
+ email: 'test@test.com',
93
+ password: 'password123',
94
+ });
95
+ });
96
+ ```
97
+
98
+ ### fireEvent 사용이 적절한 경우
99
+ - `scroll`, `resize` 등 userEvent가 지원하지 않는 이벤트
100
+ - 특정 DOM 이벤트를 정밀하게 제어해야 하는 경우
101
+
102
+ ---
103
+
104
+ ## 4. 비동기 렌더링 테스트
105
+
106
+ ### waitFor
107
+
108
+ 상태 변경 후 DOM 업데이트를 기다린다.
109
+
110
+ ```typescript
111
+ it('should show user list after loading', async () => {
112
+ // Arrange
113
+ render(<UserList />);
114
+
115
+ // Act & Assert
116
+ await waitFor(() => {
117
+ expect(screen.getByText('John')).toBeInTheDocument();
118
+ });
119
+ });
120
+ ```
121
+
122
+ ### findBy*
123
+
124
+ `getBy` + `waitFor`의 축약형이다. 비동기로 나타나는 요소를 조회할 때 사용한다.
125
+
126
+ ```typescript
127
+ it('should display success message after save', async () => {
128
+ // Arrange
129
+ const user = userEvent.setup();
130
+ render(<UserForm />);
131
+
132
+ // Act
133
+ await user.click(screen.getByRole('button', { name: '저장' }));
134
+
135
+ // Assert
136
+ const successMessage = await screen.findByText('저장되었습니다');
137
+ expect(successMessage).toBeInTheDocument();
138
+ });
139
+ ```
140
+
141
+ ### queryBy*
142
+
143
+ 요소가 **없음**을 확인할 때 사용한다 (`getBy`는 없으면 에러를 던진다).
144
+
145
+ ```typescript
146
+ it('should hide error message initially', () => {
147
+ render(<LoginForm />);
148
+
149
+ expect(screen.queryByText('로그인 실패')).not.toBeInTheDocument();
150
+ });
151
+ ```
152
+
153
+ ### 규칙
154
+ - 비동기 요소 조회: `findBy*` 사용
155
+ - 요소 부재 확인: `queryBy*` 사용
156
+ - 복잡한 비동기 대기: `waitFor` 사용
157
+ - `waitFor` 안에서 부수 효과(side effect)를 실행하지 않는다
158
+
159
+ ---
160
+
161
+ ## 5. renderHook 테스트
162
+
163
+ 커스텀 훅은 `renderHook`으로 테스트한다.
164
+
165
+ ```typescript
166
+ import { renderHook, act } from '@testing-library/react';
167
+ import { useCounter } from './useCounter';
168
+
169
+ describe('useCounter', () => {
170
+ it('should initialize with default value', () => {
171
+ // Arrange & Act
172
+ const { result } = renderHook(() => useCounter(0));
173
+
174
+ // Assert
175
+ expect(result.current.count).toBe(0);
176
+ });
177
+
178
+ it('should increment counter', () => {
179
+ // Arrange
180
+ const { result } = renderHook(() => useCounter(0));
181
+
182
+ // Act
183
+ act(() => {
184
+ result.current.increment();
185
+ });
186
+
187
+ // Assert
188
+ expect(result.current.count).toBe(1);
189
+ });
190
+
191
+ it('should reset counter to initial value', () => {
192
+ // Arrange
193
+ const { result } = renderHook(() => useCounter(5));
194
+
195
+ // Act
196
+ act(() => {
197
+ result.current.increment();
198
+ result.current.increment();
199
+ result.current.reset();
200
+ });
201
+
202
+ // Assert
203
+ expect(result.current.count).toBe(5);
204
+ });
205
+ });
206
+ ```
207
+
208
+ ### 규칙
209
+ - 상태 변경은 반드시 `act()`로 감싼다
210
+ - `result.current`로 최신 값에 접근한다
211
+ - Provider가 필요한 훅은 `wrapper` 옵션을 사용한다 (아래 섹션 참고)
212
+
213
+ ---
214
+
215
+ ## 6. Provider Wrapper 패턴
216
+
217
+ 외부 Provider에 의존하는 컴포넌트/훅 테스트 시 wrapper를 제공한다.
218
+
219
+ ### 재사용 가능한 wrapper 함수
220
+
221
+ ```typescript
222
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
223
+ import { BrowserRouter } from 'react-router-dom';
224
+ import { render, renderHook, RenderOptions } from '@testing-library/react';
225
+ import { ReactElement, ReactNode } from 'react';
226
+
227
+ function createTestQueryClient() {
228
+ return new QueryClient({
229
+ defaultOptions: {
230
+ queries: {
231
+ retry: false,
232
+ gcTime: 0,
233
+ },
234
+ },
235
+ });
236
+ }
237
+
238
+ function AllProviders({ children }: { children: ReactNode }) {
239
+ const queryClient = createTestQueryClient();
240
+ return (
241
+ <QueryClientProvider client={queryClient}>
242
+ <BrowserRouter>
243
+ {children}
244
+ </BrowserRouter>
245
+ </QueryClientProvider>
246
+ );
247
+ }
248
+
249
+ export function renderWithProviders(
250
+ ui: ReactElement,
251
+ options?: Omit<RenderOptions, 'wrapper'>,
252
+ ) {
253
+ return render(ui, { wrapper: AllProviders, ...options });
254
+ }
255
+
256
+ export function renderHookWithProviders<T>(hook: () => T) {
257
+ return renderHook(hook, { wrapper: AllProviders });
258
+ }
259
+ ```
260
+
261
+ ### 사용 예시
262
+
263
+ ```typescript
264
+ import { renderWithProviders } from '../test-utils';
265
+
266
+ it('should render user profile', async () => {
267
+ renderWithProviders(<UserProfile userId="1" />);
268
+
269
+ await waitFor(() => {
270
+ expect(screen.getByText('John')).toBeInTheDocument();
271
+ });
272
+ });
273
+ ```
274
+
275
+ ### 규칙
276
+ - 테스트용 `QueryClient`는 `retry: false`로 설정한다 (실패 시 즉시 에러)
277
+ - 각 테스트마다 새로운 `QueryClient` 인스턴스를 생성한다 (캐시 격리)
278
+ - wrapper 유틸은 `test-utils.tsx`에 정의하여 재사용한다
279
+
280
+ ---
281
+
282
+ ## 7. MSW (Mock Service Worker)
283
+
284
+ API 호출을 네트워크 수준에서 가로채어 Mock 응답을 제공한다.
285
+
286
+ ### Handler 정의
287
+
288
+ ```typescript
289
+ import { http, HttpResponse } from 'msw';
290
+
291
+ export const handlers = [
292
+ http.get('/api/users', () => {
293
+ return HttpResponse.json([
294
+ { id: '1', name: 'John', email: 'john@test.com' },
295
+ { id: '2', name: 'Jane', email: 'jane@test.com' },
296
+ ]);
297
+ }),
298
+
299
+ http.post('/api/users', async ({ request }) => {
300
+ const body = await request.json();
301
+ return HttpResponse.json(
302
+ { id: '3', ...body },
303
+ { status: 201 },
304
+ );
305
+ }),
306
+
307
+ http.get('/api/users/:id', ({ params }) => {
308
+ const { id } = params;
309
+ if (id === '999') {
310
+ return HttpResponse.json(
311
+ { message: 'User not found' },
312
+ { status: 404 },
313
+ );
314
+ }
315
+ return HttpResponse.json({ id, name: 'John', email: 'john@test.com' });
316
+ }),
317
+ ];
318
+ ```
319
+
320
+ ### 서버 설정
321
+
322
+ ```typescript
323
+ import { setupServer } from 'msw/node';
324
+ import { handlers } from './handlers';
325
+
326
+ export const server = setupServer(...handlers);
327
+
328
+ // jest.setup.ts 또는 vitest.setup.ts
329
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
330
+ afterEach(() => server.resetHandlers());
331
+ afterAll(() => server.close());
332
+ ```
333
+
334
+ ### 테스트에서 핸들러 오버라이드
335
+
336
+ ```typescript
337
+ import { server } from '../mocks/server';
338
+ import { http, HttpResponse } from 'msw';
339
+
340
+ it('should show error message when API fails', async () => {
341
+ // Arrange - 이 테스트에서만 에러 응답으로 오버라이드
342
+ server.use(
343
+ http.get('/api/users', () => {
344
+ return HttpResponse.json(
345
+ { message: 'Internal Server Error' },
346
+ { status: 500 },
347
+ );
348
+ }),
349
+ );
350
+
351
+ render(<UserList />);
352
+
353
+ // Assert
354
+ const errorMessage = await screen.findByText('데이터를 불러올 수 없습니다');
355
+ expect(errorMessage).toBeInTheDocument();
356
+ });
357
+ ```
358
+
359
+ ### 규칙
360
+ - 기본 핸들러(성공 케이스)는 `handlers.ts`에 정의한다
361
+ - 에러/예외 케이스는 개별 테스트에서 `server.use()`로 오버라이드한다
362
+ - `onUnhandledRequest: 'error'`로 설정하여 미처리된 요청을 감지한다
363
+ - `afterEach`에서 `server.resetHandlers()`로 오버라이드를 초기화한다
364
+
365
+ ---
366
+
367
+ ## 8. 파일 위치 및 네이밍
368
+
369
+ | 대상 | 위치 | 예시 |
370
+ |------|------|------|
371
+ | 컴포넌트 테스트 | 소스 파일과 같은 디렉토리 | `UserCard.test.tsx` |
372
+ | 훅 테스트 | 소스 파일과 같은 디렉토리 | `useAuth.test.ts` |
373
+ | 유틸 테스트 | 소스 파일과 같은 디렉토리 | `formatDate.test.ts` |
374
+ | MSW 핸들러 | `src/mocks/` | `src/mocks/handlers.ts` |
375
+ | 테스트 유틸 | `src/test-utils.tsx` | 공통 render, wrapper 등 |
376
+
377
+ ### 규칙
378
+ - 컴포넌트 테스트 확장자: `*.test.tsx`
379
+ - 훅/유틸 테스트 확장자: `*.test.ts`
380
+ - 테스트 파일은 테스트 대상과 같은 이름을 사용한다
381
+
382
+ ---
383
+
384
+ ## 9. 금지 사항
385
+
386
+ - `container.querySelector`로 DOM을 직접 조회 금지 (Testing Library 쿼리 사용)
387
+ - 컴포넌트 내부 상태를 직접 접근하여 검증 금지
388
+ - `fireEvent`를 기본으로 사용 금지 (`userEvent` 우선)
389
+ - 스냅샷 테스트를 행동 테스트의 대체로 사용 금지
390
+ - `waitFor` 안에서 부수 효과 실행 금지 (조회/단언만 수행)
391
+ - `act` 경고를 무시하거나 억제 금지 (원인을 해결)
392
+ - `data-testid` 남용 금지 (시맨틱 쿼리 우선)