단위 테스트에서 검증한 걸, 통합 테스트가 왜 다시 검증하는가

작성일:2026.08.21|수정일:2026.08.21|조회수:9

통합 테스트를 짜다가, 방금 단위 테스트에서 단언한 조건을 내가 다시 단언하고 있는 걸 발견했다. URL에서 쿼리 키를 만드는 변환은 단위 테스트에서 이미 모든 조합을 통과시켰는데, 통합 테스트의 expect가 같은 변환의 결과를 다시 확인하고 있었다. 둘 다 통과하니 안심은 됐다. 다만 그 변환이 깨지면 두 테스트가 동시에 빨갛게 변할 텐데, 그게 두 겹의 안전망인지 같은 그물을 두 번 깐 건지 판단이 서지 않았다.

이번 주에 한 레포지토리에 단위·통합·E2E 테스트를 한 번에 깔면서(41 files, 280 tests) 계속 붙잡고 있던 질문이 이것이다. 각 층은 무엇을 지키고, 어디부터는 건드리지 말아야 하는가. 사용한 도구는 단위와 통합이 Vitest(통합 쪽만 RTL을 얹는다), E2E가 @playwright/test, 그리고 테스트 자체를 검증하는 변형 테스트로 Stryker였다. 도구 이름보다 먼저 정리해야 했던 건 각 층의 책임이었다.

단위 테스트: DOM도 네트워크도 없이

단위 테스트의 대상은 부수효과 없는 순수 변환이다. 내 경우에는 URL 검색 조건을 요청 객체로 정규화하는 ProductListRouteParams.toRequest와, 그 요청으로 캐시 키를 만드는 ProductQueryKeyFactory가 여기에 걸렸다. 이 변환이 깨지면 다른 조건이 같은 캐시를 읽거나, 멀쩡한 캐시를 잃는다. 사용자가 화면에서 확인하는 증상은 "분명 카테고리를 바꿨는데 목록이 그대로" 같은 형태인데, 원인은 화면에서 한참 떨어진 순수 함수에 있다. DOM을 세우지 않고, 네트워크를 켜지 않고, 입력만으로 결과가 정해지는 것만 검증한다. Vitest의 Node 환경에서 도는 테스트다. 이번 주에 추가한 단위 테스트 하나를 보면 이 경계가 보인다.

TS
it('omits default q and category from canonical search', () => {  const request = ProductListRouteParams.toRequest({    q: '',    category: 'all',    sort: 'latest',    page: '1',    pageSize: '12',    scenario: 'slow',  })  expect(    ProductListRouteParams.canonicalSearchParams(request).toString(),  ).toBe('sort=latest&page=1')})

이 테스트가 지키는 계약은 "기본값은 URL에 남기지 않는다"다. q가 비어 있는데 q=가 URL에 남으면 공유 링크를 받은 사람은 빈 검색어가 고정된 화면을 본다. 실행 비용은 밀리초 단위라 경계 케이스를 계속 늘려도 부담이 없다. 이 테스트는 나중에 변형 테스트에서 살아남은 뮤탄트를 잡으러 추가된 것인데, 그 이야기는 뒷부분에 다시 나온다.

통합 테스트: 조작에서 화면까지 한 경계에서

통합 테스트의 대상은 연결이다. 사용자가 셀렉트를 바꾸면 URL이 갱신되고, 갱신된 URL이 요청을 만들고, 그 응답이 화면에 표시되기까지의 흐름을 한 테스트에서 끊김 없이 확인한다. 내 통합 테스트는 Vitest의 jsdom 프로젝트에서 돌리되 React Testing Library로 렌더하고, 네트워크는 MSW가 가로채게 했다. fetch나 HTTP 클라이언트를 바꿔치기하지 않는다. 요청은 실제로 나가고, MSW 핸들러가 그 요청을 인터셉트하는 구조여야 경계가 현실과 같게 유지된다.

에러에서 재시도로 복구되는 흐름을 검증하는 테스트는 이렇게 생겼다.

TSX
it('removes the alert and shows products when a retry succeeds', async () => {  // Arrange  const user = userEvent.setup()  let requestCount = 0  server.use(    http.get('http://localhost:3000/api/products', () => {      requestCount += 1      return requestCount === 2        ? HttpResponse.json(productList([product]))        : HttpResponse.json(            { message: '일시적인 오류입니다.' },            { status: 503 },          )    }),  )  renderProductList()  await screen.findByRole('alert')  // Act  await user.click(screen.getByRole('button', { name: '다시 시도' }))  // Assert  await screen.findByRole('heading', { name: product.name })  expect(screen.queryByRole('alert')).not.toBeInTheDocument()  expect(requestCount).toBe(2)})

여기서 단언하는 건 공개 상태의 전환이다. 경고가 사라졌는가, 상품이 보이는가, 그리고 요청이 실제로 두 번째로 나갔는가. requestCount 단언이 좀 과해 보일 수 있는데, 이걸 빼면 버튼을 눌러도 아무 요청 없이 화면만 다시 그리는 구현이 조용히 통과한다. 이 테스트가 빨갛게 변하면 "재시도가 요청을 보내지 않는다"는 사실을 실패 메시지만으로 바로 알 수 있어야 한다. 원인을 짐작하려고 구현을 열어봐야 하는 테스트는 이름이나 단언이 부족한 자리다.

DOM이 필요한 이런 테스트만 jsdom에서 돌리고, 나머지 단위 테스트는 Node 환경에서 돌리게 Vitest projects로 환경을 갈랐다. 전부 jsdom으로 돌리는 쪽도 측정해 봤는데 wall-clock 중앙값이 2.23s 대 6.34s였다. 환경을 나누는 일은 취향이 아니라 비용 문제다.

E2E: 실제 브라우저가 아니면 검증할 수 없는 것

E2E의 대상은 브라우저 계약이다. 공유 URL로 재진입했을 때 필터가 복원되는지, 뒤로 가기를 눌렀을 때 이전 조건과 결과가 돌아오는지, 새로고침 뒤에도 상태가 남는지 같은 것들은 jsdom에서 아무리 정성껏 흉내 내도 결국 실제 라우터와 실제 document가 하는 일이다. 반대로 말하면, 이 목록에 들지 못하는 검증은 E2E에 넣을 이유가 없다. 뒤로 가기 복원을 검증하는 테스트는 이렇다.

TS
test('restores the previous filter and result when the browser moves back', async ({  page,}) => {  // Arrange  await page.goto('/products')  const category = page.getByRole('combobox', { name: '카테고리' })  await category.selectOption('home')  await page.waitForURL(/category=home/)  await page.getByRole('heading', { name: stanleyLunchbox }).waitFor()  await category.selectOption('fashion')  await page.waitForURL(/category=fashion/)  await page.getByRole('heading', { name: fashionPullover }).waitFor()  // Act  await page.goBack()  // Assert  await expect(category).toHaveValue('home')  await expect(    page.getByRole('heading', { name: stanleyLunchbox }),  ).toBeVisible()})

Playwright는 production build 위에서 돌린다. 개발 서버에서만 통과하는 동작은 배포에서 깨질 수 있으니 인정하지 않기로 했다. 카테고리 변경, 정렬 변경, 페이지 이동처럼 한 화면 안의 조작은 통합 테스트에서 nuqs testing adapter로 이미 검증하고 있기 때문에 E2E로는 가져오지 않았다. 같은 흐름을 느린 브라우저에서 한 번 더 돌린다고 신뢰가 두 배가 되지는 않는다. 실행 시간만 두 배가 될 뿐이다.

변형 테스트: 테스트가 진짜 무언가를 잡는가

여기까지는 "테스트를 잘 쓰자"는 이야기인데, Stryker는 질문 자체를 바꾼다. 내 테스트가 무언가를 지키고 있는가,를 확인하는 가장 원시적인 방법은 구현을 일부러 망가뜨려 보는 것이다. Stryker는 이걸 기계가 한다. 조건을 뒤집고, 비교 연산자를 바꾸고, 리터럴을 엉뚱한 값으로 교체한 변형(mutant)을 만들어서 기존 테스트 스위트에 돌린다. 테스트가 변형을 잡아 죽이면 killed, 통과해 버리면 survived다. 설정은 짧은데, 핵심은 mutate 목록이다.

JS
const strykerConfig = {  packageManager: 'pnpm',  testRunner: 'vitest',  plugins: ['@stryker-mutator/vitest-runner'],  mutate: [    'src/views/product-list/model/ProductListStatePolicy.ts',    'src/entities/product/model/ProductListRouteParams.ts',    'src/entities/product/model/ProductQueryKeyFactory.ts',  ],}

mutate 대상을 단위 로직 세 파일로 좁힌 게 중요하다. 통합·E2E가 걸리는 파일까지 넣으면 실행 시간이 끝나지 않는 방향으로 늘어난다. 실제로는 9초가 걸렸고, 점수는 89.13에서 97.83까지 올랐다.

살아남은 변형 중 의미 있는 것이 두 개 있었다. 하나는 request.q !== '' 조건을 true로 강제하는 변형이었는데, 이건 "기본값 q를 URL에서 생략한다"는 계약을 테스트가 지키지 않고 있어서 살아남았다. 앞에서 보여준 단위 테스트가 그때 추가된 것이다. 직접 구현을 망가뜨려 보는 수동 실험을 세 번 했을 때는 전부 한 번에 잡혔는데, Stryker는 그 손 실험이 놓친 더 세밀한 경계를 찾아줬다. 내가 의도적으로 망가뜨리는 곳은 결국 내가 의심할 줄 아는 곳뿐이라는 뜻이기도 하다.

이름은 USE, 본문은 AAA

위에 나온 테스트에는 공통된 규약이 두 개 있다. 이름을 짓는 방식과 본문을 구성하는 방식이다.

이름은 USE 패턴을 따른다. Unit of work(테스트 대상 기능), Scenario(그 테스트가 진행되는 조건), Expected behavior(기대하는 결과)를 이름에 담는 규칙이다. 이번 주에 쓴 통합 테스트 이름을 이 규칙으로 잘라 보면 이렇다.

TS
it('Product list cart control - when a product is added - updates the Header count to one', ...)

Unit of work는 상품 목록의 담기 조작, Scenario는 상품을 추가했을 때, Expected behavior는 헤더 개수가 1이 되는 것이다. 테스트가 실패하면 러너 출력의 이름만 보고도 어떤 동작이 빨갛게 변했는지 알 수 있다. it worksaddToCart adds item 같은 이름은 실패했을 때 원인을 짐작할 출발점을 주지 못해서, 통과할 때는 편하고 깨졌을 때는 비용을 청구한다.

본문은 AAA 패턴으로 짠다. 준비(Arrange), 행동(Act), 검증(Assert)의 세 역할로 나누는 가장 흔한 구조인데, 거창한 규칙이라기보다는 읽는 순서를 고정하는 장치에 가깝다. 재시도 테스트에서 어떤 상태에서 출발해서(핸들러와 초기 화면), 무엇을 건드렸고(클릭 한 번), 그 결과 무엇을 요구하는지(requestCount와 화면 상태)가 주석만 따라가도 보인다. 나는 단계 사이에 빈 줄을 두고 Act는 한 줄로 끝내는 편을 선호하는데, Act가 여러 줄로 불어나면 그 테스트의 목적이 흐려지기 때문이다.

비슷한 것으로 Given-When-Then이 있다. Given이 전제 조건, When이 트리거, Then이 기대 결과라는 점에서 AAA와 사실상 같은 구조를 다른 단어로 부르는 것이고, BDD 계열 도구들이 이 이름을 쓴다. 어느 쪽을 고르든 기준은 하나다. 테스트를 여섯 달 뒤에 열었을 때 의도를 재구성하지 않아도 되는가. 이름이 무엇을 검증하는지 말하고, 본문이 어디서부터 어디까지가 검증인지 말해 주면 된다.

같은 것을 두 번 검증하지 않으려면

처음의 질문으로 돌아가서, 단위에서 검증한 것을 통합에서 다시 확인하지 않으려면 어떻게 해야 하는가. 내가 이번 주에 얻은 답은 "기능 단위로 나누지 말고 검증 대상 단위로 나눈다"는 것이다.

기능 단위로 나누면 "상품 목록 필터는 단위·통합·E2E에서 각각 한 번씩" 같은 배치가 나온다. 층마다 같은 로직을 다른 분장만으로 다시 검증하게 되고, 빨간불이 켜지면 세 테스트가 동시에 죽는다. 신호가 세 개가 아니라 하나의 신호가 세 번 반복되는 셈이다.

검증 대상 단위로 나누면 각 층이 소유하는 실패가 달라진다. 나는 설계 문서에 검증 항목마다 "빨간불이 되면 알게 되는 것"을 한 문장씩 적었는데, 이게 중복을 거르는 필터가 됐다.

도구 지키는 것 의도적으로 안 하는 것
단위 Vitest URL → 요청 → 쿼리 키의 순수 변환, 상태 정책 DOM 렌더링, 네트워크 요청
통합 Vitest + RTL + MSW 조작 → URL → 요청 → 화면의 연결, 공개 상태 전환 순수 함수의 내부 분기 재단언
E2E Playwright URL 재진입, history, reload 한 화면 안의 조작 시나리오
변형 Stryker 단위 테스트가 의도를 실제로 고정하는지 통합·E2E 범위까지 확장

이 배치에서 통합 테스트는 단위 로직을 다시 검증하지 않고 사용한다. 재시도 테스트는 ProductListRouteParams.toRequest와 쿼리 키 팩토리의 실제 코드를 통과하지만, 키의 모양을 단언하지 않는다. 그 변환이 깨지면 단위 테스트가 먼저 빨갛게 변하고, 통합 테스트는 "요청이 이상한 키로 나갔다"는 결과로 따라 죽는다. 실패의 원인과 결과에 각 층의 이름표가 붙어 있는 상태, 그게 중복 없이 층을 쌓았다는 신호라고 생각한다.

단위가 정말로 자기 몫을 지키는지는 결국 변형 테스트가 확인해 준다. 단위 테스트가 통과만 하고 의도를 고정하지 못한다면, 통합 테스트가 그걸 다시 검증하게 두는 편이 나을 수도 있다. 중복의 반대말은 방임이니까.

이번 작업에서 어려웠던 건 테스트 문법이 아니라 배치였다. 어떤 검증은 단위에 두고, 어떤 검증은 통합에서만 하고, 어떤 검증은 브라우저까지 가야 끝난다. 이 판단을 도구 선택보다 먼저 끝내면 코드 양은 줄고 실패 메시지는 정확해진다. 테스트는 품질을 증명하는 장식이라기보다, 각 층이 어떤 계약을 지키기로 했는지 적어 둔 문서에 가깝다. 그 문서에서 각 조항의 소유자가 한 명씩일 때, 빨간불은 원인을 가리킨다.

댓글

댓글을 불러오는 중...