테스트에 대한 테스트 — 경계를 한 칸씩 옮기자 여섯 곳 중 다섯 곳이 손볼 자리였다

루프팩 8주차 테스트 회고. 단위·통합 102개와 E2E 4개가 전부 초록불이었지만, 그 테스트들이 무엇을 지키는지는 아무도 확인한 적이 없었다. 경계를 한 칸씩 옮겨보니 아무도 못 잡은 게 둘, 노린 층이 못 잡은 게 하나, 잡았는데 원인을 못 읽은 게 둘이었다.

FrontendTestingVitestMSWPlaywright

8주차 과제는 "커버리지 숫자를 올리지 않는다"로 시작했다. 대신 무엇을 지킬지 먼저 정하고, 그것만 테스트로 고정하고, 내가 쓴 테스트가 진짜로 무언가를 잡는지 직접 확인하라는 것이었다.

앞의 둘은 익숙한 작업이다. 검증 항목 15개를 단위·통합·E2E로 나누고 근거를 적었고, 정한 대로 구현했다. 단위 81개, 통합 21개, E2E 4개. pnpm check가 한 번에 초록불이었다.

문제는 세 번째였다. 나는 구현을 검증하는 코드를 106개 써두고, 그 106개가 무엇을 지키는지는 한 번도 확인한 적이 없었다. 테스트도 설계의 산물인데, 설계가 맞았는지 되짚는 절차는 어디에도 없었던 것이다. 강의 노트에 이런 문장이 있었다.

구현에 일부러 버그를 넣고 테스트가 실패하는지 확인합니다. 테스트가 여전히 통과한다면 그 테스트는 무의미합니다 — mutation testing의 간단한 수동 버전이에요.

한 줄짜리 조언이라 가볍게 봤다. 여섯 곳을 골라 실제로 해봤더니 결과가 이랬다.

망가뜨린 경계처음 결과
결과 0개와 1개 사이아무도 못 잡음
첫 로딩과 갱신 중 사이아무도 못 잡음
전체 페이지 수를 세는 올림노린 단위가 못 잡음, 통합이 대신 잡음
담김·안 담김 표시E2E가 30초 타임아웃으로만 잡음
주소에 적히는 페이지 번호통합이 먼저 잡고, 노린 E2E는 진단이 흐림
문서 제목의 첫 페이지 표기잡힘

여섯 중 테스트를 손대지 않아도 됐던 건 마지막 한 줄뿐이었다. 나머지 다섯은 보강해야 했다. 통과하는 테스트 106개가 곧 지켜지는 동작 106개는 아니라는 걸, 숫자로 확인한 셈이다.

어디를 망가뜨릴지 고르는 게 실험의 절반이었다

과제 문서에 이런 힌트가 붙어 있었다. "세 실험이 전부 한 번에 잡혔다면, 쉬운 곳만 고른 게 아닌지 다시 보세요."

맞는 말이라고 생각했다. 조건을 통째로 뒤집거나 함수 호출을 지우는 변형은 대개 잡힌다. 잡히면 "테스트가 있긴 하다"는 것만 확인하고 끝나서, 남는 정보가 적다. 그래서 이번 회차는 경계값 하나로 축을 통일했다. 값이 갈리는 지점을 한 칸씩만 옮기는 것이다.

기준은 셋으로 정리했다.

  • 눈에 안 보이는 자리를 고른다. 목록이 통째로 안 나오는 변형은 어떤 테스트든 잡는다. 화면은 멀쩡한데 동작만 틀리는 자리를 찾는다.
  • 계층마다 둘씩 넣는다. 하나는 잡힐 법한 곳, 하나는 비어 있을 법한 곳. 대비가 드러나야 배치 문제가 보인다.
  • 동치 변형인지 데이터로 먼저 확인한다. 의미가 안 바뀌는 변형은 원래 못 죽인다.

세 번째는 실제로 후보를 두 개 걸러냈다. resolvePageOverflow의 totalCount === 0을 <= 0으로 바꾸는 변형은 살아남았는데, 이건 테스트 문제가 아니다. 결과 개수에 음수가 들어올 수 없으니 두 조건은 같은 동작이다. 인기순 정렬의 2차 기준을 지우는 변형도 마찬가지였다 — 시드에 동점이 한 쌍뿐이고 그 쌍의 배열 순서와 평점 순서가 이미 같았다.

후보를 고르다가 테스트가 아니라 빌드가 잡은 것도 있었다

ProductListPage의 await connection()을 지우는 변형은 테스트까지 가지도 못했다. pnpm build가 /products 정적 프리렌더 단계에서 실패한다. 주소를 읽어야 하는데 정적 렌더에는 요청이 없기 때문이다. 실험 기록에는 못 넣었지만, pnpm check가 build를 포함하니 검증 체인 전체로 보면 방어되는 자리다.

살아남은 첫 번째 — 0개와 1개 사이가 비어 있었다

빈 결과 안내는 이 한 줄에서 갈린다.

// src/_pages/product-list/ui/ProductListSection.tsx
function ProductResults({ products, ... }: ProductResultsProps) {
  if (products.length === 0) {
    return (
      <div>
        <p>검색 결과가 없습니다.</p>
        <p>다른 검색어나 카테고리를 선택해 보세요.</p>
      </div>
    );
  }
  // ...
}

여기를 products.length <= 1로 한 칸 넓혔다. 결과가 정확히 한 개일 때도 "검색 결과가 없습니다"가 뜬다. 검색 결과가 한 건인 건 흔한 상황이고, 있는 상품을 없다고 말하는 건 사용자 입장에서 꽤 큰 문제다.

통합 21개가 전부 통과했다.

왜 못 잡았는지는 픽스처를 보고 알았다. 빈 결과 테스트는 0개만 쓰고, 목록이 보이는 테스트들은 12개·2개처럼 여유 있는 개수만 쓴다. 경계 바로 옆인 1개짜리 응답이 어디에도 없어서, 0과 1을 가르는 단언 자체가 존재하지 않았다.

상품 한 개짜리 스텁 응답을 만들고 케이스를 추가했다.

it('결과가 하나뿐이면 안내 문구가 아니라 상품 카드 한 장을 보여준다', async () => {
  server.use(http.get(PRODUCT_LIST_ENDPOINT, () => HttpResponse.json(PRODUCT_LIST_STUBS.single)));
  renderWithProviders(<ProductListSection />);

  expect(await screen.findByRole('heading', { name: '데일리 코튼 티셔츠' })).toBeVisible();
  expect(screen.queryByText('검색 결과가 없습니다.')).not.toBeInTheDocument();
  expect(screen.getAllByRole('article')).toHaveLength(1);
});

안내 문구가 없는지를 함께 본 게 핵심이다. 카드가 보이는 것만 확인하면 문구가 같이 떠 있어도 통과한다. 같은 변형을 다시 걸었더니 이 테스트가 잡았다.

살아남은 두 번째 — 일부러 넣은 동작이 통째로 사라져도 몰랐다

목록 화면은 로딩을 두 가지로 나눠 다룬다. 최초 진입은 스켈레톤을 보여주고, 필터를 바꿔 다시 불러오는 중에는 이전 목록을 남긴 채 갱신 중임만 알린다. 화면이 깜빡이지 않게 하려고 일부러 넣은 동작이다.

{isPending ? (
  <ProductGridSkeleton count={PRODUCT_PAGE_SIZE} />
) : !shownList ? (
  <ProductResultsError ... />
) : (
  // 이전 목록을 유지한 채 갱신
)}

첫 줄의 조건을 isPending || isFetching으로 넓혀, 갱신 중에도 보고 있던 목록을 지우고 스켈레톤으로 덮게 했다. 역시 전부 통과했다.

이번엔 픽스처가 아니라 관측 시점이 문제였다. 필터를 바꾸는 테스트들이 전부 findBy로 새 목록이 나타나기를 기다린다. 즉 최종 상태만 본다. 갱신이 진행되는 중간 순간에 이전 목록이 남아 있는지를 확인하는 단언이 하나도 없었다.

중간 순간을 관측하려면 그 순간을 만들어야 한다. 응답을 200ms 지연시켰다.

it('필터를 바꿔 다시 불러오는 동안에는 이전 조건의 목록이 화면에 남는다', async () => {
  renderWithProviders(<ProductListSection />);
  await screen.findByRole('heading', { name: '오버핏 블레이저' });

  server.use(
    http.get(PRODUCT_LIST_ENDPOINT, async () => {
      await delay(200);
      return HttpResponse.json(PRODUCT_LIST_STUBS.casual);
    })
  );
  await userEvent.selectOptions(screen.getByLabelText('카테고리'), 'casual');

  // 새 결과가 오기 전 순간. 스켈레톤으로 갈아끼우지 않고 이전 결과를 남긴 채 갱신 중임만 알린다.
  expect(screen.getByRole('heading', { name: '오버핏 블레이저' })).toBeVisible();
  expect(getResultsRegion()).toHaveAttribute('aria-busy', 'true');

  // 새 결과가 도착하면 이전 조건의 상품이 사라진다.
  await waitFor(() => expect(screen.queryByRole('heading', { name: '오버핏 블레이저' })).not.toBeInTheDocument());
  expect(screen.getByRole('heading', { name: '데일리 코튼 티셔츠' })).toBeVisible();
});

이 두 건을 나란히 놓고 보니 공통점이 있었다. 둘 다 경계를 두 값으로만 나눈 자리였다. 빈 결과는 "0개 / 넉넉한 개수"만 봤고, 로딩은 "최초 / 완료"만 봤다. 가운데 값(1개)과 중간 순간(갱신 중)이 비어 있었다.

단위가 못 잡은 걸 통합이 우연히 대신 잡고 있었다

세 번째는 페이지 보정 함수다.

// src/_pages/product-list/lib/resolvePageOverflow.ts
export function resolvePageOverflow(page: number, totalCount: number, pageSize: number): number | null {
  if (totalCount === 0) return null;

  const totalPages = Math.ceil(totalCount / pageSize);   // ← 여기를 Math.round로

  if (page <= totalPages) return null;
  return totalPages;
}

올림을 반올림으로 바꿨다. 순수 함수이고 단위 테스트가 6개나 붙어 있으니 당연히 단위가 잡을 줄 알았다. 단위 81개가 전부 통과했다.

테스트 파일을 열어 보고 이유를 알았다. 6개가 전부 같은 조합을 쓴다.

expect(resolvePageOverflow(1, 30, 12)).toBeNull();
expect(resolvePageOverflow(3, 30, 12)).toBeNull();
expect(resolvePageOverflow(4, 30, 12)).toBe(3);
expect(resolvePageOverflow(999, 30, 12)).toBe(3);
// 빈 결과 케이스 2개는 나눗셈 앞에서 빠져나간다

30 ÷ 12 = 2.5인데, 소수부 0.5는 올림과 반올림이 일치하는 유일한 값이다. 페이지 번호 쪽 경계(첫 페이지 / 마지막 페이지 / 한 칸 초과 / 크게 초과)는 촘촘한데, 페이지 수를 만들어내는 축은 값이 하나뿐이었다.

실제로 이 버그가 나가면 결과 개수가 페이지 크기의 절반보다 적을 때(검색 결과 1~5개) 전체 페이지 수가 0으로 계산된다. 첫 페이지에 있는데도 "범위를 넘겼다"고 판정돼 주소의 페이지를 0으로 되돌리려 든다.

더 마음에 걸린 건 통합 4개가 대신 잡았다는 사실이었다. 언뜻 "계층이 서로를 받쳐주고 있다"로 읽히지만, 확인해 보니 통합 스텁의 총 개수가 마침 14개라 14 ÷ 12 = 1.17이었고, 반올림하면 1페이지가 되어 2페이지 관련 테스트들이 무너진 것뿐이었다. 스텁 개수를 24개나 12개로 바꾸는 순간 이 방어는 조용히 사라진다.

비싼 계층이 싼 계층의 일을 우연히 대신하고 있는 배치였다. 규칙을 만드는 축을 단위에서 고정했다.

describe('마지막 조각도 한 페이지로 세어 전체 페이지 수를 낸다', () => {
  it('마지막 페이지가 페이지 크기의 절반도 못 채워도 그 페이지는 범위 안이다', () => {
    // 25개는 12 + 12 + 1이라 3페이지다. 나머지 1을 버리면 2페이지가 되어 마지막 상품에 닿지 못한다.
    expect(resolvePageOverflow(3, 25, 12)).toBeNull();
    expect(resolvePageOverflow(4, 25, 12)).toBe(3);
  });

  it('결과가 한 페이지를 못 채워도 한 페이지는 있다', () => {
    expect(resolvePageOverflow(1, 5, 12)).toBeNull();
    expect(resolvePageOverflow(2, 5, 12)).toBe(1);
  });

  it('페이지 크기로 나누어떨어지면 빈 페이지를 더 만들지 않는다', () => {
    expect(resolvePageOverflow(2, 24, 12)).toBeNull();
    expect(resolvePageOverflow(3, 24, 12)).toBe(2);
  });
});

나머지가 절반보다 적은 조합, 한 페이지를 못 채우는 조합, 나누어떨어지는 조합을 각각 범위 안·범위 밖 두 방향으로 단언한다. 같은 변형을 다시 걸었더니 이제 단위 2개가 먼저 잡는다.

잡히긴 잡혔는데, 실패 메시지가 원인을 안 알려줬다

과제는 잡힌 경우에도 "실패 메시지만 보고 원인을 짐작할 수 있었는지" 적으라고 했다. 이 질문이 생각보다 날카로웠다.

담기 버튼의 담김 상태 표시를 뒤집었다.

// src/_pages/product-list/ui/CartToggleButton.tsx
<button type="button" aria-label={`${productLabel} 장바구니`} aria-pressed={isInCart} ... >
//                                                            ↑ !isInCart 로 뒤집음

단위·통합 102개는 전부 통과하고 E2E 하나만 실패했다. 계층 분리가 의도대로 작동한 것처럼 보였는데, 메시지를 보니 그렇지 않았다.

locator.click: Test timeout of 30000ms exceeded.
waiting for locator('button[aria-label$="장바구니"][aria-pressed="false"]')

"안 담긴 버튼을 못 찾았다"까지만 알려준다. 표시가 뒤집힌 건지, 버튼이 아예 안 그려진 건지, 페이지가 안 뜬 건지 구분되지 않는다. 게다가 30초를 기다린 뒤에야 실패한다.

통합 테스트가 이 경계를 못 본 이유는 버튼을 이름으로만 찾고 담김 표시를 읽지 않아서였다. 그 단언을 통합에 내렸다.

it('담기 전에는 담기지 않은 상태로, 담은 뒤에는 담긴 상태로 표시된다', async () => {
  renderListWithHeader();
  await screen.findByRole('heading', { name: '데일리 코튼 티셔츠' });

  const cartButton = screen.getByRole('button', { name: '데일리 코튼 티셔츠 장바구니' });
  expect(cartButton).toHaveAttribute('aria-pressed', 'false');

  await userEvent.click(cartButton);

  expect(cartButton).toHaveAttribute('aria-pressed', 'true');
});

같은 변형을 다시 걸면 E2E가 30초를 기다리기 전에 통합이 43ms 만에 expected element to have attribute aria-pressed="false"로 잡는다. E2E는 지우지 않고 그대로 뒀다. 라우트를 넘나드는 시나리오가 그 항목의 본래 목적이고, 표시 회귀의 1차 방어선만 통합으로 내린 것이다.

주소에 적히는 페이지 번호를 하나 작게 쓰는 변형에서도 비슷한 일이 있었다.

// src/_pages/product-list/lib/productListFilters.ts
serialize: (value) => String(Math.round(value))   // ← Math.round(value) - 1 로

화면은 2페이지인데 주소는 1페이지라, 새로고침하거나 링크를 공유하면 다른 페이지가 열린다. E2E 항목을 노린 변형이었는데 통합이 먼저, 더 좁은 범위로 잡았다. expected '1' to be '2'라는 메시지가 테스트 이름과 함께 나와 원인이 바로 읽혔다.

정작 E2E는 새로고침 전후 상태를 통째로 비교하는 방식이라 어디가 달라졌는지 찾는 데 한 단계가 더 걸렸다. 성질 기반 비교는 시드가 바뀌어도 안 깨진다는 장점이 있지만, 표기가 통째로 어긋나도 전후가 같으면 통과한다. 절대값 단언을 한 줄 넣었다.

// 화면과 주소가 같은 페이지를 가리키는지 먼저 못 박는다.
await expect(page).toHaveURL(/[?&]page=2(&|$)/);

실험을 시작하기도 전에 새고 있던 것

세 번째 단계로 넘어가기 전, 구현을 마무리하면서 대기 코드를 한 번 훑었다. "E2E에 await가 너무 많은 것 아닌가"라는 의심에서 시작했는데, await 자체는 문제가 아니었다. 대신 기다리는 조건이 틀린 곳이 나왔다.

await userEvent.selectOptions(screen.getByLabelText('정렬'), 'price-asc');
await waitFor(() => expect(screen.getByRole('heading', { name: '우드 디퓨저' })).toBeVisible());

const ascendingPrices = getVisiblePrices();

우드 디퓨저는 정렬 전 기본 응답에도 들어 있다. 조건이 조작 전에도 참이니 이 줄은 아무것도 기다리지 않는다. 그런데도 통과하고 있었던 건 MSW 응답이 userEvent.selectOptions의 내부 flush 안에서 끝나기 때문이었다. 하네스의 타이밍 우연에 기대고 있었던 셈이다.

핸들러에 delay(50)을 주입하니 바로 드러났다.

- Expected  [19000, 29000, 39000, 45000, ...]
+ Received  [19000, 29000, 39000, 45000, ..., 59000, 89000, 129000]   ← 정렬 전 순서

이 테스트가 재는 값이 화면의 가격이므로, 기다리는 대상도 가격으로 맞췄다.

const initialPrices = getVisiblePrices();

await userEvent.selectOptions(screen.getByLabelText('정렬'), 'price-asc');
await waitFor(() => expect(getVisiblePrices()).not.toEqual(initialPrices));

waitForElementToBeRemoved로 바꾸려던 계획은 철회했다

강의 예제에서 본 패턴이라 자연스러워 보였는데, 호출 시점에 요소가 남아 있어야 하는 API다. 응답이 userEvent 안에서 끝나 이미 사라진 뒤라 already removed 예외를 던진다. 지연을 주입한 실험에서만 통과하고 평소에는 실패했다. 같은 이유로 다른 파일의 waitFor + queryBy 조합도 그대로 두는 게 맞았다.

E2E에서도 같은 모양이 나왔다. aria-busy는 false → true → false로 움직이는데, 조작 직후에 바로 false를 확인하면 요청이 시작되기 전 상태를 통과시킨다. 조건이 주소에 실린 것을 먼저 확인하고 나서 결과 영역을 읽도록 바꿨다.

두 곳 다 대기 API를 잘못 골라서 생긴 게 아니었다. 시나리오가 무엇을 검증하는지 흐려서 잘못된 조건이 들어와도 아무도 못 알아챈 것이다. await waitFor(() => expect(...).toBeVisible())는 겉모습이 단언이라, "이게 무엇을 기다리는 조건인가"를 아무도 묻지 않게 만든다.

규칙을 두 줄로 정리했다.

  • 대기 조건은 조작 전에는 거짓, 조작 후에는 참이어야 한다. 가르지 못하면 그 줄은 없는 것과 같다.
  • 기다리는 대상은 그 테스트가 재는 값으로 고른다. 가격을 재는 테스트는 가격을 기다린다. 다른 요소를 신호로 삼으면 픽스처를 알아야만 이해되는 코드가 된다.

이 기준으로 나머지 대기를 전부 대조했더니, 문제가 된 건 그 한 줄뿐이었다.

이 실험이 거짓말하지 않으려면 미리 정해뒀어야 했던 것

여기까지 쓰고 보니, 실험이 성립한 건 앞 단계에서 내린 결정 두 개 덕이었다.

하나는 모킹 경계다. 처음엔 MSW 핸들러가 서버의 필터·정렬·페이징 함수를 그대로 불러 응답을 만들게 하려 했다. 조건 조합마다 픽스처를 안 만들어도 되니 편하다. 그런데 그렇게 하면 기대값을 계산하는 경로와 구현이 같아져서, 테스트가 동어반복이 된다. 게다가 필터링 동작은 이미 서버 쪽 단위 테스트가 덮고 있어서 계층이 겹친다. 조건 키로 미리 써둔 응답을 고르는 스텁으로 바꿨다.

이 선택 덕에 "결과가 정확히 한 개"나 "정확히 2페이지" 같은 경계 상태를 직접 만들 수 있었다. 시드에서 역산했으면 시드가 하나만 늘어도 관련 없는 테스트가 깨졌을 것이다.

다른 하나는 격리다. 장바구니·위시리스트 스토어는 모듈 최상위에서 만들어지는 인스턴스라 프로세스 전체에 하나뿐이다. 초기화하지 않으면 앞 테스트가 담아둔 상품 덕에 "담기를 누르면 개수가 1이 된다"가 통과한다. 담기 호출을 지워도 통과한다. 변형 실험에서 살아남는 변형이 정확히 이렇게 생긴다.

전역 setup의 afterEach에 초기화를 뒀다. 파일별로 두면 새 파일에서 빠뜨릴 수 있고, 빠뜨리면 조용히 통과하는 테스트가 생겨 리뷰에서도 안 보인다. 등록 순서에는 이유가 있어서 주석을 남겼다 — Vitest의 afterEach는 등록 역순으로 실행되므로, 초기화를 cleanup보다 먼저 등록해야 언마운트가 끝난 뒤에 스토어가 바뀐다.

스토어를 Context 기반으로 바꾸면 격리가 구조적으로 보장되지만 하지 않았다. 지금 구조에 문제가 있어서가 아니라 테스트가 편해지려고 바꾸는 것이기 때문이다. 반대로 문서 제목을 조립하는 계산은 조회 함수 안에 섞여 있어서 떼어냈다. "테스트하기 어렵다"가 구조 결함의 신호일 때와 그냥 테스트 편의일 때를 가르는 게 이번 주에 제일 애매했던 판단이었다.

여섯 번을 한 장으로

망가뜨린 경계처음 결과왜 못 잡았나보강
결과 0개와 1개아무도 못 잡음0개와 넉넉한 개수만 있고 1개 픽스처가 없음상품 1개 스텁 + 통합 1케이스
첫 로딩과 갱신 중아무도 못 잡음최종 상태만 보고 중간 순간을 안 봄지연 응답 중 이전 목록 유지 통합 1케이스
전체 페이지 수 올림단위 실패, 통합이 우연히 잡음단위 6개가 전부 30 ÷ 12 = 2.5 — 올림·반올림 일치 유일값단위 3케이스(25·12 / 5·12 / 24·12)
담김 표시E2E가 30초 타임아웃으로만 잡음통합이 버튼을 이름으로만 찾고 표시를 안 읽음통합에 표시 단언 → 43ms에 잡힘
주소의 페이지 번호통합이 잡음, E2E 진단 흐림E2E가 전후 비교만 해서 표기 전체 변경을 못 가름E2E에 page=2 절대값 단언 한 줄
문서 제목 첫 페이지 표기잡힘, 메시지도 분명—없음

보강은 전부 테스트만 늘리는 방향이었다. 구현은 한 줄도 안 고쳤고, 실험용으로 망가뜨린 코드는 되돌린 뒤 pnpm check 전체 통과를 다시 확인했다. 단위·통합 102개가 108개가 됐다.

배운 것

살아남을 법한 곳을 노려야 정보가 남는다. 잡힌 실험에서 얻은 건 실패 메시지 판독 기록 정도였다. 정보는 전부 살아남은 쪽에서 나왔고, 살아남은 자리는 하나같이 경계를 두 값으로만 나눈 곳이었다. 유효/무효, 0개/넉넉한 개수, 최초/완료. 가운데와 반대쪽 끝이 비어 있었다.

순수 함수를 단위로 덮었다고 그 축이 다 덮인 게 아니다. 페이지 보정 함수는 단위 테스트가 6개나 있었는데, 촘촘했던 건 페이지 번호 축뿐이고 페이지 수를 만들어내는 축은 값이 하나였다. 케이스 개수는 커버리지처럼 보이지만 커버리지가 아니다.

초록불이 어느 계층에서 켜졌는지도 봐야 한다. 통합이 단위 대신 잡고 있던 건 설계가 아니라 스텁 개수의 우연이었고, E2E만 잡던 건 30초를 기다린 뒤에야 "못 찾았다"고 말해줬다. 같은 회귀를 어느 층이 얼마나 빨리, 얼마나 분명하게 알려주는가가 층을 나누는 실질적인 근거가 됐다.

강의 노트의 한 줄을 실제로 여섯 번 해보기 전까지는, 나도 테스트 106개가 초록불이면 동작 106개가 지켜지는 줄 알았다. is-it-going-well