들어가며

테스트 코드를 작성하며 늘 따라다니던 의문이 있었다.

  1. 프론트엔드에서의 테스트 코드는 어디까지 작성해야 할까?
  2. 유닛테스트, 통합테스트, e2e의 경계는 어떻게 나눠야 할까?
  3. 커버리지를 무조건 높이는 것이 좋은 것일까?

대표적으로 위의 세 개였다.

최근 프론트엔드 테스트에 대한 세션을 접하고 생각하는 방향이 바뀌었다.

결국 프론트엔드에서 테스트 코드를 작성하는 일은 사용자가 운영 환경에서 더 안정적으로 쓸 수 있게 만드는 것 하나로 수렴한다.

그렇다면 테스트 케이스도 사용자 행위 중심으로 작성되어야 하고, 구현 세부사항을 테스트하는 것은 지양해야 한다.

예를 들어 특정 유저가 장바구니에 상품 1개를 담았다 라는 행위를 가정해보자.

이 화면의 상태가 Zustand로 관리되고 있다고 하자. 그 라이브러리를 Jotai로 바꿔도 테스트 케이스는 깨지면 안 된다. 사용자가 장바구니에 상품 1개를 담는 행위는 그대로이기 때문이다.

코드가 바뀔 때마다 따라서 바뀌어야 하는 테스트는 오히려 유지보수성을 악화시키는 족쇄라는 점도 인지할 수 있었다.

마침 과제로 프론트엔드 테스트를 세울 일이 생겼고, 회사에서도 쌓여 있던 테스트를 정리하던 중이었다. 위의 세 질문을 들고 두 곳에서 같은 원칙을 적용해봤다.


커버리지를 세지 않기로 하면 무엇이 남는가

과제 요구사항의 첫 줄이 이랬다.

이번 주에는 커버리지 숫자를 올리지 않아요. 프론트엔드는 지켜야 할 스펙이 어디에도 적혀 있지 않으니, 무엇을 어떻게 검증할지 먼저 정하고 그것만 테스트로 고정해요.

세 번째 질문에 대한 답이 여기 있었다.

커버리지는 코드가 실행됐는지를 센다. 실행됐는지와 지켜졌는지는 다른 문제다. 단언 없이 렌더만 하는 테스트도 커버리지는 올린다. 그런데 프론트엔드에는 애초에 지켜야 할 스펙 문서가 없다. 백엔드처럼 API 명세가 있는 것도 아니고, 화면이 무엇을 보장해야 하는지는 대개 아무 데도 안 적혀 있다.

그래서 숫자 대신 문장을 먼저 썼다. 검증 항목 15개를 표로 만들고, 각 항목마다 이 칸을 채웠다.

이 테스트가 빨간불이 되면 나는 무엇을 알게 되는가?

이 칸이 이번 작업에서 제일 중요한 장치였다. 여기에 코드가 잘 돌아간다컴포넌트가 렌더된다 같은 답이 나오면 그 항목은 잘못 잡은 것이다. 그 테스트는 통과해도 보장하는 게 없다.

실제로 채운 것 중 몇 개다.

검증 대상빨간불이 되면 알게 되는 것
장바구니 개수 파생같은 상품을 두 번 담으면 헤더 개수가 2로 늘거나, 다시 눌러도 안 빠져서 헤더 숫자와 실제 담긴 상품이 어긋난다
목록 에러5xx인데 인라인으로 삼켜 경계가 안 뜨거나, 4xx인데 경계로 던져 고칠 수단인 필터 폼까지 사라진다
조작이 URL에 반영조작해도 URL이 안 바뀌어 공유 링크가 무의미해지거나, 받은 링크로 들어와도 폼이 기본값으로 뜬다
페이지 이동마지막 페이지에서 “다음”이 안 막혀 빈 목록으로 넘어가거나, 로딩 중 연타로 페이지가 건너뛴다

전부 사용자가 겪는 일로 적혀 있다. 저 칸을 먼저 채우고 나니 그 다음 결정들이 거의 자동으로 따라왔다. 무엇을 단언할지, 어느 층에 둘지, 어디까지 모킹할지가 전부 저 문장에서 나왔다.


사용자 행위 중심 은 코드 수준에서 무슨 뜻인가

앞써 적은 원칙을 실제로 적용해보니, 가장 많이 고친 곳은 테스트 구조가 아니라 expect 안쪽이었다.

배열의 모양이 아니라 사용자가 아는 사실

// 담은 순서는 화면 어디에도 노출되지 않는데 잠근다
expect(useCartStore.getState().cart).toEqual(["p1", "p3"])
 
// 사용자눈에 보이고 아는 사실
expect(inCartCount()).toBe(2)
expect(isInCart("p2")).toBe(false)

장바구니 개수는 store에 따로 저장하지 않고 cart.length로 파생한다. 사용자에게 장바구니는 내가 담아 둔 상품들 이지 배열이 아니다. 몇 번째로 담았는지는 화면에 나오지 않는다.

위쪽처럼 쓰면 배열을 Set으로 바꾸는 순간 테스트가 깨진다. 사용자가 겪는 일은 하나도 안 바뀌었는데도. 들어가며에 적은 ZustandJotai로 바꾼다는 가정이 바로 이 이야기다. 저장 방식이 아니라 파생 규칙을 단언해야 한다.

쿼리스트링의 모양이 아니라 링크의 결과

AI로 만든 nuqs 라이브러리를 사용한 쿼리 파라미터 기반 리스트 조회 시 보이는 화면에 관련한 테스트코드 초안에는 이런 단언이 있었다.

expect(updates.at(-1)?.get("page")).toBeNull()

이건 nuqs의 직렬화 규칙, 그러니까 파서 기본값과 같아진 조건은 URL에서 지운다 를 고정한다. 라이브러리가 기본값도 싣도록 바뀌면 테스트는 깨지는데 사용자가 겪는 일은 없다. 전형적인 족쇄라고 판단하였다.

지키려는 건 URL이 이렇게 생겼다가 아니라 이 화면의 링크를 보내면 상대도 같은 화면을 본다 였다. 그래서 단언을 이렇게 바꿨다.

초안 (구현 어휘)수정 (사용자 결과)
page 조건이 URL 에서 빠진다3페이지에서 검색하면 1페이지 결과부터 본다
조건 초기화를 누르면 모든 조건이 URL 에서 빠진다조건 초기화를 누르면 조건 없이 조회한 기본 목록으로 돌아간다

테스트 이름도 같은 기준으로 다시 썼다. works, renders 같은 이름은 깨졌을 때 아무것도 알려주지 않는다. 조건과 결과가 이름에 드러나야 실패 로그만 보고 원인을 짚을 수 있다.


층의 경계 - 처음 잡은 기준은 틀렸다

두 번째 질문이다. 15개 항목을 단위, 통합, E2E 중 어디에 둘지 정해야 했다.

처음에 이렇게 생각했다.

URL을 건드리는 항목이면 E2E.

이 기준으로 나눠보니 바로 이상해졌다. 카테고리 변경, 정렬 변경, 페이지 이동도 전부 URL을 쓴다. 그럼 이 셋도 E2E로 가야 하는데, 그건 아무리 봐도 과하다.

문제가 된 항목은 조작이 URL에 반영되고 그 URL로 다시 들어가면 복원되는가 였다. 이걸 처음엔 통합테스트 레벨로 두었다. 검증하려는 게 브라우저 기능이 아니라 우리 코드의 결정이라고 봤기 때문이다. NuqsTestingAdapter가 진입 조건 주입과 URL 갱신 관측을 둘 다 해주니 브라우저 없이 확인된다고 판단했다.

하지만 구현 단계에서 그 위험이 그대로 드러나게 되었다.

카테고리를 home 으로 → 정렬을 price-desc 로, 두 번 조작했을 때 받은 이벤트

  "category=home"  ||  "sort=price-desc"
                        ↑ category 가 없다

테스트 어댑터는 URL 상태를 누적하지 않는다. 각 이벤트가 그 호출이 바꾼 조건만 담는다. 실제 브라우저에서는 두 조건이 함께 쌓이는데도 그렇다.

처음엔 단일 조작 왕복으로 범위를 좁혀 우회했다. 그런데 그건 진짜 동작의 퇴화된 케이스만 보는 테스트다. 사용자가 실제로 하는 일은 검색하고, 카테고리를 고르고, 정렬을 바꾼 뒤 그 주소를 남에게 보내는 것이다.

기준을 다시 잡았다.

지키려는 것
파생 규칙 (담긴 수, 링크 정규화)단위렌더가 필요 없다
조건이 바뀌면 목록이 반응한다통합같은 문서 안에서 끝난다
링크 공유, 뒤로가기, 새로고침E2E문서 경계를 넘는 일은 흉내낼 수 없다

기준은 URL을 건드리는가 가 아니라 **URL이 문서 경계를 넘어 살아남아야 하는가** 였다. 더 일반화하면 이렇다.

무엇이 없으면 재현이 안 되는가.

history 스택이 없으면 뒤로가기는 재현이 안 된다. 문서 재로드가 없으면 새로고침은 재현이 안 된다. jsdom 테스트환경에서는 둘 다 존재하지 않았다. 그래서 그 셋만 E2E로 보내고 나머지는 통합에 남겼다. 검색과 필터가 커머스의 핵심 흐름이라 실패 비용이 높다는 것도 함께 고려했다.

옮기면서 치른 값은 E2E 항목 3개에서 4개로, 실행 시간 4.5초에서 9초 정도였다.

옮기자마자 테스트가 버그를 잡았다

E2E로 옮기고 처음 돌린 순간 한 케이스가 빨간불이 됐다.

경계 케이스: /products?category=nope&sort=nope&page=0 로 진입

Expected substring: "1 /"
Received string:    "이전0 / 3다음"

/products?page=0으로 들어가면 1페이지 상품이 보이는데 페이지 표시가 0 / 3이었다.

원인은 page 정규화 규칙이 두 벌이었기 때문이다.

경로파서?page=0
서버 (generateMetadata)parseFilterParamspage >= 1 가드1
클라이언트 (ProductListPage)nuqs parseAsInteger.withDefault(1)0

withDefault는 파라미터가 없을 때만 적용된다. '0'은 정수로 잘 파싱되니 그대로 통과한다.

그 다음이 더 고약했다. 증상이 연쇄로 숨었다.

?page=0
  ↓
클라이언트: 0으로 파싱
  ↓
0이 falsy → 요청 파라미터에서 page 가 통째로 빠짐
  ↓
서버: page 없음 → 1
  ↓
상품 데이터: 1페이지 (정상)
  ↓
클라이언트 UI: page 상태는 여전히 0
  ↓
"0 / 3"

데이터는 맞고 라벨만 틀린 형태다. 눈으로는 잘 안 잡힌다.

고칠 때 한쪽에 가드를 덧대지 않았다. 규칙이 두 벌인 게 원인이니 판정을 한 곳에서만 정의하도록 바꿨다.

// parseFilterParams.ts
export const isReachablePage = (page: number): boolean => Number.isSafeInteger(page) && page >= 1
 
// useProductFilterState.ts
const parseAsReachablePage = createParser({
  parse: (value) => {
    const page = parseAsInteger.parse(value)
    return page !== null && isReachablePage(page) ? page : null
  },
  serialize: (page: number) => String(page),
})

이 버그에서 두 가지를 얻었다.

하나는 배치가 곧 커버리지라는 것이다. 이 항목을 통합에 뒀다면 이 버그는 안 잡혔다. 테스트 어댑터는 실제 nuqs 파서를 지나지 않기 때문이다. 같은 항목을 검증해도 어느 층에 두느냐에 따라 잡을 수 있는 것이 달라진다.

다른 하나는 이 버그의 모양 자체다. 같은 사실을 두 곳에서 계산하고 있었고, 어느 쪽도 혼자서는 틀리지 않았다. 둘이 어긋났을 뿐이다. 그래서 단위 테스트로는 잡히지 않는다.


어디까지가 내가 지켜야 할 코드인가

과제에 이런 조건이 있었다.

앱 코드의 HTTP 클라이언트를 직접 모킹하지 않아요. fetchaxios를 바꿔치기하는 방식이 남아 있으면 안 돼요.

처음엔 그냥 제약이라고 생각했는데, 곱씹어보니 앞의 원칙과 같은 이야기였다.

vi.mock을 하나 붙일 때마다 테스트가 지나지 않는 코드가 그만큼 생긴다. apiClient를 모킹하면 URL을 어떻게 조립하는지, 4xx와 5xx를 어떤 에러 타입으로 바꾸는지가 전부 검증 대상에서 빠진다. 그런데 그 부분도 사용자가 겪는 결과에 영향을 준다.

그래서 요청은 실제로 나가게 두고 MSW가 네트워크에서 가로채도록 했다.

핸들러 정책은 이렇게 정했다.

  • 기본 핸들러에는 성공 경로만 둔다.
  • 실패, 빈 결과, 지연은 그 테스트 안에서 server.use(...)로 덮는다.
  • onUnhandledRequest: 'error'. 모킹되지 않은 요청이 조용히 나가지 않게 막는다.

기본 핸들러에 시나리오 분기를 넣으면, 어떤 테스트가 무엇을 기대하는지 알려고 파일을 넘나들며 읽어야 한다. 테스트는 그 파일만 읽고 이해할 수 있어야 한다고 봤다.

미모킹 요청을 일부러 내보내 봤더니 실패 체인이 원인을 그대로 보여줬다.

[MSW] Cannot bypass a request when using the "error" strategy
  → NetworkError: GET /api/nope 요청이 네트워크 단계에서 실패했습니다.

NetworkError가 우리 코드베이스의 apiClient에서 나온 거라는 게 중요했다. 스텁이 아니라 진짜 요청이 나갔다는 증거였다.

회사 코드에서 같은 기준을 적용해봤다

회사 프로젝트에는 이미 테스트가 꽤 쌓여 있었다. 그 중 하나를 열어보니 이랬다. 326줄짜리 파일인데 앞의 100줄 가까이가 전부 모킹이었다.

vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
vi.mock("@/store/useUserStore", () => ({
  /* ... */
}))
vi.mock("@/services/useApiMutation", () => ({
  /* ... */
}))
vi.mock("@/api/.../settingPropertyPutFetch", () => ({
  /* ... */
}))
vi.mock("@/pages/.../util", () => ({
  validateCustomPropertyData: vi.fn(() => ({ success: true, error: {} })),
  validateSystemPropertyData: vi.fn(() => ({ success: true, error: {} })),
}))
vi.mock("@/hooks/useAuthPermission", () => ({
  /* ... */
}))
vi.mock("@/components/CellRenderer/SaveIcon", () => ({
  /* ... */
}))
// ...

검증 함수를 success: true로 고정하고, 저장 API를 가짜로 바꾸고, 저장 버튼까지 가짜로 바꿨다. 그래놓고 “저장 버튼을 누르면 mutateAsync가 호출된다”를 단언한다.

이건 내가 방금 짠 가짜들이 내가 짠 대로 동작하는지 확인하는 테스트다. 사용자가 겪는 일과는 아무 관계가 없었고, 대부분 구현 세부사항에 관련된 테스트거나 의미없는 모킹 요소들로 초록불만 보이게 하는 테스트였다.

지금 이 파일은 이렇게 되어 있다.

// 레벨: MSW 통합
import { describe, it } from "vitest"
 
describe("SettingPropertyEdit", () => {
  it.todo("값을 고치고 저장하면 확인 모달이 뜬다")
  it.todo("확인하면 수정한 값이 저장 요청에 담겨 나간다")
  it.todo("저장에 성공하면 목록에 새 값이 보인다")
  it.todo("필수값을 비우고 저장하면 에러 문구가 보이고 편집 상태가 유지된다")
  it.todo("취소하면 원래 값으로 되돌아간다")
  it.todo("새로 추가하던 행을 취소하면 목록에서 사라진다")
})

326줄이 11줄이 됐다. 초록불은 하나도 없다. 대신 무엇을 지켜야 하는지는 남았다. 1장에서 만든 빨간불이 되면 알게 되는 것 칸과 같은 역할이다.

물론 제대로 작성된 테스트 코드도 있었지만, 대부분은 위와 같이 의미없는 모킹으로 테스트를 진행하거나, 구현 세부사항을 테스트 했다.

또한 it.skip이 아니라 it.todo를 사용하였다. skip있었는데 지금 끈 것이고 todo아직 작성하지 않은 테스트라는 의미이다. 리포터에도 다르게 찍힌다. 이 구분이 흐려지지 않게 lint로 못을 박았다.

rules: {
  'testing-library/no-manual-cleanup': 'error',
  'vitest/no-disabled-tests': 'error',   // it.skip 금지
  'vitest/no-focused-tests': 'error',    // it.only 커밋 금지
  'vitest/expect-expect': 'error',       // 단언 없는 테스트 금지
}

이 정리로 테스트 파일 92개에서 8,581줄을 지웠다. 추가한 건 948줄이다.

지운 것 중 대부분이 구현이 구현대로 동작하는지 확인하는 테스트였다. 커버리지로 보면 명백한 손해다. 그런데 저 8,581줄이 지키고 있던 게 무엇이냐고 물으면, 솔직히 답할 수 있는 게 별로 없었다.

결론적으로는 의미없는 테스트 코드를 점진적으로 개선하는 방향, 또 테스트코드를 작성하며, 불필요한 모킹이 많아질 경우, 코드의 구조를 의심하며 점진적으로 리팩토링 해나가려고 한다.


환경 분리도 결정이다

과제 레포에는 기존 테스트가 5개 있었는데 전부 DOM이 필요 없는 테스트였다. 컴포넌트 테스트를 붙이려면 DOM이 필요하다. 전부 jsdom으로 돌리면 통과는 하지만, DOM이 필요 없는 5개까지 매번 브라우저 흉내 환경을 세우게 된다.

vitesttest.projectsnodejsdom 두 프로젝트를 나눴다. 명령은 여전히 vitest run 하나다.

projects: [
  { test: { name: 'node',  environment: 'node',
            include: ['src/**/*.test.{ts,tsx}'],
            exclude: ['src/**/*.dom.test.{ts,tsx}'] } },
  { test: { name: 'jsdom', environment: 'jsdom',
            include: ['src/**/*.dom.test.{ts,tsx}'],
            setupFiles: ['./src/test/setup.dom.ts'],
            environmentOptions: { jsdom: { url: 'http://localhost:3000' } } } },
],

임시로 전부 jsdom인 설정을 만들어 대조군을 쟀다.

프로젝트파일테스트setupenvironment
node (기존 5개)5410ms0ms
jsdom (신규)13179ms401ms

분리의 값어치가 이 표 첫 줄이다. 기존 5개는 DOM 환경 비용을 한 푼도 내지 않는다. 전부 jsdom으로 뒀다면 이 비용이 파일 수에 비례해 계속 쌓인다.


마치며

글을 마치며, 처음의 세 질문에 대한 현재진행형의 결론을 내려보았다.

어디까지 작성해야 할까? 전부는 아니다. 변경 빈도와 실패 비용으로 가른다. 실패 비용이 낮은데 자주 바뀌는 것에 테스트를 붙이면 그건 자산이 아니라 부채라고 결론을 내리게 되었다.

테스트 층의 경계는 어떻게 나눠야 할까? 무엇을 쓰는가가 아니라 **무엇이 없으면 재현이 안 되는가** 로 나눈다. history 스택이 필요하면 E2E, 컴포넌트 배선이 필요하면 통합, 아무것도 필요 없으면 단위다. 그리고 이 배치가 곧 무엇을 잡을 수 있는지를 결정한다. ?page=0 버그가 그 증거였다.

커버리지를 무조건 높이는 게 좋을까? 아니다. 커버리지는 코드가 실행됐는지를 세지, 무언가가 지켜지고 있는지를 세지 않는다. 살아남은 변형 두 개가 지나간 코드는 이미 커버리지 100%였다.

통합테스트가 필요할까

그리고 하나 더 알게 된 게 있다. 이번에 발견한 버그 두 개, 그러니까 서버와 클라이언트의 page 파서가 갈린 것과 회사 Grid에서 페이지 상태를 React와 라이브러리가 각자 들고 있던 것은 모양이 완전히 같았다. 같은 사실을 두 곳에서 계산하고 있었고, 어느 쪽도 혼자서는 틀리지 않았다. 각자 자기 규칙대로 올바른 값을 내니까 단위 테스트로는 잡히지 않았다.

프론트엔드에서 통합 테스트가 필요한 이유가 여기 있다고 생각했다.