PromleeBlog
sitemap
aboutMe

posting thumbnail
Form 입력과 Validation 테스트 - React로 시작하는 TDD 10편
Testing Form Input and Validation - TDD with React Part 10

📅

🚀

들어가기 전에 🔗

웹 사이트에서 가장 많이 사용하는 기능 중 하나가 바로
로그인
입니다.
사용자는 아이디와 비밀번호를 입력하고, 로그인 버튼을 누릅니다.
이때 만약 비밀번호를 비워두고 버튼을 누르면 어떻게 될까요?
"비밀번호를 입력해 주세요"라는 빨간색 경고 문구가 떠야 합니다.

오늘은 이렇게 사용자가 키보드로 글자를 입력하는 과정(type)과, 잘못된 입력에 대해 경고하는
유효성 검사
(Validation)를 테스트하는 방법을 알아보겠습니다.
이 과정에서 userEvent 객체가 아주 중요한 역할을 합니다.

🚀

시나리오: 로그인 폼 🔗

간단한 로그인 컴포넌트(LoginForm)를 만든다고 가정해 봅시다.
요구사항:
  1. 이메일과 비밀번호 입력창, 그리고 로그인 버튼이 있어야 한다.
  2. 사용자가 입력창에 글자를 치면, 그 값이 화면에 반영되어야 한다.
  3. 빈 값으로 로그인 버튼을 누르면 "이메일을 입력하세요" 또는 "비밀번호를 입력하세요" 에러 메시지가 떠야 한다.
  4. 입력이 올바르면 부모 컴포넌트가 전달해 준 onSubmit 함수가 실행되어야 한다.

🚀

1단계: 테스트 작성 (Red) 🔗

src/components/LoginForm.test.tsx 파일을 생성합니다.
이번 테스트에서는 사용자가 키보드를 한 글자씩 누르는 동작을 흉내 내기 위해 userEvent.type()을 사용합니다.
또한, 폼 제출이 성공했는지 확인하기 위해
스파이
(Spy) 함수인 vi.fn()을 활용합니다.
src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, describe, test, expect } from 'vitest';
// 아직 컴포넌트가 없으므로 import 에러가 날 수 있습니다.
import LoginForm from './LoginForm';
 
describe('LoginForm 컴포넌트 동작 테스트', () => {
 
  test('이메일과 비밀번호를 입력하고 제출하면 onSubmit이 호출된다', async () => {
    const user = userEvent.setup();
    // 폼 제출 성공 여부를 확인하기 위한 가짜 함수(Mock Function)
    const handleSubmit = vi.fn();
 
    render(<LoginForm onSubmit={handleSubmit} />);
 
    const emailInput = screen.getByLabelText('이메일');
    const passwordInput = screen.getByLabelText('비밀번호');
    const submitBtn = screen.getByRole('button', { name: '로그인' });
 
    console.log('[Step 1] 입력 전 상태 확인');
    screen.debug(emailInput); // 입력창 상태만 따로 출력해 봅니다.
 
    console.log('[Step 2] 텍스트 입력 시뮬레이션');
    // 사용자가 타자를 치는 동작 (비동기)
    await user.type(emailInput, 'test@example.com');
    await user.type(passwordInput, 'password123');
 
    // 입력값이 제대로 들어갔는지 검증 (value 속성 확인)
    expect(emailInput).toHaveValue('test@example.com');
    expect(passwordInput).toHaveValue('password123');
 
    console.log('[Step 3] 로그인 버튼 클릭');
    await user.click(submitBtn);
 
    // handleSubmit 함수가 정확한 인자(입력한 값)와 함께 1번 호출되었는지 검증
    expect(handleSubmit).toHaveBeenCalledTimes(1);
    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });
 
  test('빈 값으로 제출하면 에러 메시지가 표시되고 onSubmit은 호출되지 않는다', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();
 
    render(<LoginForm onSubmit={handleSubmit} />);
    const submitBtn = screen.getByRole('button', { name: '로그인' });
 
    console.log('[Step 4] 빈 값으로 제출 시도');
    await user.click(submitBtn);
 
    console.log('[Step 5] 에러 메시지 렌더링 확인');
    screen.debug();
 
    // 에러 메시지가 화면에 나타났는지 확인
    expect(screen.getByText('이메일을 입력하세요')).toBeInTheDocument();
    expect(screen.getByText('비밀번호를 입력하세요')).toBeInTheDocument();
 
    // 에러가 났으므로 제출 함수는 호출되지 않아야 함
    expect(handleSubmit).not.toHaveBeenCalled();
  });
});

🚀

2단계: 테스트 실행 (Red) 🔗

터미널에서 테스트를 실행합니다.
Terminal
npm test src/components/LoginForm.test.tsx
당연히 실패합니다.
LoginForm 컴포넌트가 없기 때문입니다.
이제 이 상세한 테스트 시나리오를 만족시키는 컴포넌트를 구현해 봅시다.

🚀

3단계: 기능 구현 (Green) 🔗

src/components/LoginForm.tsx를 생성합니다.
입력값을 관리하기 위해 useState를 사용하고, 제출 시 검증 로직을 추가합니다.
src/components/LoginForm.tsx
import { useState } from 'react';
 
interface LoginFormProps {
  onSubmit: (data: { email: string; password: string }) => void;
}
 
export default function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  // 에러 상태 관리 (초기값은 빈 객체)
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault(); // 폼의 기본 동작(새로고침) 방지
    
    // 유효성 검사 로직
    const newErrors: { email?: string; password?: string } = {};
    if (!email) newErrors.email = '이메일을 입력하세요';
    if (!password) newErrors.password = '비밀번호를 입력하세요';
 
    // 에러가 하나라도 있으면 제출 중단
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }
 
    // 검사 통과 시 에러 초기화 및 데이터 전송
    setErrors({});
    onSubmit({ email, password });
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <div>
        {/* 라벨과 인풋을 연결하기 위해 htmlFor와 id를 일치시킵니다 */}
        <label htmlFor="email">이메일</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        {/* 에러가 있으면 빨간색 문구 표시 */}
        {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
      </div>
 
      <div>
        <label htmlFor="password">비밀번호</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        {errors.password && <span style={{ color: 'red' }}>{errors.password}</span>}
      </div>
 
      <button type="submit">로그인</button>
    </form>
  );
}

🚀

4단계: 테스트 재실행 및 결과 분석 🔗

다시 테스트를 실행합니다.
Terminal
npm test src/components/LoginForm.test.tsx

성공 로그 확인 및 분석 🔗

성공 로그 확인
성공 로그 확인
[Step 1] 입력 전 상태 확인
<input id="email" type="email" value="" />
 
[Step 2] 텍스트 입력 시뮬레이션
[Step 3] 로그인 버튼 클릭
 
[Step 4] 빈 값으로 제출 시도
[Step 5] 에러 메시지 렌더링 확인
<body>
  ...
  <span style="color: red;">이메일을 입력하세요</span>
  <span style="color: red;">비밀번호를 입력하세요</span>
  ...
</body>
 
PASS src/components/LoginForm.test.tsx

userEvent.type vs fireEvent.change 🔗

과거에는 fireEvent.change를 많이 썼지만, 지금은 userEvent.type을 권장합니다.

🚀

결론 🔗

소스 코드는 GitHub - Promleeblog/react-tdd-setup 에서 확인할 수 있습니다.
Terminal
git clone https://github.com/PROMLEE/my-tdd-app.git
cd my-tdd-app
git checkout part10
오늘은 웹 애플리케이션의 입구인 폼(Form)을 테스트했습니다.

이제 우리는 화면 그리기(Render), 클릭(Click), 비동기 데이터(Async), 그리고 입력(Input)까지 프론트엔드 테스트의 4대 요소를 모두 익혔습니다.
다음 시간에는
11편. 테스트 관점에서 본 컴포넌트 구조
를 통해, 테스트를 짜다 보니 발견하게 되는 "나쁜 컴포넌트 구조"와 이를 개선하는 방법을 알아보겠습니다.

참고 🔗