React 요약 13 - 테스팅
Updated:
Jest와 React Testing Library
// Button.jsx
import React from 'react';
function Button({ onClick, disabled, children }) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`button ${disabled ? 'button-disabled' : ''}`}
>
{children}
</button>
);
}
export default Button;
// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button Component', () => {
test('renders button with correct text', () => {
render(<Button>Click me</Button>);
// 버튼 텍스트 확인
const buttonElement = screen.getByText('Click me');
expect(buttonElement).toBeInTheDocument();
});
test('calls onClick handler when clicked', () => {
// 모의 함수 생성
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
// 버튼 클릭
const buttonElement = screen.getByText('Click me');
fireEvent.click(buttonElement);
// 클릭 핸들러가 호출되었는지 확인
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
const buttonElement = screen.getByText('Click me');
// disabled 속성 확인
expect(buttonElement).toBeDisabled();
// 클래스 확인
expect(buttonElement).toHaveClass('button-disabled');
});
});
// Counter.jsx
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(0);
return (
<div>
<h2>Counter: {count}</h2>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={reset}>Reset</button>
</div>
);
}
export default Counter;
// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter Component', () => {
test('renders with initial count of 0', () => {
render(<Counter />);
// 초기 카운트 확인
expect(screen.getByText('Counter: 0')).toBeInTheDocument();
});
test('increments count when increment button is clicked', () => {
render(<Counter />);
// 증가 버튼 클릭
fireEvent.click(screen.getByText('Increment'));
// 카운트가 1로 증가했는지 확인
expect(screen.getByText('Counter: 1')).toBeInTheDocument();
});
test('decrements count when decrement button is clicked', () => {
render(<Counter />);
// 감소 버튼 클릭
fireEvent.click(screen.getByText('Decrement'));
// 카운트가 -1로 감소했는지 확인
expect(screen.getByText('Counter: -1')).toBeInTheDocument();
});
test('resets count to 0 when reset button is clicked', () => {
render(<Counter />);
// 증가 버튼 여러 번 클릭
fireEvent.click(screen.getByText('Increment'));
fireEvent.click(screen.getByText('Increment'));
// 리셋 버튼 클릭
fireEvent.click(screen.getByText('Reset'));
// 카운트가 0으로 리셋되었는지 확인
expect(screen.getByText('Counter: 0')).toBeInTheDocument();
});
});
// UserProfile.jsx
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUser() {
try {
setLoading(true);
setError(null);
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <div data-testid="loading">Loading...</div>;
if (error) return <div data-testid="error">Error: {error}</div>;
if (!user) return null;
return (
<div data-testid="user-profile">
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Website: {user.website}</p>
</div>
);
}
export default UserProfile;
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
// fetch를 모킹
global.fetch = jest.fn();
describe('UserProfile Component', () => {
// 각 테스트 전에 모의 함수 초기화
beforeEach(() => {
fetch.mockClear();
});
test('shows loading state initially', () => {
// fetch 응답을 해결되지 않는 프로미스로 모킹
fetch.mockImplementationOnce(() => new Promise(() => {}));
render(<UserProfile userId={1} />);
// 로딩 상태 확인
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
test('renders user data when fetch succeeds', async () => {
// 성공적인 응답 모킹
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone: '123-456-7890',
website: 'johndoe.com'
};
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockUser)
})
);
render(<UserProfile userId={1} />);
// 비동기 작업이 완료될 때까지 대기
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
});
// 사용자 데이터가 올바르게 표시되는지 확인
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Email: john@example.com')).toBeInTheDocument();
expect(screen.getByText('Phone: 123-456-7890')).toBeInTheDocument();
expect(screen.getByText('Website: johndoe.com')).toBeInTheDocument();
// fetch가 올바른 URL로 호출되었는지 확인
expect(fetch).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users/1');
});
test('shows error when fetch fails', async () => {
// 실패한 응답 모킹
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: false,
status: 404
})
);
render(<UserProfile userId={1} />);
// 에러 상태가 표시될 때까지 대기
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument();
});
// 에러 메시지 확인
expect(screen.getByText('Error: Failed to fetch user')).toBeInTheDocument();
});
test('refetches when userId changes', async () => {
// 첫 번째 사용자에 대한 응답 모킹
const mockUser1 = { id: 1, name: 'John Doe', email: 'john@example.com', phone: '123', website: 'john.com' };
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockUser1)
})
);
const { rerender } = render(<UserProfile userId={1} />);
// 첫 번째 사용자 데이터가 로드될 때까지 대기
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// 두 번째 사용자에 대한 응답 모킹
const mockUser2 = { id: 2, name: 'Jane Smith', email: 'jane@example.com', phone: '456', website: 'jane.com' };
fetch.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockUser2)
})
);
// userId prop 변경하여 컴포넌트 다시 렌더링
rerender(<UserProfile userId={2} />);
// 두 번째 사용자 데이터가 로드될 때까지 대기
await waitFor(() => {
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
// fetch가 두 번 호출되었는지 확인
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenNthCalledWith(1, 'https://jsonplaceholder.typicode.com/users/1');
expect(fetch).toHaveBeenNthCalledWith(2, 'https://jsonplaceholder.typicode.com/users/2');
});
});
// Form.jsx
import { useState } from 'react';
function Form({ onSubmit }) {
const [formData, setFormData] = useState({
username: '',
email: '',
password: ''
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value
});
// 입력 시 해당 필드의 오류 지우기
if (errors[name]) {
setErrors({
...errors,
[name]: ''
});
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.username.trim()) {
newErrors.username = 'Username is required';
}
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validateForm()) {
onSubmit(formData);
}
};
return (
<form onSubmit={handleSubmit} data-testid="registration-form">
<div>
<label htmlFor="username">Username:</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleChange}
aria-invalid={!!errors.username}
/>
{errors.username && (
<span className="error" data-testid="username-error">
{errors.username}
</span>
)}
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
aria-invalid={!!errors.email}
/>
{errors.email && (
<span className="error" data-testid="email-error">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
aria-invalid={!!errors.password}
/>
{errors.password && (
<span className="error" data-testid="password-error">
{errors.password}
</span>
)}
</div>
<button type="submit">Register</button>
</form>
);
}
export default Form;
// Form.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Form from './Form';
describe('Form Component', () => {
test('renders form elements correctly', () => {
render(<Form onSubmit={() => {}} />);
// 폼 요소 확인
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument();
});
test('shows validation errors when form is submitted with empty fields', () => {
render(<Form onSubmit={() => {}} />);
// 빈 폼 제출
fireEvent.click(screen.getByRole('button', { name: /register/i }));
// 오류 메시지 확인
expect(screen.getByTestId('username-error')).toHaveTextContent('Username is required');
expect(screen.getByTestId('email-error')).toHaveTextContent('Email is required');
expect(screen.getByTestId('password-error')).toHaveTextContent('Password is required');
});
test('shows validation error for invalid email', () => {
render(<Form onSubmit={() => {}} />);
// 유효하지 않은 이메일 입력
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'invalid-email' }
});
// 폼 제출
fireEvent.click(screen.getByRole('button', { name: /register/i }));
// 이메일 오류 메시지 확인
expect(screen.getByTestId('email-error')).toHaveTextContent('Email is invalid');
});
test('shows validation error for short password', () => {
render(<Form onSubmit={() => {}} />);
// 짧은 비밀번호 입력
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: '12345' }
});
// 폼 제출
fireEvent.click(screen.getByRole('button', { name: /register/i }));
// 비밀번호 오류 메시지 확인
expect(screen.getByTestId('password-error')).toHaveTextContent('Password must be at least 6 characters');
});
test('calls onSubmit with form data when form is valid', () => {
const handleSubmit = jest.fn();
render(<Form onSubmit={handleSubmit} />);
// 유효한 데이터 입력
fireEvent.change(screen.getByLabelText(/username/i), {
target: { value: 'testuser' }
});
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'test@example.com' }
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: 'password123' }
});
// 폼 제출
fireEvent.click(screen.getByRole('button', { name: /register/i }));
// onSubmit이 올바른 데이터와 함께 호출되었는지 확인
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});
});
test('clears error when user types in a field with error', () => {
render(<Form onSubmit={() => {}} />);
// 빈 폼 제출하여 오류 발생
fireEvent.click(screen.getByRole('button', { name: /register/i }));
// 사용자 이름 필드에 입력
fireEvent.change(screen.getByLabelText(/username/i), {
target: { value: 'testuser' }
});
// 사용자 이름 오류가 사라졌는지 확인
expect(screen.queryByTestId('username-error')).not.toBeInTheDocument();
// 다른 오류는 여전히 존재하는지 확인
expect(screen.getByTestId('email-error')).toBeInTheDocument();
expect(screen.getByTestId('password-error')).toBeInTheDocument();
});
});
코멘트: Jest와 React Testing Library는 React 애플리케이션을 테스트하기 위한 강력한 조합입니다. 주요 특징은 다음과 같습니다:
- Jest: JavaScript 테스트 프레임워크
- 테스트 러너, 어설션 라이브러리, 모킹 기능 제공
- 스냅샷 테스팅, 코드 커버리지 보고서 지원
- 병렬 테스트 실행으로 성능 최적화
- React Testing Library: 사용자 중심 테스트 라이브러리
- DOM 노드를 직접 테스트하여 구현 세부 사항에 의존하지 않음
- 접근성 쿼리를 우선시하여 실제 사용자 경험에 가까운 테스트
- 간결한 API로 직관적인 테스트 작성
테스트 작성 시 주요 패턴:
- 컴포넌트 렌더링:
render()
- 요소 찾기:
getBy*
,queryBy*
,findBy*
쿼리- 사용자 이벤트 시뮬레이션:
fireEvent
또는userEvent
- 비동기 작업 처리:
waitFor
,findBy*
쿼리- 모킹:
jest.fn()
,jest.mock()
좋은 테스트는 구현 세부 사항이 아닌 사용자 행동과 결과에 초점을 맞춥니다.
통합 테스트와 E2E 테스트
// TodoApp.jsx - 통합 테스트를 위한 예제 애플리케이션
import { useState } from 'react';
function TodoApp() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
if (!input.trim()) return;
setTodos([
...todos,
{
id: Date.now(),
text: input,
completed: false
}
]);
setInput('');
};
const toggleTodo = (id) => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div data-testid="todo-app">
<h1>Todo List</h1>
<div className="add-todo">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add a new todo"
data-testid="todo-input"
/>
<button
onClick={addTodo}
data-testid="add-button"
>
Add
</button>
</div>
{todos.length === 0 ? (
<p data-testid="empty-message">No todos yet. Add one above!</p>
) : (
<ul data-testid="todo-list">
{todos.map(todo => (
<li
key={todo.id}
data-testid={`todo-item-${todo.id}`}
className={todo.completed ? 'completed' : ''}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
data-testid={`todo-checkbox-${todo.id}`}
/>
<span data-testid={`todo-text-${todo.id}`}>{todo.text}</span>
<button
onClick={() => deleteTodo(todo.id)}
data-testid={`todo-delete-${todo.id}`}
>
Delete
</button>
</li>
))}
</ul>
)}
<div className="todo-stats" data-testid="todo-stats">
<p>Total: {todos.length}</p>
<p>Completed: {todos.filter(todo => todo.completed).length}</p>
</div>
</div>
);
}
export default TodoApp;
// TodoApp.test.jsx - 통합 테스트
import { render, screen, fireEvent } from '@testing-library/react';
import TodoApp from './TodoApp';
describe('TodoApp Integration Tests', () => {
test('renders empty state initially', () => {
render(<TodoApp />);
expect(screen.getByTestId('empty-message')).toBeInTheDocument();
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Total: 0');
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Completed: 0');
});
test('can add a new todo', () => {
render(<TodoApp />);
// 새 할 일 추가
fireEvent.change(screen.getByTestId('todo-input'), {
target: { value: 'Learn React Testing' }
});
fireEvent.click(screen.getByTestId('add-button'));
// 빈 메시지가 사라졌는지 확인
expect(screen.queryByTestId('empty-message')).not.toBeInTheDocument();
// 할 일 목록에 새 항목이 추가되었는지 확인
const todoList = screen.getByTestId('todo-list');
expect(todoList).toBeInTheDocument();
// 할 일 텍스트 확인
const todoItems = screen.getAllByTestId(/^todo-item/);
expect(todoItems).toHaveLength(1);
expect(screen.getByText('Learn React Testing')).toBeInTheDocument();
// 통계가 업데이트되었는지 확인
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Total: 1');
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Completed: 0');
// 입력 필드가 지워졌는지 확인
expect(screen.getByTestId('todo-input')).toHaveValue('');
});
test('can toggle todo completion status', () => {
render(<TodoApp />);
// 할 일 추가
fireEvent.change(screen.getByTestId('todo-input'), {
target: { value: 'Learn React Testing' }
});
fireEvent.click(screen.getByTestId('add-button'));
// ID 가져오기 (동적으로 생성되므로)
const todoItem = screen.getByText('Learn React Testing').closest('li');
const todoId = todoItem.dataset.testid.split('-')[2];
// 체크박스 토글
const checkbox = screen.getByTestId(`todo-checkbox-${todoId}`);
fireEvent.click(checkbox);
// 완료 상태 확인
expect(todoItem).toHaveClass('completed');
expect(checkbox).toBeChecked();
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Completed: 1');
// 다시 토글
fireEvent.click(checkbox);
// 미완료 상태 확인
expect(todoItem).not.toHaveClass('completed');
expect(checkbox).not.toBeChecked();
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Completed: 0');
});
test('can delete a todo', () => {
render(<TodoApp />);
// 할 일 추가
fireEvent.change(screen.getByTestId('todo-input'), {
target: { value: 'Learn React Testing' }
});
fireEvent.click(screen.getByTestId('add-button'));
// 두 번째 할 일 추가
fireEvent.change(screen.getByTestId('todo-input'), {
target: { value: 'Write Tests' }
});
fireEvent.click(screen.getByTestId('add-button'));
// 할 일 항목 확인
expect(screen.getAllByTestId(/^todo-item/)).toHaveLength(2);
// 첫 번째 할 일 삭제
const firstTodoItem = screen.getByText('Learn React Testing').closest('li');
const firstTodoId = firstTodoItem.dataset.testid.split('-')[2];
fireEvent.click(screen.getByTestId(`todo-delete-${firstTodoId}`));
// 삭제 확인
expect(screen.queryByText('Learn React Testing')).not.toBeInTheDocument();
expect(screen.getAllByTestId(/^todo-item/)).toHaveLength(1);
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Total: 1');
// 두 번째 할 일 삭제
const secondTodoItem = screen.getByText('Write Tests').closest('li');
const secondTodoId = secondTodoItem.dataset.testid.split('-')[2];
fireEvent.click(screen.getByTestId(`todo-delete-${secondTodoId}`));
// 모든 할 일이 삭제되었는지 확인
expect(screen.queryByTestId('todo-list')).not.toBeInTheDocument();
expect(screen.getByTestId('empty-message')).toBeInTheDocument();
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Total: 0');
});
test('complete end-to-end todo workflow', () => {
render(<TodoApp />);
// 1. 초기 상태 확인
expect(screen.getByTestId('empty-message')).toBeInTheDocument();
// 2. 첫 번째 할 일 추가
fireEvent.change(screen.getByTestId('todo-input'), {
target: { value: 'Learn React Testing' }
});
fireEvent.click(screen.getByTestId('add-button'));
// 3. 두 번째 할 일 추가
fireEvent.change(screen.getByTestId('todo-input'), {
target: { value: 'Write Tests' }
});
fireEvent.click(screen.getByTestId('add-button'));
// 4. 세 번째 할 일 추가
fireEvent.change(screen.getByTestId('todo-input'), {
target: { value: 'Refactor Code' }
});
fireEvent.click(screen.getByTestId('add-button'));
// 5. 할 일 목록 확인
const todoItems = screen.getAllByTestId(/^todo-item/);
expect(todoItems).toHaveLength(3);
// 6. 첫 번째와 세 번째 할 일 완료로 표시
const firstTodoId = todoItems[0].dataset.testid.split('-')[2];
const thirdTodoId = todoItems[2].dataset.testid.split('-')[2];
fireEvent.click(screen.getByTestId(`todo-checkbox-${firstTodoId}`));
fireEvent.click(screen.getByTestId(`todo-checkbox-${thirdTodoId}`));
// 7. 통계 확인
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Total: 3');
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Completed: 2');
// 8. 두 번째 할 일 삭제
const secondTodoId = todoItems[1].dataset.testid.split('-')[2];
fireEvent.click(screen.getByTestId(`todo-delete-${secondTodoId}`));
// 9. 최종 상태 확인
expect(screen.getAllByTestId(/^todo-item/)).toHaveLength(2);
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Total: 2');
expect(screen.getByTestId('todo-stats')).toHaveTextContent('Completed: 2');
expect(screen.queryByText('Write Tests')).not.toBeInTheDocument();
expect(screen.getByText('Learn React Testing')).toBeInTheDocument();
expect(screen.getByText('Refactor Code')).toBeInTheDocument();
});
});
// E2E 테스트 예제 (Cypress)
// cypress/integration/todo_app_spec.js
describe('Todo App E2E Tests', () => {
beforeEach(() => {
// 각 테스트 전에 애플리케이션 방문
cy.visit('http://localhost:3000');
});
it('should add, toggle, and delete todos', () => {
// 초기 상태 확인
cy.get('[data-testid=empty-message]').should('be.visible');
cy.get('[data-testid=todo-stats]').should('contain', 'Total: 0');
// 첫 번째 할 일 추가
cy.get('[data-testid=todo-input]').type('Learn Cypress');
cy.get('[data-testid=add-button]').click();
// 할 일이 추가되었는지 확인
cy.get('[data-testid=todo-list]').should('exist');
cy.get('[data-testid^=todo-item]').should('have.length', 1);
cy.get('[data-testid^=todo-text]').should('contain', 'Learn Cypress');
// 두 번째 할 일 추가
cy.get('[data-testid=todo-input]').type('Write E2E Tests');
cy.get('[data-testid=add-button]').click();
// 두 개의 할 일이 있는지 확인
cy.get('[data-testid^=todo-item]').should('have.length', 2);
// 첫 번째 할 일 완료로 표시
cy.get('[data-testid^=todo-checkbox]').first().click();
// 완료 상태 확인
cy.get('[data-testid^=todo-item]').first().should('have.class', 'completed');
cy.get('[data-testid=todo-stats]').should('contain', 'Completed: 1');
// 두 번째 할 일 삭제
cy.get('[data-testid^=todo-delete]').eq(1).click();
// 삭제 확인
cy.get('[data-testid^=todo-item]').should('have.length', 1);
cy.get('[data-testid=todo-stats]').should('contain', 'Total: 1');
// 첫 번째 할 일 삭제
cy.get('[data-testid^=todo-delete]').first().click();
// 모든 할 일이 삭제되었는지 확인
cy.get('[data-testid=empty-message]').should('be.visible');
cy.get('[data-testid=todo-stats]').should('contain', 'Total: 0');
});
it('should not add empty todos', () => {
// 빈 입력으로 추가 버튼 클릭
cy.get('[data-testid=add-button]').click();
// 할 일이 추가되지 않았는지 확인
cy.get('[data-testid=empty-message]').should('be.visible');
// 공백만 있는 입력으로 추가 버튼 클릭
cy.get('[data-testid=todo-input]').type(' ');
cy.get('[data-testid=add-button]').click();
// 할 일이 추가되지 않았는지 확인
cy.get('[data-testid=empty-message]').should('be.visible');
});
it('should persist todos across page reloads', () => {
// 이 테스트는 로컬 스토리지를 사용하는 애플리케이션에서 작동
// TodoApp을 수정하여 로컬 스토리지 지원 추가 필요
// 할 일 추가
cy.get('[data-testid=todo-input]').type('Persistent Todo');
cy.get('[data-testid=add-button]').click();
// 페이지 새로고침
cy.reload();
// 할 일이 여전히 존재하는지 확인
cy.get('[data-testid^=todo-item]').should('have.length', 1);
cy.get('[data-testid^=todo-text]').should('contain', 'Persistent Todo');
});
it('should handle multiple todos with various operations', () => {
// 여러 할 일 추가
const todos = ['Buy groceries', 'Clean house', 'Pay bills', 'Call mom', 'Exercise'];
todos.forEach(todo => {
cy.get('[data-testid=todo-input]').type(todo);
cy.get('[data-testid=add-button]').click();
});
// 모든 할 일이 추가되었는지 확인
cy.get('[data-testid^=todo-item]').should('have.length', todos.length);
// 두 번째와 네 번째 할 일 완료로 표시
cy.get('[data-testid^=todo-checkbox]').eq(1).click();
cy.get('[data-testid^=todo-checkbox]').eq(3).click();
// 완료 상태 확인
cy.get('[data-testid=todo-stats]').should('contain', `Completed: 2`);
// 세 번째 할 일 삭제
cy.get('[data-testid^=todo-delete]').eq(2).click();
// 삭제 확인
cy.get('[data-testid^=todo-item]').should('have.length', todos.length - 1);
cy.get('[data-testid=todo-stats]').should('contain', `Total: ${todos.length - 1}`);
// 첫 번째 할 일 완료로 표시했다가 다시 미완료로 변경
cy.get('[data-testid^=todo-checkbox]').first().click();
cy.get('[data-testid^=todo-checkbox]').first().click();
// 완료 상태 확인 (변경 없음)
cy.get('[data-testid=todo-stats]').should('contain', 'Completed: 2');
});
});
코멘트: 통합 테스트와 E2E(End-to-End) 테스트는 애플리케이션의 여러 부분이 함께 작동하는 방식을 검증하는 중요한 테스트 유형입니다:
통합 테스트:
- 여러 컴포넌트나 기능이 함께 작동하는 방식을 테스트
- React Testing Library를 사용하여 구현 가능
- 단위 테스트보다 더 넓은 범위를 커버하지만 E2E 테스트보다는 제한적
- 실제 브라우저 환경이 아닌 JSDOM에서 실행됨
E2E 테스트:
- 실제 브라우저에서 사용자 관점에서 전체 애플리케이션 흐름을 테스트
- Cypress, Playwright, Selenium 등의 도구 사용
- 가장 현실적인 테스트 환경 제공
- 설정 및 실행이 복잡하고 시간이 오래 걸릴 수 있음
테스트 피라미드에 따르면, 단위 테스트가 가장 많고, 통합 테스트가 중간, E2E 테스트가 가장 적은 비율로 구성하는 것이 일반적입니다. 각 테스트 유형은 서로 다른 종류의 문제를 발견하므로 균형 잡힌 접근이 중요합니다.
Leave a comment