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 애플리케이션을 테스트하기 위한 강력한 조합입니다. 주요 특징은 다음과 같습니다:

  1. Jest: JavaScript 테스트 프레임워크
    • 테스트 러너, 어설션 라이브러리, 모킹 기능 제공
    • 스냅샷 테스팅, 코드 커버리지 보고서 지원
    • 병렬 테스트 실행으로 성능 최적화
  2. 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 테스트가 가장 적은 비율로 구성하는 것이 일반적입니다. 각 테스트 유형은 서로 다른 종류의 문제를 발견하므로 균형 잡힌 접근이 중요합니다.

Categories:

Updated:

Leave a comment