개발자를 위한 Claude Code React 단위 테스트 종합 가이드 - k82022603/k82022603.github.io GitHub Wiki
개발자를 위한 Claude Code React 단위 테스트 종합 가이드 #
테스트 작성을 미루는 이유:
1. 지루하다 (반복적인 setup/teardown)
2. 시간이 없다 (기능 개발에 밀려남)
3. 뭘 테스트해야 할지 모르겠다
4. Mock 설정이 복잡하다
결과:
- 테스트 커버리지 20%
- 버그는 프로덕션에서 발견
- 리팩토링 공포증
$ claude
> UserProfile 컴포넌트의 테스트를 작성해줘.
> React Testing Library 사용하고,
> 모든 사용자 인터랙션을 테스트해줘.
→ 3분 후:
- 12개 테스트 케이스
- 95% 코드 커버리지
- Mock 자동 생성
- 실행 후 모두 통과시간 비교:
수동 작성: 2시간 (컴포넌트당)
Claude Code: 3분
→ 40배 빠름
❌ Enzyme (구식):
// 구현 세부사항 테스트
const wrapper = shallow(<Button />);
expect(wrapper.find('.button-class')).toHaveLength(1);✅ React Testing Library (현대적):
// 사용자 관점 테스트
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();-
사용자가 하는 대로 테스트
- DOM 쿼리는 실제 사용자가 요소를 찾는 방식
- 구현 세부사항 무시
-
접근성 우선
-
getByRole,getByLabelText권장 - 스크린 리더가 읽는 방식
-
-
비동기 처리
-
waitFor,findBy*활용 - 실제 앱처럼 대기
-
# React Testing Standards
## Tech Stack
- **Testing Framework**: Jest 29 / Vitest 1.0
- **UI Testing**: React Testing Library 14
- **Mocking**: MSW (Mock Service Worker) 2.0
- **Coverage Target**: 80% lines, 75% branches
## Component Categories
### 1. Presentational Components (UI only)
**Test Count**: 5-8 tests
**Focus**:
- Props rendering
- User interactions (click, type, submit)
- Conditional rendering
- Accessibility
### 2. Container Components (with state/effects)
**Test Count**: 8-12 tests
**Focus**:
- Data fetching
- State updates
- Side effects
- Error handling
### 3. Form Components
**Test Count**: 10-15 tests
**Focus**:
- Validation
- Submission
- Error messages
- Disabled states
## Testing Principles
### ✅ DO:
- Test user behavior, not implementation
- Use semantic queries (`getByRole`, `getByLabelText`)
- Test accessibility
- Use `userEvent` for interactions
- Test error states
- Mock external dependencies (APIs, localStorage)
### ❌ DON'T:
- Test framework internals (React.useState works)
- Test CSS classes or styles
- Use `container.querySelector`
- Test library code (axios works)
- Create separate mock files for every dependency
## Query Priority
1. `getByRole` (best - accessibility)
2. `getByLabelText` (forms)
3. `getByPlaceholderText` (if no label)
4. `getByText` (non-interactive)
5. `getByTestId` (last resort)
## File Structure
```
src/
components/
Button/
Button.tsx
Button.test.tsx ← Test next to component
index.ts
```
## Test Template
```javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentName } from './ComponentName';
describe('ComponentName', () => {
it('should render with required props', () => {
render(<ComponentName prop="value" />);
expect(screen.getByRole('...')).toBeInTheDocument();
});
it('should handle user interaction', async () => {
const user = userEvent.setup();
render(<ComponentName />);
await user.click(screen.getByRole('button'));
expect(screen.getByText(/result/i)).toBeInTheDocument();
});
});
```
## Mock Strategy
### API Mocking (MSW)
```javascript
// src/mocks/handlers.js
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([{ id: 1, name: 'Test' }])
})
];
```
### Function Mocking
```javascript
const mockOnClick = jest.fn();
```
### Module Mocking
```javascript
jest.mock('./api', () => ({
fetchUser: jest.fn()
}));
```
## Coverage Threshold
Enforce in jest.config.js:
```javascript
coverageThreshold: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80
}
}
```
## Common Patterns to Avoid
### ❌ Testing Implementation
```javascript
// Bad
expect(component.state.count).toBe(1);
```
### ✅ Testing Behavior
```javascript
// Good
expect(screen.getByText('Count: 1')).toBeInTheDocument();
```
### ❌ Waiting with setTimeout
```javascript
// Bad
setTimeout(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
}, 1000);
```
### ✅ Waiting Properly
```javascript
// Good
await screen.findByText('Loaded');
// or
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
```.claude/agents/react-component-tester.md:
---
name: react-component-tester
description: |
Expert in writing React component unit tests.
Triggers when user asks to test React components,
write tests for UI, or mentions React Testing Library.
tools:
- Read
- Write
- Edit
- Bash
- Grep
model: sonnet
---
# React Component Testing Specialist
You write comprehensive React component tests using React Testing Library.
## Analysis Phase
Before writing tests:
1. Read the component file
2. Identify component type (presentational/container/form)
3. List all props and their types
4. Find all user interactions (clicks, inputs, etc.)
5. Identify conditional rendering
6. Check for API calls or side effects
## Test Structure
```javascript
describe('ComponentName', () => {
// Group 1: Rendering
describe('Rendering', () => {
it('renders with required props');
it('renders with optional props');
it('renders without crashing');
});
// Group 2: User Interactions
describe('User Interactions', () => {
it('handles click events');
it('handles input changes');
it('handles form submission');
});
// Group 3: Conditional Logic
describe('Conditional Rendering', () => {
it('shows loading state');
it('shows error state');
it('shows empty state');
});
// Group 4: Integration
describe('Integration', () => {
it('calls API on mount');
it('updates on prop changes');
});
});
```
## Query Selection
1. Prefer `getByRole`:
```javascript
screen.getByRole('button', { name: /submit/i })
```
2. Use `getByLabelText` for forms:
```javascript
screen.getByLabelText(/email/i)
```
3. Use `getByTestId` as last resort:
```javascript
screen.getByTestId('custom-element')
```
## User Event Pattern
Always use `userEvent.setup()`:
```javascript
const user = userEvent.setup();
await user.click(element);
await user.type(input, 'text');
```
## Async Testing
Use `findBy*` or `waitFor`:
```javascript
// Preferred
const element = await screen.findByText(/loaded/i);
// Alternative
await waitFor(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument();
});
```
## Mock Strategy
1. Mock API calls with MSW
2. Mock functions with `jest.fn()`
3. Mock modules only when necessary
4. Never mock React internals
## Completion Checklist
Before finishing:
- [ ] All user interactions tested
- [ ] All conditional branches tested
- [ ] All props tested
- [ ] Accessibility verified
- [ ] Tests run and pass
- [ ] No act() warnings
- [ ] Coverage > 80%
```
### 2. Hook Test Specialist
**.claude/agents/react-hook-tester.md:**
```markdown
---
name: react-hook-tester
description: |
Specialist in testing React custom hooks.
Triggers for "test hook", "custom hook testing".
tools: [Read, Write, Edit, Bash]
model: sonnet
---
# React Hook Testing Expert
Test React hooks using `@testing-library/react-hooks`.
## Hook Test Pattern
```javascript
import { renderHook, act } from '@testing-library/react';
import { useCustomHook } from './useCustomHook';
describe('useCustomHook', () => {
it('initializes with default values', () => {
const { result } = renderHook(() => useCustomHook());
expect(result.current.value).toBe(initialValue);
});
it('updates state on action', () => {
const { result } = renderHook(() => useCustomHook());
act(() => {
result.current.updateValue(newValue);
});
expect(result.current.value).toBe(newValue);
});
});
```
## Testing Hooks with Dependencies
```javascript
it('refetches on dependency change', () => {
const { result, rerender } = renderHook(
({ id }) => useUser(id),
{ initialProps: { id: 1 } }
);
expect(result.current.user.id).toBe(1);
rerender({ id: 2 });
expect(result.current.user.id).toBe(2);
});
```
## Testing Async Hooks
```javascript
it('fetches data asynchronously', async () => {
const { result } = renderHook(() => useAsyncData());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
});
```
```
### 3. Mock Generator
**.claude/agents/mock-generator.md:**
```markdown
---
name: mock-generator
description: |
Generates realistic mocks and fixtures for tests.
Triggers for "create mock", "generate test data".
tools: [Read, Write]
model: sonnet
---
# Mock Data Generator
Create realistic mocks for React tests.
## MSW Handlers
```javascript
// src/mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
email: '[email protected]'
});
}),
http.post('/api/users', async ({ request }) => {
const data = await request.json();
return HttpResponse.json(
{ id: '123', ...data },
{ status: 201 }
);
}),
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' }
]);
})
];
```
## Test Fixtures
```javascript
// src/test-utils/fixtures.js
export const mockUser = {
id: '1',
name: 'Test User',
email: '[email protected]',
role: 'admin'
};
export const mockUsers = [
mockUser,
{ id: '2', name: 'User 2', email: '[email protected]', role: 'user' }
];
export const mockPost = {
id: '1',
title: 'Test Post',
content: 'Test content',
author: mockUser
};
```
## Component Mocks
```javascript
// __mocks__/MyComponent.tsx
export const MyComponent = ({ children, ...props }) => (
<div data-testid="my-component-mock" {...props}>
{children}
</div>
);
```.claude/commands/test.md:
# /test
Generate comprehensive React component tests.
Usage: `/test ComponentName`
## Workflow
1. Read component file: `src/components/$ARGUMENTS.tsx`
2. Analyze:
- Component type (presentational/container)
- Props and their types
- User interactions
- Conditional rendering
- API calls
3. Generate test file: `src/components/$ARGUMENTS.test.tsx`
4. Run tests: `npm test $ARGUMENTS.test`
5. Fix any failures
6. Report coverage
## Test Requirements
- Use React Testing Library
- Follow SKILL.md standards
- Test all user interactions
- Mock API calls with MSW
- Aim for 80%+ coverage
- No act() warnings
- All tests must pass
## Output
Report:
- Number of tests created
- Coverage percentage
- Any issues found
- Suggestions for improvement// src/components/Button/Button.tsx
import { ButtonHTMLAttributes } from 'react';
import './Button.css';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
loading?: boolean;
icon?: React.ReactNode;
}
export const Button = ({
children,
variant = 'primary',
size = 'medium',
loading = false,
icon,
disabled,
...props
}: ButtonProps) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
{...props}
>
{loading && <span className="spinner" />}
{icon && <span className="icon">{icon}</span>}
{children}
</button>
);
};$ claude
> Button 컴포넌트의 테스트를 작성해줘.
> React Testing Library 사용하고,
> 모든 props와 상태를 테스트해줘.
⏺ Spawning react-component-tester agent...
# Agent가 컴포넌트 분석
⏺ Reading Button.tsx...
⏺ Analyzing component:
- Type: Presentational
- Props: variant, size, loading, icon, disabled, children
- Interactions: click
- Conditional: loading spinner, icon
⏺ Generating comprehensive tests...// src/components/Button/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
describe('Rendering', () => {
it('renders with children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i }))
.toBeInTheDocument();
});
it('renders with default props', () => {
render(<Button>Default</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-primary');
expect(button).toHaveClass('btn-medium');
});
it('applies variant class', () => {
render(<Button variant="danger">Delete</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-danger');
});
it('applies size class', () => {
render(<Button size="large">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('btn-large');
});
it('renders with icon', () => {
const icon = <span data-testid="test-icon">🔥</span>;
render(<Button icon={icon}>With Icon</Button>);
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
expect(screen.getByRole('button')).toHaveTextContent('With Icon');
});
});
describe('Loading State', () => {
it('shows spinner when loading', () => {
const { container } = render(<Button loading>Loading</Button>);
expect(container.querySelector('.spinner')).toBeInTheDocument();
});
it('disables button when loading', () => {
render(<Button loading>Loading</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('does not trigger onClick when loading', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button loading onClick={handleClick}>Loading</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
describe('Disabled State', () => {
it('disables button when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('does not trigger onClick when disabled', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button disabled onClick={handleClick}>Disabled</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
describe('User Interactions', () => {
it('calls onClick when clicked', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('calls onClick multiple times', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.tripleClick(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(3);
});
});
describe('Accessibility', () => {
it('is keyboard accessible', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Accessible</Button>);
const button = screen.getByRole('button');
button.focus();
await user.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalled();
});
it('has correct ARIA attributes when disabled', () => {
render(<Button disabled>Disabled</Button>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('disabled');
});
});
describe('HTML Attributes', () => {
it('spreads additional props', () => {
render(<Button data-testid="custom-button" id="my-button">Button</Button>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('data-testid', 'custom-button');
expect(button).toHaveAttribute('id', 'my-button');
});
it('supports type attribute', () => {
render(<Button type="submit">Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
});
});
⏺ Running tests...
⏺ Bash(npm test Button.test)
PASS src/components/Button/Button.test.tsx
Button
Rendering
✓ renders with children
✓ renders with default props
✓ applies variant class
✓ applies size class
✓ renders with icon
Loading State
✓ shows spinner when loading
✓ disables button when loading
✓ does not trigger onClick when loading
Disabled State
✓ disables button when disabled prop is true
✓ does not trigger onClick when disabled
User Interactions
✓ calls onClick when clicked
✓ calls onClick multiple times
Accessibility
✓ is keyboard accessible
✓ has correct ARIA attributes when disabled
HTML Attributes
✓ spreads additional props
✓ supports type attribute
Test Suites: 1 passed, 1 total
Tests: 16 passed, 16 total
Coverage: 95.2% lines, 92.5% branches
✅ All tests passed
✅ Coverage threshold met (95.2% > 80%)$ claude
> TDD 방식으로 LoginForm 컴포넌트를 개발해줘:
>
> 요구사항:
> - email, password 입력
> - 유효성 검사 (이메일 형식, 비밀번호 최소 8자)
> - 제출 시 API 호출
> - 로딩/에러 상태
>
> 프로세스:
> 1. 실패하는 테스트 작성 (Red)
> 2. 최소 구현 (Green)
> 3. 리팩토링
>
> 서브 에이전트 활용해서 병렬로 처리해줘.⏺ Starting TDD workflow with 3 sub-agents:
1. test-writer: Write failing tests
2. code-writer: Minimal implementation
3. refactorer: Clean up code
⏺ Phase 1: RED - Writing failing tests...
Sub-agent 1: Test Writer
// src/components/LoginForm/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
describe('Rendering', () => {
it('renders email and password fields', () => {
render(<LoginForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it('renders submit button', () => {
render(<LoginForm onSubmit={jest.fn()} />);
expect(screen.getByRole('button', { name: /sign in/i }))
.toBeInTheDocument();
});
});
describe('Validation', () => {
it('shows error for invalid email format', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
});
it('shows error for short password', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit=jest.fn()} />);
await user.type(screen.getByLabelText(/password/i), '123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/password must be at least 8 characters/i))
.toBeInTheDocument();
});
it('does not show errors for valid input', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
expect(screen.queryByText(/invalid/i)).not.toBeInTheDocument();
});
});
describe('Submission', () => {
it('calls onSubmit with form data', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123'
});
});
});
it('prevents submission with invalid data', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'invalid');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(handleSubmit).not.toHaveBeenCalled();
});
});
describe('Loading State', () => {
it('disables form during submission', async () => {
const handleSubmit = jest.fn(() => new Promise(resolve => setTimeout(resolve, 100)));
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByLabelText(/email/i)).toBeDisabled();
expect(screen.getByLabelText(/password/i)).toBeDisabled();
});
it('shows loading indicator', async () => {
const handleSubmit = jest.fn(() => new Promise(resolve => setTimeout(resolve, 100)));
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/signing in/i)).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('shows error message on submission failure', async () => {
const handleSubmit = jest.fn(() => Promise.reject(new Error('Invalid credentials')));
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
});
it('allows retry after error', async () => {
let callCount = 0;
const handleSubmit = jest.fn(() => {
callCount++;
if (callCount === 1) return Promise.reject(new Error('Error'));
return Promise.resolve();
});
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
// First attempt (fails)
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await screen.findByText(/error/i);
// Second attempt (succeeds)
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(handleSubmit).toHaveBeenCalledTimes(2);
});
});
});
⏺ Running tests...
❌ All 13 tests failed (component doesn't exist yet)
⏺ Phase 2: GREEN - Writing minimal implementation...Sub-agent 2: Code Writer
// src/components/LoginForm/LoginForm.tsx
import { useState, FormEvent } from 'react';
interface LoginFormProps {
onSubmit: (data: { email: string; password: string }) => Promise<void>;
}
export const LoginForm = ({ onSubmit }: LoginFormProps) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const [loading, setLoading] = useState(false);
const [submitError, setSubmitError] = useState('');
const validateEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const validate = () => {
const newErrors: { email?: string; password?: string } = {};
if (!validateEmail(email)) {
newErrors.email = 'Invalid email format';
}
if (password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setSubmitError('');
if (!validate()) {
return;
}
setLoading(true);
try {
await onSubmit({ email, password });
} catch (error) {
setSubmitError((error as Error).message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
{errors.email && <span>{errors.email}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
{errors.password && <span>{errors.password}</span>}
</div>
{submitError && <div>{submitError}</div>}
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
);
};
⏺ Running tests again...
✅ All 13 tests passed
⏺ Phase 3: REFACTOR - Improving code quality...Sub-agent 3: Refactorer
// src/components/LoginForm/LoginForm.tsx (refactored)
import { useState, FormEvent, ChangeEvent } from 'react';
import { validateEmail } from '@/utils/validation';
import { Input } from '@/components/Input';
import { Button } from '@/components/Button';
import { ErrorMessage } from '@/components/ErrorMessage';
import './LoginForm.css';
interface LoginFormProps {
onSubmit: (data: LoginFormData) => Promise<void>;
}
interface LoginFormData {
email: string;
password: string;
}
interface FormErrors {
email?: string;
password?: string;
}
const MIN_PASSWORD_LENGTH = 8;
export const LoginForm = ({ onSubmit }: LoginFormProps) => {
const [formData, setFormData] = useState<LoginFormData>({
email: '',
password: ''
});
const [errors, setErrors] = useState<FormErrors>({});
const [loading, setLoading] = useState(false);
const [submitError, setSubmitError] = useState('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user types
if (errors[name as keyof FormErrors]) {
setErrors(prev => ({ ...prev, [name]: undefined }));
}
};
const validate = (): boolean => {
const newErrors: FormErrors = {};
if (!validateEmail(formData.email)) {
newErrors.email = 'Invalid email format';
}
if (formData.password.length < MIN_PASSWORD_LENGTH) {
newErrors.password = `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setSubmitError('');
if (!validate()) return;
setLoading(true);
try {
await onSubmit(formData);
} catch (error) {
setSubmitError((error as Error).message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="login-form">
<Input
label="Email"
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
error={errors.email}
disabled={loading}
/>
<Input
label="Password"
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
error={errors.password}
disabled={loading}
/>
{submitError && <ErrorMessage message={submitError} />}
<Button type="submit" loading={loading} fullWidth>
Sign In
</Button>
</form>
);
};
⏺ Running tests final time...
✅ All 13 tests still passed
✅ Code coverage: 96.8%
✅ No ESLint warnings
✅ No act() warnings
⏺ TDD cycle completed successfully결과 요약:
✅ TDD Workflow Completed
Phase 1 (RED): 13 failing tests written
Phase 2 (GREEN): Minimal implementation → all tests pass
Phase 3 (REFACTOR): Code quality improved, tests still pass
Files Created:
- LoginForm.tsx (95 lines)
- LoginForm.test.tsx (180 lines)
- LoginForm.css (25 lines)
Test Results:
- 13 tests, all passing
- Coverage: 96.8% lines, 95% branches
- Execution time: 1.2s
Next Steps:
- Add to Storybook?
- Create integration test?
- Add to design system docs?
// src/hooks/useAuth.ts
import { useState, useEffect } from 'react';
import { authAPI } from '@/api/auth';
export const useAuth = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
const userData = await authAPI.me();
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const login = async (email, password) => {
setLoading(true);
try {
const userData = await authAPI.login(email, password);
setUser(userData);
return userData;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
};
const logout = async () => {
await authAPI.logout();
setUser(null);
};
return { user, loading, error, login, logout };
};$ claude
> useAuth 훅의 테스트를 작성해줘.
> renderHook 사용하고 MSW로 API 모킹해줘.
⏺ Spawning react-hook-tester...// src/hooks/useAuth.test.ts
import { renderHook, waitFor, act } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { useAuth } from './useAuth';
const server = setupServer(
http.get('/api/auth/me', () => {
return HttpResponse.json({
id: '1',
name: 'Test User',
email: '[email protected]'
});
}),
http.post('/api/auth/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === '[email protected]' && password === 'password123') {
return HttpResponse.json({
id: '1',
name: 'Test User',
email: '[email protected]',
token: 'fake-jwt-token'
});
}
return HttpResponse.json(
{ message: 'Invalid credentials' },
{ status: 401 }
);
}),
http.post('/api/auth/logout', () => {
return new HttpResponse(null, { status: 204 });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('useAuth', () => {
describe('Initialization', () => {
it('starts with loading state', () => {
const { result } = renderHook(() => useAuth());
expect(result.current.loading).toBe(true);
expect(result.current.user).toBeNull();
});
it('loads user on mount', async () => {
const { result } = renderHook(() => useAuth());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual({
id: '1',
name: 'Test User',
email: '[email protected]'
});
});
it('handles auth check error', async () => {
server.use(
http.get('/api/auth/me', () => {
return HttpResponse.json(
{ message: 'Unauthorized' },
{ status: 401 }
);
})
);
const { result } = renderHook(() => useAuth());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(result.current.error).toBeDefined();
});
});
describe('Login', () => {
it('logs in successfully', async () => {
const { result } = renderHook(() => useAuth());
// Wait for initial load
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Perform login
await act(async () => {
await result.current.login('[email protected]', 'password123');
});
expect(result.current.user).toEqual({
id: '1',
name: 'Test User',
email: '[email protected]',
token: 'fake-jwt-token'
});
expect(result.current.error).toBeNull();
});
it('handles login error', async () => {
const { result } = renderHook(() => useAuth());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await expect(
act(async () => {
await result.current.login('[email protected]', 'wrongpass');
})
).rejects.toThrow();
expect(result.current.user).toBeNull();
expect(result.current.error).toBeDefined();
});
it('sets loading during login', async () => {
const { result } = renderHook(() => useAuth());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
let loginPromise;
act(() => {
loginPromise = result.current.login('[email protected]', 'password123');
});
expect(result.current.loading).toBe(true);
await act(async () => {
await loginPromise;
});
expect(result.current.loading).toBe(false);
});
});
describe('Logout', () => {
it('logs out successfully', async () => {
const { result } = renderHook(() => useAuth());
// Wait for initial load (user loaded)
await waitFor(() => {
expect(result.current.user).toBeDefined();
});
// Logout
await act(async () => {
await result.current.logout();
});
expect(result.current.user).toBeNull();
});
});
});
⏺ Running tests...
✅ All 8 tests passed
✅ Coverage: 94.3%// ✅ 최선: Accessible role
screen.getByRole('button', { name: /submit/i })
// ✅ 좋음: Label
screen.getByLabelText(/email/i)
// ⚠️ 괜찮음: Placeholder (label 없을 때만)
screen.getByPlaceholderText(/search/i)
// ⚠️ 최후: Test ID
screen.getByTestId('submit-button')
// ❌ 나쁨: querySelector
container.querySelector('.submit-button')// ✅ 권장: findBy (자동 waitFor)
const element = await screen.findByText(/loaded/i);
// ✅ 좋음: waitFor
await waitFor(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument();
});
// ❌ 나쁨: setTimeout
setTimeout(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument();
}, 1000);// ✅ 최선: userEvent.setup()
const user = userEvent.setup();
await user.click(button);
await user.type(input, 'text');
// ⚠️ 괜찮음: fireEvent (간단한 경우)
fireEvent.click(button);
// ❌ 나쁨: 직접 핸들러 호출
props.onClick();// ✅ API: MSW
server.use(
http.get('/api/users', () => HttpResponse.json([]))
);
// ✅ 함수: jest.fn()
const mockFn = jest.fn();
// ⚠️ 모듈: 필요시만
jest.mock('./api');
// ❌ React 내부
jest.mock('react');name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
- name: Check coverage threshold
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% is below 80%"
exit 1
fiClaude Code는 React 개발자를 위한 테스트 자동화 시스템입니다.
핵심 가치:
- 40배 빠른 테스트 작성: 2시간 → 3분
- 서브 에이전트 활용: 병렬 처리로 효율 극대화
- TDD 자동화: Red-Green-Refactor 자동 순환
- 80%+ 커버리지: 자동으로 높은 품질 달성
- Best Practices: RTL 패턴 자동 적용
시작하기:
$ brew install anthropic/claude/claude-code
$ cd my-react-app
$ claude
> UserProfile 컴포넌트 테스트 작성해줘테스트는 이제 "작성하는 것"이 아니라 "요청하는 것"입니다!
문서 작성 일자: 2026-01-22