← posts

15개 동작을 나누며 고민한 단위 테스트, 통합 테스트, E2E의 경계

2026-08-21 · 회고

지난 7주 동안 과제를 하며 테스트가 필요할 때마다 AI의 도움을 많이 받았다. 구현 코드를 보여주고 테스트를 만들어 달라고 한 뒤 초록불이 들어오면 다음 작업으로 넘어갔다. 그 과정에서 테스트를 직접 설계할 기회는 많지 않았다.

8주차에는 먼저 지키고 싶은 동작을 고른 뒤 각 동작을 단위, 통합, E2E 중 어디에서 검증할지 정했다. 테스트를 작성한 다음에는 구현 일부를 망가뜨려 실제로 빨간불이 들어오는지도 확인했다.

이번에도 AI는 활용했다. 답을 바로 받기보다 내 판단을 먼저 문서에 적은 뒤 리뷰를 요청했다. 테스트 코드를 작성하는 시간보다 검증 범위와 위치를 정하는 데 더 오래 걸렸다.

테스트 종류를 대상의 크기로만 나누지 않기로 했다

예전에는 테스트 대상을 크기로 나눴다. 함수는 단위 테스트, 컴포넌트는 통합 테스트, 페이지 전체는 E2E 테스트라고 막연히 여겼다.

상품 목록처럼 여러 관심사가 얽힌 기능은 이 기준만으로 나누기 어려웠다. 이번에는 테스트에서 확인할 동작과 필요한 환경을 먼저 봤다.

  • 단위 테스트는 DOM이나 네트워크 없이 하나의 규칙과 경계값을 본다.
  • 통합 테스트는 여러 모듈을 연결해 사용자가 보게 되는 결과를 확인한다.
  • E2E 테스트는 실제 브라우저의 URL, 히스토리, 새로고침처럼 대체하기 어려운 동작을 맡는다.

상품 목록의 총 페이지 수는 전체 상품 수와 페이지 크기만 있으면 계산된다. 카테고리를 바꾼 뒤 새 요청이 나가고 화면의 상품이 바뀌려면 URL 상태, React Query, API 응답과 렌더링이 함께 움직여야 한다. 뒤로 가기와 새로고침 뒤에도 조건이 복원되는지는 실제 브라우저에서 봐야 한다.

같은 상품 목록도 무엇을 지키려는지에 따라 테스트 위치가 달라졌다. 실제 15개 항목을 나눠 보니 각 테스트 계층의 경계가 더 구체적으로 보였다.

Vitest 환경을 확인해 보니 모든 테스트가 jsdom에서 돌고 있었다

vitest run을 실행해 보니 모든 테스트가 전역 jsdom에서 돌고 있었다. 내가 직접 설정한 값인데도 어떤 테스트에 이 환경이 필요한지는 따로 생각해 보지 않았다.

총 페이지 수를 계산하거나 URL 문자열을 파싱하는 테스트에도 window, document, localStorage가 생겼다. 이 설정에서는 Node에서 돌아야 할 코드가 브라우저 API에 의존해도 테스트가 통과한다. 어떤 테스트에 DOM이 필요한지도 드러나지 않았다.

Vitest에서 환경을 나누는 방법으로 docblock과 test.projects를 비교했다.

파일 상단에 @vitest-environment docblock을 적으면 파일만 열어도 환경을 바로 확인할 수 있다. 파일마다 직접 적어야 하므로 누락 가능성이 있고 Node와 jsdom에 서로 다른 공통 setupFiles를 붙이기도 어려웠다.

test.projects는 환경별 프로젝트를 만들어 environment, include, setupFiles를 한곳에서 관리한다. 환경별 공통 설정을 따로 관리하기 쉬워 이 방식을 택했다.

import { configDefaults, defineConfig } from 'vitest/config';
 
export default defineConfig({
  test: {
    projects: [
      {
        extends: true,
        test: {
          name: 'node',
          environment: 'node',
          setupFiles: ['./vitest.msw.setup.ts'],
          include: [
            'src/**/*.test.ts',
            'src/_pages/home/ui/HomePage.test.tsx',
          ],
        },
      },
      {
        extends: true,
        test: {
          name: 'jsdom',
          environment: 'jsdom',
          setupFiles: ['./vitest.msw.setup.ts', './vitest.setup.ts'],
          include: ['src/**/*.test.tsx', 'tests/**/*.test.tsx'],
          exclude: [
            ...configDefaults.exclude,
            'src/_pages/home/ui/HomePage.test.tsx',
          ],
        },
      },
    ],
  },
});

기본 규칙은 .ts 테스트를 Node에서, .tsx 테스트를 jsdom에서 실행하는 것이다.

JSX를 쓰는 HomePage.test.tsxreact-dom/server만 사용해서 DOM이 필요 없다. 이 파일은 Node 프로젝트에 넣고 jsdom에서는 제외했다.

현재는 확장자만 봐도 환경을 짐작할 수 있다. 예외가 늘어나면 .node.test.ts, .browser.test.tsx처럼 파일명에 환경을 드러내는 편이 더 알아보기 쉬울 것 같다.

E2E는 언제 실행해야 할까

Playwright E2E를 pnpm test에 포함할지도 고민했다. E2E는 production build를 만들고 실제 브라우저까지 띄운다. 작은 로직 하나를 고칠 때마다 기다리기에는 무거웠다.

실행 시점은 다음과 같이 나눴다.

  • pnpm test는 Vitest만 실행한다.
  • 필요할 때 pnpm test:e2e로 Playwright를 따로 돌린다.
  • PR 전 pnpm check와 CI에서는 build 후 전체 E2E까지 확인한다.

E2E를 추가한 뒤 pnpm check는 약 25초에서 42~43초로 늘었다. Playwright 테스트는 3개에서 11개가 됐고 별도로 잰 E2E 실행 시간은 약 21.6초였다.

개발 중에는 Vitest로 단위 테스트와 통합 테스트를 실행한다. 시간이 더 필요한 E2E 테스트는 작업을 마친 뒤 직접 실행하고, PR 전과 CI에서도 빠짐없이 확인하도록 나눴다.

15개 항목을 나누며 가장 오래 고민한 두 가지

과제에서 검증할 동작은 15개였다. 장바구니와 위시리스트 개수부터 상품 목록이 로딩 중일 때, 결과가 없을 때, 에러가 날 때, 재시도할 때의 화면까지 포함했다. 필터, 정렬, 페이지 변경과 히스토리, 새로고침도 검증 대상이었다.

처음 작성한 표에는 단위 테스트, 통합 테스트, E2E라는 이름만 붙어 있었다. 다음 항목을 분류할 때 같은 기준을 적용하기 어려웠다. 방법론보다 선택 이유를 먼저 적기로 했다.

  1. 왜 이 테스트 방법을 골랐는가
  2. 빨간불이 들어오면 무엇이 잘못됐다고 말할 수 있는가

15개 검증 항목을 단위 테스트 3개, 통합 테스트 8개, E2E 테스트 4개로 나눈 결과

URL을 다룬다고 무조건 E2E여야 할까

URL을 다룬다는 이유로 처음에는 E2E라고 봤다. URL 문자열을 조건 객체로 바꾸는 loadProductListConditions와 이를 조회 조건으로 만드는 toProductListQuery는 렌더링 없이 실행할 수 있었다.

이 부분에서는 전체 흐름보다 입력 조합이 더 중요했다. 조건이 없을 때 기본값을 채우는지, 검색어의 공백과 분리된 한글 자모를 정리하는지, 알 수 없는 카테고리와 정렬을 거르는지, 잘못된 page를 1로 되돌리는지를 촘촘히 봐야 했다.

E2E로 작성하면 케이스마다 페이지를 열어야 한다. 실패해도 파서와 query key, 화면 중 어디에서 어긋났는지 바로 알기 어렵다. 파싱 규칙은 단위 테스트로 내리고 실제 주소로 다시 들어왔을 때 같은 화면이 복원되는지만 E2E에 남겼다.

조건 변경을 어디까지 실제로 연결해야 할까

카테고리, 정렬, 페이지 변경도 모두 E2E 후보로 두었다. 실제 앱에서는 조작이 URL을 바꾸고 새 조건으로 API를 호출한 뒤 목록을 갱신한다.

조건을 바꾸면 새 요청이 나가고 화면도 새 응답으로 바뀌어야 한다. NuqsTestingAdapter와 MSW를 연결하면 이 동작을 통합 테스트에서도 확인할 수 있었다.

사용자 조작부터 Nuqs 테스트 어댑터, React Query, MSW, 상품 목록 렌더링까지 연결한 통합 테스트 흐름

통합 테스트에서는 실제 URL 문자열이 바뀌는지 확인할 수 없다. 이 부분은 필터 조작이 주소에 남는 대표 E2E 한 건으로 확인했다. 카테고리, 정렬, 페이지 변경을 모두 E2E로 작성하지 않아도 URL 층을 실제로 지나는 경로는 남는다.

테스트 계층은 지키려는 동작을 확인할 수 있는 가장 작은 범위로 정했다.

테스트하지 않을 것도 미리 정했다

주어진 15개에 포함되지 않은 상품 검색은 다음 보강 대상으로 남겼다. 검색어 정규화가 어긋나면 눈에는 같은 검색어인데 서버와 브라우저가 다른 캐시를 쓸 수 있고 검색할 때 페이지를 1로 돌리지 않으면 빈 페이지가 열릴 수 있다.

CSS 클래스와 스타일은 지금의 단위, 통합, E2E 테스트로 고정하지 않기로 했다. jsdom은 실제로 보이는 모습을 판정하지 못한다. 색상과 위치를 숫자로 적은 E2E는 UI를 바꿀 때마다 함께 깨진다. 공통 컴포넌트의 모양을 계속 지켜야 한다면 시각 회귀 도구를 검토할 생각이다.

테스트를 더하기 전에 기존 테스트의 책임부터 확인했다

단위 3개, 통합 8개, E2E 4개 항목을 계획한 자리에 배치했다. 그렇다고 15개 테스트를 모두 새로 만들지는 않았다. 기존 코드와 테스트를 다시 읽고 옮길 것, 지울 것, 그대로 활용할 것을 나눴다.

판단대상이유
단위 테스트로 분리countTotalPagesDOM과 무관한 페이지네이션 규칙이므로 0개, 나누어떨어지는 값, 마지막에 하나가 남는 값을 확인했다.
기존 테스트 삭제URL hook 테스트애플리케이션의 규칙보다 nuqs가 같은 parser를 사용하는지 확인하고 있었다. URL 파싱은 단위 테스트로, 실제 주소 복원은 E2E로 나눴다.
기존 테스트 활용장바구니와 위시리스트의 헤더 개수상태 변경이 헤더에 반영되는 동작을 기존 통합 테스트가 이미 지키고 있었다.

countTotalPagesProductList 안의 지역 함수에서 features/product로 옮겼다. 테스트할 규칙을 정리하다 보니 컴포넌트 안에 있을 이유가 없는 로직도 함께 드러났다.

통합 테스트는 사용자 조작부터 화면 갱신까지 연결했다

처음에는 ProductList만 렌더링했다. 카테고리를 바꾸는 시나리오를 작성하려고 보니 화면 안에 필터가 없었다. ProductListFiltersProductList를 같은 테스트 환경에 두고 React Query와 userEvent를 연결했다. 이제 사용자가 조건을 바꾸면 요청이 달라지고 새 응답이 화면에 나타나는 과정까지 한 번에 확인할 수 있었다.

API 요청은 MSW로 가로챘다. 기본 핸들러에는 카테고리, 정렬, 페이지에 따른 성공 응답을 두고 실패, 빈 결과, 응답 지연은 필요한 테스트에서만 덮어썼다.

이 구조에서는 실제 상품 API와 MSW 핸들러가 필터, 정렬, 페이지 규칙을 각각 가진다. 둘이 달라져도 통합 테스트가 통과할 수 있다는 한계가 있다. 통합 테스트는 응답을 받은 뒤 화면이 올바르게 바뀌는지 확인하고, 실제 API의 필터링 규칙은 route 테스트가 맡도록 경계를 정했다.

반복을 줄이더라도 시나리오는 감추지 않았다

카테고리 변경, 정렬 변경, 페이지 이동 테스트에는 비슷한 코드가 반복됐다. 처음에는 헬퍼로 묶고 싶었지만 조작과 결과까지 감추면 각 테스트가 무엇을 지키는지 읽기 어려웠다. 공통 렌더링 준비만 묶고 사용자가 한 행동과 화면에서 확인할 결과는 각 테스트에 남겼다.

에러 상황을 나누자 기존 구현에서 처리하지 못한 경우가 나왔다

7주차 과제 피드백에서 목록을 이동하던 중 요청이 실패해도 이전 목록을 유지하고 배너만 보여줘야 한다는 의견을 받았다. 8주차에 이 동작을 테스트로 옮기면서 상품 목록의 에러 상황을 세 가지로 나눠 봤다.

  1. 최초 조회 실패: 보여줄 데이터가 없으므로 전체 에러 화면을 띄운다.
  2. 같은 조건의 재조회 실패: 캐시에 목록이 있어 이전 화면을 유지하고 배너만 띄운다.
  3. 조건을 바꾼 뒤 조회 실패: 수정 전에는 이전 목록이 사라지고 전체 에러 화면으로 바뀌었다.

최초 조회 실패, 같은 조건 재조회 실패, 수정 전 조건 변경 후 실패에서 서로 다른 화면 상태

세 번째 상황은 피드백에서 기대한 동작과 달랐다. 7주차에 keepPreviousData를 넣은 목적은 새 목록이 올 때까지 이전 목록을 보여주는 것이었다. TanStack Query는 새 조건의 요청이 실패한 순간 placeholderData를 유지하지 않았다. 카테고리를 바꾼 뒤 요청이 실패하면 보고 있던 목록 전체가 사라졌다.

테스트로 세 번째 상황을 재현하면서 기존 구현이 조건 변경 후 실패를 처리하지 못한다는 사실을 확인했다. 이번 과제에서 조건 변경 후 요청이 실패해도 직전 목록을 유지하고 전체 에러 화면 대신 배너를 보여주도록 수정했다.

테스트를 쓰기 전에는 에러 화면이 잘 나오는지만 보면 된다고 여겼다. 막상 경우를 나누자 어떤 데이터를 남기고 어떤 메시지를 보여줄지부터 다시 정해야 했다.

216개 테스트를 모두 통과한 변이가 남았다

테스트를 다 작성한 뒤 구현 네 곳을 일부러 바꿨다. 테스트의 기대값을 그대로 뒤집는 단순한 실험은 피하고 조건과 경계처럼 실제 수정에서 놓칠 법한 부분을 골랐다. 네 곳 중 세 곳은 테스트가 잡았고 나머지 한 곳은 Vitest와 Playwright를 모두 통과했다.

망가뜨린 곳변경결과
URL의 page 파서page >= 1 제거단위 테스트가 0, -1 입력에서 잡음
첫 페이지의 이전 버튼page <= 1page < 1로 변경통합 테스트가 잡음
페이지 보정 히스토리replacepush로 변경뒤로 가기 E2E가 잡음
갱신 중 총 페이지 수 가드isPlaceholderData 조건 제거모든 테스트를 통과

마지막 변이는 Vitest 205개와 Playwright 11개를 모두 통과했다. 현재 테스트 결과만으로는 이 변경을 구분할 수 없었다.

전체가 3페이지인 목록에서 마지막 페이지를 보고 있다고 해보자. 1페이지만 있는 다른 목록으로 이동한 뒤 캐시 만료 시간인 5분이 지나서 뒤로 가면 문제가 드러났다. 이전 조건의 데이터가 돌아오기 전에 현재 목록의 총 페이지 수를 기준으로 보정하면서 페이지가 강제로 1페이지로 바뀌었다.

버그를 재현한 뒤 테스트 추가 여부를 검토했다. 처음 정한 15개 동작에 포함되지 않았고 재현하려면 쿼리 캐시 만료와 화면 전환 순서를 세밀하게 맞춰야 했다. 드문 상황을 지키려다 테스트가 타이밍에 예민해질 가능성이 더 커 보여 이번에는 추가하지 않았다.

테스트를 추가하는 대신 가드 옆에 캐시 만료 후 잘못된 페이지 보정을 막는 코드라고 적었다. 주석은 변경 이유를 설명할 뿐 같은 문제가 다시 생기는 것을 막아주지는 못한다. 지금도 이 가드는 테스트로 보호되지 않는다. 재현 빈도에 비해 테스트 유지 비용이 더 크다고 판단해 이번에는 이 빈틈을 남겼다.

같은 문제가 반복되거나 페이지 보정 로직을 자주 고치게 되면 그때 테스트 비용을 다시 따져볼 생각이다.

테스트가 변이를 놓쳤다고 곧바로 테스트를 추가할 필요는 없었다. 먼저 그 동작이 원래 검증 범위에 있었는지 확인해야 했다.

이번 과제를 통해 배운 점

이전에는 “장바구니 기능 테스트”라고 하나의 대상으로 뭉뚱그려 생각했다. 이번에는 실패 범위에 따라 다음과 같이 나눴다.

  • 단위 테스트에서는 toggle 액션이 정확한 상품 ID를 추가하고 제거하는지 본다.
  • 통합 테스트에서는 스토어가 바뀔 때 헤더의 장바구니 개수가 다시 그려지는지 본다.
  • E2E에서는 상품 목록에서 담은 뒤 헤더 숫자가 오르고 새로고침해도 상태가 남는지 본다.

이렇게 나누니 테스트 이름도 달라졌다. worksrenders 대신 “첫 페이지에서는 이전으로 갈 수 없다”처럼 지키려는 동작을 그대로 적었다. 빨간불이 들어왔을 때 테스트 이름만 읽어도 어디부터 살펴볼지 짐작할 수 있었다.

아직 테스트 코드 자체를 잘 쓴다는 확신은 없다. 통합 테스트의 반복이 읽기 위해 남겨야 할 중복인지, 더 걷어내야 할 준비 코드인지도 판단하기 어렵다. 구현 세부사항과 얼마나 거리를 둬야 하는지, 경계값은 어디까지 골라야 하는지도 더 경험해 봐야 한다.

이번에는 주어진 15개 항목만 이 기준으로 살펴봤다. 다음에는 지난 7주 동안 작성한 테스트를 다시 읽어볼 생각이다. jsdom이 필요하지 않은 테스트, 우리 코드보다 라이브러리를 확인하는 테스트, 실패해도 원인을 좁히기 어려운 테스트부터 찾아보려 한다.

다음에 테스트 파일을 열면 초록불을 확인하기 전에 각 테스트가 무엇을 지키는지와 왜 그 위치에서 검증하는지를 먼저 살펴보려고 한다.