뒤로가기
프론트엔드 테스트, 뭘 테스트하고 뭘 안 할지

December 7, 2021

testingReactfrontend

커버리지 80%를 찍은 날, 팀 전체가 축하 메시지를 보냈다.

스프린트 두 번을 통째로 투자한 결과였다. 기존 컴포넌트마다 unit test를 붙이고, util 함수에도 빠짐없이 테스트를 작성했다. SonarQube 대시보드에 초록불이 켜졌을 때 팀 리드가 슬랙에 "드디어 80% 돌파"라고 올렸다. 다들 축하 리액션을 달았다. 그런데 그 다음 달 프로덕션 버그 수는 이전 달과 거의 같았다.

왜?

우리가 테스트하고 있던 것들#

그때 작성한 테스트를 지금 다시 보면 패턴이 보인다. formatDate('2025-01-01')'2025년 1월 1일'을 반환하는지, calculateDiscount(10000, 0.1)9000을 반환하는지, Button 컴포넌트에 disabled prop을 넘기면 disabled attribute가 붙는지. 이런 테스트가 수백 개였다.

하나하나는 틀린 테스트가 아니었다. 문제는 이런 테스트들이 실제 유저가 마주치는 버그를 잡아주지 못한다는 거였다. 유저는 formatDate 함수를 직접 호출하지 않는다. 유저는 주문 내역 페이지에 들어가서 날짜가 제대로 보이는지 확인한다. 그리고 프로덕션에서 터진 버그들은 대부분 "API 응답이 예상과 다를 때 화면이 어떻게 되는지", "로딩 중에 유저가 다른 버튼을 누르면 어떻게 되는지" 같은 것들이었다. 컴포넌트 단위로 잘게 쪼갠 unit test로는 절대 잡을 수 없는 영역.

Testing Pyramid에서 Testing Trophy로#

전통적인 testing pyramid는 unit test를 가장 많이, integration test를 중간, E2E test를 가장 적게 작성하라고 말한다. 빠르고 싸니까 unit을 많이 쓰라는 논리다.

Kent C. Dodds가 제안한 testing trophy는 이 비율을 뒤집는다. 가장 두꺼운 부분이 integration test다. 그 위에 얇은 E2E, 아래에 얇은 unit, 맨 아래에 static analysis(TypeScript, ESLint). 트로피 모양이라 testing trophy라고 부른다.

핵심 논리는 단순하다. 테스트가 소프트웨어를 사용하는 방식과 비슷할수록, 테스트가 더 많은 신뢰를 준다. unit test는 함수 하나가 제대로 작동하는지 확인할 수 있지만, 그 함수들이 조합되어 유저에게 올바른 결과를 보여주는지는 확인할 수 없다. integration test는 여러 컴포넌트와 Hook이 함께 동작하는 걸 검증한다. 비용 대비 잡아내는 버그의 범위가 가장 넓다.

이전 팀에서 커머스 상품 목록 페이지를 예로 들면, 이 페이지에 관련된 unit test가 12개 있었다. useProductList Hook의 반환값 테스트, ProductCard 컴포넌트의 렌더링 테스트, filterProducts util 함수 테스트 등등. 근데 "필터를 선택하고 → 정렬을 바꾸면 → 필터가 초기화되는" 버그를 잡은 건 QA 담당자였다. unit test 12개 중 어떤 것도 이 시나리오를 커버하지 못했다. integration test 하나면 됐을 일이다.

테스트 안 해도 되는 것들#

시간은 유한하다. 뭘 테스트하지 않을지 결정하는 게 뭘 테스트할지 결정하는 것만큼 중요하다.

스타일 테스트. colorred인지, margin-top16px인지 테스트하는 코드를 본 적 있다. 디자인은 자주 바뀐다. 이런 테스트는 디자인 변경할 때마다 같이 고쳐야 해서 유지보수 비용만 올라간다. 스타일은 Storybook이나 visual regression test로 검증하는 게 맞다.

라이브러리 내부 동작. React Query가 캐시를 제대로 관리하는지, Zustand의 selector가 리렌더링을 막는지, 이런 건 해당 라이브러리가 자체 테스트로 보장하는 영역이다. 우리가 다시 테스트할 이유가 없다. react-hook-form의 register가 input에 올바른 ref를 전달하는지 테스트하고 있다면, 그건 react-hook-form 메인테이너의 일이다.

implementation detail. 이게 가장 흔한 실수다. state가 특정 값으로 변하는지 직접 확인하거나, 특정 함수가 몇 번 호출됐는지 체크하거나, 내부 컴포넌트 구조를 테스트하는 것. 리팩토링하면 바로 깨진다. 동작은 그대로인데 테스트가 실패하면, 그건 나쁜 테스트다.

tsx
// 나쁜 테스트: implementation detail을 검증
test('버튼 클릭 시 setCount가 호출된다', () => {
  const setCount = jest.fn();
  jest.spyOn(React, 'useState').mockReturnValue([0, setCount]);
  render(<Counter />);
  fireEvent.click(screen.getByRole('button'));
  expect(setCount).toHaveBeenCalledWith(1);
});

// 좋은 테스트: 유저가 보는 결과를 검증
test('버튼 클릭 시 카운트가 증가한다', () => {
  render(<Counter />);
  fireEvent.click(screen.getByRole('button'));
  expect(screen.getByText('1')).toBeInTheDocument();
});

테스트해야 하는 것들#

반대로, 시간을 들여야 하는 영역이 있다.

유저 인터랙션 흐름. 버튼을 클릭하면 모달이 열리고, 모달에서 확인을 누르면 리스트에서 항목이 사라지는 것. 이런 흐름이 깨지면 유저가 바로 알아챈다.

조건부 렌더링. 로그인 상태에 따라 다른 UI가 보이는 것, 권한에 따라 버튼이 활성화/비활성화되는 것, 빈 상태(empty state) 처리. 이런 건 조건문 하나 빠뜨리면 프로덕션 이슈가 된다.

폼 검증. 이메일 형식이 틀릴 때 에러 메시지가 뜨는지, 필수 필드를 비워두고 제출하면 막히는지, 비밀번호 조건이 충족되지 않을 때 어떤 안내가 나오는지. 폼은 유저가 데이터를 입력하는 핵심 접점이고, 여기서 문제가 생기면 전환율이 직접 떨어진다.

API 에러 핸들링. 네트워크 에러일 때 "잠시 후 다시 시도해주세요"가 뜨는지, 401일 때 로그인 페이지로 리다이렉트하는지, 서버가 예상과 다른 응답을 줄 때 화면이 터지지 않는지. 프로덕션에서 가장 많이 터지는 버그 유형이 바로 이거다.

React Testing Library 철학#

React Testing Library의 기본 원칙이 있다.

The more your tests resemble the way your software is used, the more confidence they can give you.

유저는 컴포넌트의 state를 모른다. props가 뭔지도 모른다. 유저는 화면에서 텍스트를 읽고, 버튼을 누르고, 입력 필드에 타이핑한다. 테스트도 그렇게 해야 한다.

쿼리 우선순위도 이 철학을 반영한다. getByRole이 1순위다. 유저가 인식하는 방식(버튼, 텍스트박스, 링크)으로 요소를 찾는다. getByText가 그 다음이다. 유저가 읽는 텍스트로 찾는다. getByTestId는 최후의 수단이다. 유저는 data-testid를 모른다.

tsx
// 피해야 할 쿼리
const button = container.querySelector('.submit-btn');
const input = screen.getByTestId('email-input');

// 권장하는 쿼리
const button = screen.getByRole('button', { name: '로그인' });
const input = screen.getByRole('textbox', { name: '이메일' });

getByRole을 쓰면 자연스럽게 접근성도 챙기게 된다. getByRole('button', { name: '로그인' })이 동작하려면 해당 버튼에 접근 가능한 이름이 있어야 한다. 테스트가 통과하면 스크린 리더도 그 버튼을 "로그인"으로 읽는다는 뜻이다.

실전 예제: 로그인 폼 테스트#

말로만 하면 와닿지 않으니 실제 코드를 보자. 간단한 로그인 폼이 있다고 가정한다.

tsx
// LoginForm.tsx
export function LoginForm({ onSuccess }: { onSuccess: () => void }) {
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    setIsLoading(true);
    setError('');

    try {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });

      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.message || '로그인에 실패했습니다');
      }

      onSuccess();
    } catch (err) {
      setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">이메일</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="password">비밀번호</label>
      <input id="password" name="password" type="password" required />

      {error && <p role="alert">{error}</p>}

      <button type="submit" disabled={isLoading}>
        {isLoading ? '로그인 중...' : '로그인'}
      </button>
    </form>
  );
}

이 폼의 테스트를 작성한다. 유저 관점에서 중요한 시나리오는 세 가지다. 성공, 실패, 그리고 로딩 상태.

tsx
// LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
import { server } from './mocks/server';
import { http, HttpResponse } from 'msw';

const mockOnSuccess = jest.fn();

beforeEach(() => {
  mockOnSuccess.mockClear();
});

test('이메일과 비밀번호를 입력하고 제출하면 로그인에 성공한다', async () => {
  const user = userEvent.setup();

  server.use(
    http.post('/api/login', () => {
      return HttpResponse.json({ token: 'fake-token' });
    })
  );

  render(<LoginForm onSuccess={mockOnSuccess} />);

  await user.type(screen.getByRole('textbox', { name: '이메일' }), 'test@example.com');
  await user.type(screen.getByLabelText('비밀번호'), 'password123');
  await user.click(screen.getByRole('button', { name: '로그인' }));

  await waitFor(() => {
    expect(mockOnSuccess).toHaveBeenCalledTimes(1);
  });
});

test('잘못된 인증 정보로 로그인하면 에러 메시지가 표시된다', async () => {
  const user = userEvent.setup();

  server.use(
    http.post('/api/login', () => {
      return HttpResponse.json(
        { message: '이메일 또는 비밀번호가 올바르지 않습니다' },
        { status: 401 }
      );
    })
  );

  render(<LoginForm onSuccess={mockOnSuccess} />);

  await user.type(screen.getByRole('textbox', { name: '이메일' }), 'wrong@example.com');
  await user.type(screen.getByLabelText('비밀번호'), 'wrongpassword');
  await user.click(screen.getByRole('button', { name: '로그인' }));

  const alert = await screen.findByRole('alert');
  expect(alert).toHaveTextContent('이메일 또는 비밀번호가 올바르지 않습니다');
  expect(mockOnSuccess).not.toHaveBeenCalled();
});

test('제출 중에는 버튼이 비활성화되고 로딩 텍스트가 표시된다', async () => {
  const user = userEvent.setup();

  server.use(
    http.post('/api/login', async () => {
      await new Promise((resolve) => setTimeout(resolve, 100));
      return HttpResponse.json({ token: 'fake-token' });
    })
  );

  render(<LoginForm onSuccess={mockOnSuccess} />);

  await user.type(screen.getByRole('textbox', { name: '이메일' }), 'test@example.com');
  await user.type(screen.getByLabelText('비밀번호'), 'password123');
  await user.click(screen.getByRole('button', { name: '로그인' }));

  expect(screen.getByRole('button', { name: '로그인 중...' })).toBeDisabled();

  await waitFor(() => {
    expect(screen.getByRole('button', { name: '로그인' })).toBeEnabled();
  });
});

각 테스트의 구조를 보면: render → 유저 인터랙션 → 결과 확인. 내부 state를 직접 체크하는 곳이 없다. setError가 호출됐는지, isLoadingtrue로 바뀌었는지 같은 건 안 본다. 유저가 화면에서 확인할 수 있는 것만 검증한다. 에러 메시지가 보이는지, 버튼이 비활성화됐는지, 성공 콜백이 호출됐는지.

MSW로 API mocking#

위 예제에서 이미 MSW(Mock Service Worker)를 쓰고 있다. MSW가 다른 mocking 방식보다 나은 이유를 짧게 정리한다.

jest.mock이나 jest.spyOn으로 fetch를 직접 mock하면 implementation detail에 묶인다. fetch 대신 axios로 바꾸면 테스트가 깨진다. MSW는 네트워크 레벨에서 가로채기 때문에 HTTP 클라이언트가 뭐든 상관없다. 컴포넌트 입장에서는 진짜 서버에 요청을 보내는 것과 차이가 없다.

설정도 간단하다.

tsx
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: 1,
      name: '조상현',
      email: 'test@example.com',
    });
  }),

  http.get('/api/products', ({ request }) => {
    const url = new URL(request.url);
    const category = url.searchParams.get('category');

    const products = [
      { id: 1, name: '상품 A', category: 'electronics' },
      { id: 2, name: '상품 B', category: 'clothing' },
    ];

    const filtered = category
      ? products.filter((p) => p.category === category)
      : products;

    return HttpResponse.json(filtered);
  }),
];

// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
tsx
// jest.setup.ts 또는 vitest.setup.ts
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

기본 handler를 설정해두고, 특정 테스트에서 다른 응답이 필요하면 server.use()로 오버라이드한다. 로그인 폼 테스트에서 성공/실패 케이스마다 다른 handler를 넣은 게 그 패턴이다.

네트워크 에러도 시뮬레이션할 수 있다.

tsx
test('네트워크 에러 시 적절한 메시지가 표시된다', async () => {
  const user = userEvent.setup();

  server.use(
    http.post('/api/login', () => {
      return HttpResponse.error();
    })
  );

  render(<LoginForm onSuccess={mockOnSuccess} />);

  await user.type(screen.getByRole('textbox', { name: '이메일' }), 'test@example.com');
  await user.type(screen.getByLabelText('비밀번호'), 'password123');
  await user.click(screen.getByRole('button', { name: '로그인' }));

  const alert = await screen.findByRole('alert');
  expect(alert).toHaveTextContent('알 수 없는 오류가 발생했습니다');
});

"이 테스트가 깨졌을 때 진짜 문제인가?"#

팀에서 정한 기준이 하나 있다. 테스트가 실패했을 때 스스로에게 묻는다: "이게 프로덕션에서도 문제인가?"

대답이 "아니오"면 그 테스트는 삭제하거나 수정해야 한다.

실제로 겪은 케이스. 컴포넌트 내부에서 div 구조를 변경했더니 snapshot test가 20개 깨졌다. 유저가 보는 화면은 완전히 동일했다. 텍스트 같고, 버튼 같고, 인터랙션 같았다. 하지만 DOM 구조가 바뀌어서 snapshot이 일치하지 않았다. 이 20개 테스트를 업데이트하는 데 시간을 쓸 것인가? 아니면 처음부터 snapshot test 대신 행동 기반 테스트를 작성할 것인가?

snapshot test가 무조건 나쁘다는 건 아니다. API 응답 스키마 검증이나 에러 메시지 포맷 확인 같은 데는 유용하다. 하지만 컴포넌트 UI에 대한 snapshot test는 대부분 노이즈만 만든다. 변경할 때마다 jest --updateSnapshot을 무의식적으로 실행하게 되면, 그 테스트는 이미 역할을 잃은 거다.

거꾸로, 깨졌을 때 즉시 대응해야 하는 테스트가 있다. 결제 금액 계산 테스트가 깨졌으면 무조건 프로덕션에도 문제가 있을 가능성이 높다. 로그인 성공/실패 분기 테스트가 깨졌으면 유저가 로그인을 못 하고 있을 수 있다. 이런 테스트는 시간을 들여 유지할 가치가 있다.

E2E는 언제 쓰나#

E2E 테스트는 비싸다. 느리고, 불안정하고(flaky), 유지보수 비용이 높다. 그래서 모든 기능에 E2E를 붙이면 CI가 30분 넘게 돌아간다.

E2E는 critical path에만 쓴다. 이 기능이 안 되면 비즈니스가 안 돌아가는 것들.

  • 회원가입 → 이메일 인증 → 로그인 전체 흐름
  • 상품 선택 → 장바구니 → 결제 → 주문 완료
  • 핵심 검색 → 결과 노출 → 상세 페이지 진입

Playwright나 Cypress로 이 3~5개 시나리오만 커버하면 된다. 나머지는 integration test로 충분하다.

이전 프로젝트에서 E2E를 30개 넘게 작성한 적이 있다. 결과는 비참했다. 매주 1~2개씩 flaky test가 발생했고, "이번에도 타이밍 이슈"라며 재실행하는 게 루틴이 됐다. 결국 CI에서 E2E 실패를 무시하기 시작했다. 테스트를 무시하는 습관이 생기면 테스트가 아무리 많아도 의미가 없다. 그 이후로는 critical path 5개만 E2E로 유지하고 나머지는 전부 integration test로 전환했다. E2E 실행 시간은 12분에서 3분으로 줄었고, flaky test는 거의 사라졌다.

어디서부터 시작할까#

테스트가 하나도 없는 프로젝트라면 전부 다 작성하려 하지 말고, 가장 자주 버그가 나는 페이지 하나를 골라서 integration test를 붙여보자. 대부분의 경우 폼이 있는 페이지다. 로그인, 회원가입, 주문서 작성.

그 하나의 테스트가 CI에서 돌아가고, PR마다 자동으로 실행되는 걸 보면, 다음 테스트를 작성하고 싶어진다. 커버리지 숫자를 채우려고 작성하는 테스트가 아니라, "이게 깨지면 진짜 문제니까" 작성하는 테스트. 그 차이가 프로덕션 버그 수에 반영된다.