문서 읽는 데 59분 · F1

F-1: 왜 테스트인가 — 이미 있었고, 재봅니다

목차 43
전체 59강 중 39강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다.

상황 하나만 그려볼게요.

여러분이 좋아요 버튼의 숫자 계산을 한 줄 살짝 고쳤다고 해봅시다. 저장하고 브라우저를 열어 하트를 눌러보니 잘 됩니다. 그래서 그대로 배포했어요.

다음 날 문의가 들어옵니다. 댓글이 안 지워진다고요.

좋아요를 고쳤는데 댓글 삭제가 망가졌는데, 둘이 무슨 상관인가 싶겠지만 우리 앱에서 그 둘은 같은 목록 도구인 A-6에서 만든 removeById를 쓰고 있어서, 좋아요 쪽에서 그 함수의 동작을 조금 바꾸니 댓글 쪽이 같이 물린 거예요.

이걸 배포 전에 잡으려면 어떻게 해야 할까요. 한 줄을 고칠 때마다 화면을 열고 좋아요를 누르고 댓글을 지우고 팔로우를 하고 로그인을 다시 하고... 지난 시간에 우리가 컨테이너를 띄워놓고 curl 다섯 줄을 손으로 확인했던 것 기억하시죠. 그게 다섯 줄이라 가능했던 거지, 앱 전체는 그렇게 못 하니 오늘부터 그 확인을 코드로 적어볼게요.

오늘 우리가 처음 할 일은 그런 코드를 쓰는 게 아니라, 이 저장소를 열어서 그런 코드가 이미 몇 개나 있는지 세어보는 것부터 시작하는 거예요. 세어보면 아마 좀 놀라실 거예요.

텍스트
오늘의 여정

  손으로 확인하던 것        ──  왜 안 되는가
  테스팅 트로피             ──  맨 아래 층은 이미 우리 것이었다
  저장소를 열어본다         ──  몇 개가 있나
  설정 파일을 읽는다        ──  세 줄
  그 세 줄을 끊어본다       ──  무엇이 무너지나
  커버리지를 켠다           ──  첫 숫자
  그 숫자를 의심한다        ──  셋 다 거짓말할 수 있다

💡 오늘 수업의 핵심 — "숫자를 만들기 전에, 그 숫자가 무엇을 세고 있는지부터 안다"

🎯 학습 목표

  • 손으로 하는 확인이 왜 앱 크기와 함께 무너지는지 설명하고, 테스팅 트로피의 네 층을 우리 저장소에 그대로 짚을 수 있다.
  • Vitest가 "설정이 거의 없다" 고 말하는 근거가 무엇인지 알고, 그 재사용을 끊었을 때 실제로 무엇이 무너지는지 확인할 수 있다.
  • 커버리지 숫자를 뽑고, 그 숫자가 세는 범위와 재는 환경에 따라 어떻게 달라지는지 설명할 수 있다.

Step 1: "손으로 확인하던 것"

지금까지 우리가 무언가를 고칠 때마다 한 일을 떠올려봅시다.

D-2에서는 <Link>로 옮겨 다니며 요청이 몇 건 나가는지 개발자 도구를 열어 셌고, D-4에서는 첫 바이트가 몇 ms인지 쟀죠. 그리고 D-7에서는 팔로우 버튼을 누르고 서버 로그를 봤고, D-10에서는 컨테이너를 띄우고 curl을 다섯 번 날렸습니다.

전부 손으로, 그때 한 번만 한 거였죠.

여기서 중요한 건 "귀찮다"가 아닙니다. 진짜 문제는 이겁니다.

확인은 고친 곳에만 하게 된다

사람은 자기가 방금 고친 곳을 확인하니까, 좋아요를 고쳤으면 좋아요를 눌러보지만 아무도 좋아요를 고치고 나서 댓글 삭제까지 눌러보지는 않아요. 그럴 이유가 없다고 생각하니까요.

코드는 그렇게 안 나뉘어 있습니다. 그런데 우리 앱에서 좋아요와 댓글 삭제는 이렇게 이어져 있어요.

텍스트
  LikeButton ──┐
               ├──> removeById  (A-6 에서 만든 목록 도구)
  CommentList ─┘

  한쪽을 보고 고치면 다른 쪽이 조용히 물린다

이렇게 "고친 곳 말고 다른 데가 망가지는 것"을 회귀라고 부르는데, 되돌아간다는 뜻으로 잘 되던 게 다시 안 되게 된 거니까요.

회귀는 손으로 못 잡습니다. 잡으려면 고칠 때마다 앱 전체를 다시 확인해야 하는데, 그게 사람이 할 수 있는 양이 아니거든요.

그래서 확인을 코드로 적습니다

한 번 적어두면, 그 뒤로는 명령 한 줄에 전부 다시 확인됩니다. 좋아요를 고치든 댓글을 고치든 상관없이 매번 전부요.

우리가 오늘 세어볼 게 바로 그 코드입니다.

💡 한 줄 정리

손으로 하는 확인의 진짜 한계는 느린 게 아니라 고친 곳만 보게 된다는 것이고, 회귀는 정확히 그 바깥에서 생긴다.

🙋 학생 질문 — "타입 검사가 있잖아요. tsc가 이런 것도 잡아주지 않나요?"

절반은 맞는데, 그 절반이 생각보다 커요.

A-6에서 본 것처럼 tsc --noEmit은 없는 속성을 읽거나, 인자 개수가 틀리거나, undefined 일 수 있는 값을 그냥 쓰는 것을 잡아주고, C-7에서는 테스트가 전부 통과하는데 타입 검사가 4건을 잡아낸 적도 있었죠.

하지만 타입 검사가 보는 건 값이 맞는지가 아닙니다. removeByIdT[]를 돌려준다는 것은 검사해도 그 배열에서 진짜로 그 번호만 빠졌는지는 모릅니다. likeCount + 1likeCount - 1로 잘못 써도 둘 다 number라 통과해요.

정리하면, 타입 검사는 모양을 보고 테스트는 값을 보는데 둘은 겹치지 않아서, 다음 Step에서 볼 트로피에도 둘이 따로 있습니다.


Step 2: "트로피의 맨 아래는 이미 우리 것이었다"

테스트를 어떤 비율로 섞어야 하는가에 대한 그림이 있는데, 예전에는 아래가 넓고 위가 좁은 삼각형인 피라미드를 그려서 단위 테스트는 아주 많이, 통합 테스트는 조금, 화면 전체 테스트는 아주 조금만 두는 식이었어요.

요즘은 트로피 모양으로 그립니다. 피라미드와 다른 점은 가운데가 가장 넓다는 거예요.

텍스트
        ┌────────────┐
        │    E2E     │   느리고 비싸다. 핵심 흐름만 얇게
        ├────────────┤
        │Integration │   여기가 가장 넓다
        ├────────────┤
        │   Unit     │   함수 하나씩
        ├────────────┤
        │   Static   │   실행하지 않고 잡는 것
        └────────────┘

왜 가운데가 넓어졌을까요. 함수를 하나하나 다 검사해도 그것들이 이어졌을 때 제대로 도는지는 여전히 모르기 때문인데, Step 1의 좋아요와 댓글 문제가 정확히 그 예예요. removeById도 잘 돌고 LikeButton도 잘 도는데, 둘이 만나는 지점이 망가진 겁니다.

맨 아래 층은 이미 깔려 있습니다

여기서 오늘 첫 번째로 놀라실 부분이 나옵니다. 그런데 트로피의 맨 아래 Static실행하지 않고 잡는 것이에요. 코드를 돌리지 않고 읽기만 해서 문제를 찾는 층이죠.

우리가 지금까지 매 모듈 끝에 무엇을 돌렸는지 기억하시나요.

Bash
npx tsc --noEmit
npx eslint .

이 두 개가 트로피의 맨 아래 층입니다. A-6에서 tsc --noEmit을 별도로 돌려야 하는 이유를 배웠고, B-1부터 eslint를 켜뒀죠. C-8에서는 그 eslint 규칙이 사실은 React Compiler의 판정과 같은 것이라는 걸 봤고요.

여러분은 이미 트로피의 한 층을 쌓고 있었는데, 이름을 안 붙였을 뿐이에요.

이 층은 공짜에 가깝습니다. 실행을 안 하니까 빠르고 앱을 띄울 필요도 없어서, 그래서 트로피에서도 맨 아래 넓은 받침으로 그리는 거예요.

오늘 우리가 얹을 것은 그 위층입니다.

💡 한 줄 정리

트로피는 피라미드와 달리 가운데(통합)가 가장 넓고, 맨 아래 정적 층은 우리가 A-6부터 이미 돌리고 있던 tsceslint 다.

🙋 학생 질문 — "그럼 E2E는 나쁜 건가요? 왜 맨 위에 조금만 두나요?"

나쁘지 않은데, 오히려 사용자가 진짜 겪는 것에 가장 가까운 유일한 층이에요. 브라우저를 진짜로 띄워서 진짜로 클릭하니까요.

문제는 값인데, 느리고 잘 깨지고 깨졌을 때 어디가 원인인지 알기 어렵다는 거예요.

우리도 이미 그 맛을 봤어요. C-10에서 두 탭을 띄워 쪽지가 오가는 걸 확인했던 것 기억하시죠. 그때 제가 화면 캡처를 못 남겼다고 했는데, 브라우저 자동화가 탭 포커스 때문에 말을 안 들어서였습니다. E-7에서는 애니메이션 때문에 잰 크기가 44px 이 아니라 41.8px 로 나오기도 했고요.

E2E는 그런 종류의 어려움을 계속 만나기 때문에, "핵심 흐름 몇 개만 얇게"가 요즘의 답이에요. 로그인해서 게시물 올리고 댓글 다는 것 같은, 그게 안 되면 서비스가 끝인 흐름들만요.


Step 3: "저장소를 열어봅니다"

이제 세어봅시다. 저장소 뿌리에서요.

Bash
npm test
텍스트
 Test Files  135 passed (135)
      Tests  954 passed (954)
   Duration  19.54s

135개 파일에 954개의 확인이 이미 들어 있습니다.

여러분이 A-1에서 타입을 선언하던 때부터, B-2에서 좋아요 토글을 만들 때도, C-6에서 서버를 붙일 때도, D-8에서 문지기를 세울 때도 이 숫자는 계속 자라고 있었어요.

어디에 있는지 볼까요.

Bash
find src -name "*.test.ts" -o -name "*.test.tsx" | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn
텍스트
  61 src/components
  32 src/routes
  12 src/lib
   7 src/hooks
   6 src/queries
   5 src/realtime
   3 src/types
   3 src/api
   2 src/contexts

두 가지가 눈에 띕니다.

하나, 테스트 파일이 소스 파일 바로 옆에 있습니다. tests/ 같은 폴더를 따로 두지 않았어요. PostCard.tsx를 고치러 들어가면 그 옆에 확인 코드가 같이 보입니다. 고칠 때 같이 고치게 되고, 지울 때 같이 지워지고요.

둘, 파일 이름 앞에 모듈 번호가 붙어 있습니다. a6-collections.test.ts, c4-store-basics.test.tsx처럼 붙어 있어서, 어느 시점에 무엇을 확인하려고 만든 것인지가 이름만 봐도 남아요.

하나만 열어봅시다

src/lib/a6-collections.test.ts를 열어볼게요. 사실 A-6에서 만든 제네릭 목록 도구를 확인하는 코드입니다.

TypeScript
// apps/web-spa/src/lib/a6-collections.test.ts
import { describe, it, expect } from 'vitest';
import { findById, removeById } from './collections';
import { feedPosts } from '../data/feed';

const comments = [
  { id: 1, content: '첫째' },
  { id: 2, content: '둘째' },
];

describe('findById — 어떤 목록에서든 번호로 하나를 찾는다', () => {
  it('게시물에서 찾으면 게시물이 나온다', () => {
    const found = findById(feedPosts, 2);

    expect(found?.username).toBe('minji');
  });

  it('없으면 undefined', () => {
    expect(findById(comments, 999)).toBeUndefined();
  });
});

지금은 읽기만 합니다. 직접 쓰는 건 다음 시간이지만, 모양은 눈에 들어오시죠. describe로 묶고, it으로 하나씩 적고, expect로 기대한 값과 비교하는데, 영어로 읽으면 "it 게시물에서 찾으면 게시물이 나온다"처럼 문장이 돼요.

그리고 Step 1의 그 함수도 여기 있는데, removeById요. 같은 파일 아래쪽에 이런 확인이 있어요.

TypeScript
  it('원본을 건드리지 않는다', () => {
    removeById(comments, 1);

    expect(comments).toHaveLength(2);
  });

만약 누군가 removeById를 원본을 직접 지우는 방식으로 바꾸면, 좋아요를 고치다 그랬든 뭘 하다 그랬든 이 줄이 즉시 빨개집니다. 댓글 화면을 열어보지 않아도요.

Step 1에서 그린 그 사고가 이 저장소에서는 이미 막혀 있었던 거예요.

💡 한 줄 정리

이 저장소에는 135개 파일 / 954개의 확인이 A-1부터 쌓여 있었고, 소스 옆에 붙어 있어서 고칠 때 같이 눈에 들어온다.

🙋 학생 질문 — "이게 다 있었는데 왜 지금까지 한 번도 안 보여주셨나요?"

일부러 안 보여드렸습니다. 이유가 두 가지예요.

첫째, 그때그때 배울 게 이미 충분했습니다. B-2에서 useState를 처음 만나는데 거기에 테스트 문법까지 얹으면 한 번에 두 가지 새로운 것을 배워야 하니, 하나씩 가는 게 나아요.

둘째, 더 중요한 이유인데요. 테스트는 도구지 목적이 아니기 때문입니다. 먼저 만들고 싶은 게 있고, 그게 커져서 손으로 못 지키게 되고, 그래서 도구를 들이는 순서가 자연스러워요. 반대로 하면 "왜 이걸 쓰는지" 없이 문법만 외우게 됩니다.

지금이 그 순서가 맞아떨어지는 시점입니다. 앱이 충분히 커졌고, 여러분은 Step 1의 그 사고가 왜 무서운지 이제 체감하시니까요.


Step 4: "설정은 세 줄입니다"

Vitest를 소개할 때 거의 항상 따라오는 말이 있습니다. "설정이 거의 없다"는 말이요.

보통 이런 말은 반쯤 믿고 들어야 하는데, 이번엔 파일을 직접 열어서 확인할 수 있습니다.

TypeScript
// apps/web-spa/vitest.config.ts
// 상호작용(클릭·입력)을 검증하려면 브라우저 DOM 이 필요해서 jsdom 환경을 얹는다.
// vite.config.ts 는 그대로 두고 여기서 병합만 한다.
import { defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      environment: 'jsdom',
      globals: true,
      setupFiles: ['./src/test-setup.ts'],
    },
  }),
);

954개의 확인을 굴리는 설정 전부입니다. 진짜 설정이라고 부를 만한 줄은 셋이에요.

  • environment: 'jsdom' — 테스트를 돌릴 때 브라우저 흉내를 켭니다. documentwindow를 쓸 수 있게요. jsdom은 진짜 브라우저가 아니라 Node 안에서 도는 흉내인데, 클릭하고 글자를 읽는 정도는 충분히 됩니다.
  • globals: truedescribeexpect를 매번 import 하지 않아도 쓸 수 있게 합니다. 우리 파일들은 대부분 그래도 import를 적어뒀는데, 적어두면 편집기가 무엇인지 바로 알려주거든요.
  • setupFiles — 판을 돌리기 전에 먼저 실행할 파일입니다. 우리 것에는 jsdom에 없는 기능을 메우는 코드가 들어 있어요.

진짜 중요한 줄은 따로 있습니다

위 셋이 아니라 mergeConfig(viteConfig, ...)입니다.

이 한 줄이 vite.config.ts를 통째로 가져옵니다. 앱을 빌드할 때 쓰는 그 설정을요.

텍스트
  vite.config.ts  ─────┐
    @    src/         │
    react()            ├──> vitest.config.ts 가 그대로 물려받는다
    React Compiler     │      + environment: jsdom
    tailwindcss()  ────┘      + globals: true
                              + setupFiles

이게 왜 중요하냐면, 앱이 도는 방식과 테스트가 도는 방식이 갈리지 않기 때문입니다.

앱에서 @/lib/utils라고 쓰면 src/lib/utils를 가리키는데, 테스트에서도 따로 안 알려줬는데 똑같이 가리켜요. 앱이 React Compiler로 자동 메모이제이션되면, 테스트에서 도는 컴포넌트도 똑같이 됩니다.

"설정이 거의 없다"는 말의 정확한 뜻은 설정이 없다가 아니라 한 번 쓴 설정을 두 곳이 나눠 쓴다는 겁니다.

말로만 들으면 당연해 보이죠. 다음 Step에서 그 한 줄을 끊어보겠습니다.

💡 한 줄 정리

Vitest 설정의 본체는 environment·globals·setupFiles 세 줄이 아니라, vite.config.ts를 통째로 물려받는 mergeConfig 한 줄이다.

🙋 학생 질문 — "그런데 이 파일, 언제 만들어진 건가요?"

git log로 따라가 보면 B-2인데, 좋아요 토글을 만들던 그 시간이에요.

Bash
git log --oneline --follow -- apps/web-spa/vitest.config.ts
텍스트
03f54b6 test: jsdom + Testing Library 내부 검증 환경 (B2)

그 뒤로 오늘까지 한 번도 안 고쳤는데, 그사이에 라우터가 들어왔고(C-1), 전역 상태가 들어왔고(C-4), 서버 통신이 붙었고(C-6), 실시간 통로가 열렸고(C-9), Tailwind와 Shadcn으로 화면을 전부 갈아엎었죠(E 카테고리 전체).

그때마다 vite.config.ts는 계속 바뀌었고, React Compiler도 그중에 추가된 거였죠. 테스트 설정은 안 건드려도 됐는데, 물려받고 있으니까요.

이게 오늘 Step 5에서 확인할 이야기의 예고편입니다.


Step 5: "그 한 줄을 끊어봅니다"

말로 하는 대신 직접 끊어보겠습니다. mergeConfig를 걷어내고, 나머지 세 줄은 그대로 두는 거예요.

TypeScript
// apps/web-spa/vitest.config.ts  (일부러 망가뜨린 판)
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
});

jsdom도 켜져 있고, globals도 켜져 있고, setupFiles도 그대로입니다. 겉보기엔 멀쩡해 보이죠.

돌려봅시다.

텍스트
 Test Files  72 failed | 63 passed (135)
      Tests  5 failed | 417 passed (422)

135개 중 63개만 살아남았습니다. 그런데 더 눈여겨볼 숫자가 있어요. 원래 954개였던 확인이 422개만 실행됐고, 532개는 아예 시작조차 못 했어요.

왜 시작을 못 했나

실패한 72개 중 70개는 이런 메시지를 냅니다. 사실 원인은 단순합니다.

텍스트
Error: Failed to resolve import "@/lib/utils" from "src/components/ui/button.tsx".
Does the file exist?

@가 무엇인지 모르게 된 겁니다. 그 별칭은 vite.config.ts에 적혀 있었고, 우리가 방금 그 연결을 끊었으니까요.

E-5에서 Shadcn 컴포넌트를 들여왔을 때 그 파일들이 @/lib/utilscn을 가져온다고 했던 것 기억하시죠. 그 파일 하나를 못 읽으면 그걸 쓰는 카드가 안 열리고, 카드를 못 열면 피드가 안 열리고, 그래서 70개가 줄줄이 무너집니다.

진짜 흥미로운 건 나머지 5개입니다

70개는 아예 못 열린 거고, 5개는 열려서 실행됐는데 결과가 틀렸습니다. 그리고 후자가 더 무서운 종류예요.

텍스트
 FAIL  src/contexts/c3-rerender-scope.test.tsx   (2건)
 FAIL  src/hooks/a5-identity-probe.test.tsx      (3건)

이 다섯 개의 공통점이 뭘까요. 전부 React Compiler가 일하고 있다는 것을 확인하는 판입니다.

C-3에서 우리는 통설 두 개를 실측으로 뒤집었어요. "Context 값이 바뀌면 아래가 전부 다시 그려진다"와 "객체를 value로 주면 리렌더 폭풍이 난다" 요. 둘 다 React Compiler가 켜져 있으면 그렇지 않다는 걸 숫자로 봤죠.

그 판의 코드에 이런 주석이 그때부터 적혀 있습니다.

TypeScript
// apps/web-spa/src/contexts/c3-rerender-scope.test.tsx
    // props 가 그대로인 자식 엘리먼트를 컴파일러가 붙들고 있기 때문이다.
    // (같은 판을 컴파일러 없이 돌리면 셋 다 1 -> 2 가 된다)
    expect(counts.reader).toBe(before.reader);

지금, 컴파일러 없이 돌리니 정확히 그 주석대로 1이 2로 나왔습니다.

C-3 때 적어둔 예측이 지금 재현된 거예요.

정리하면

텍스트
                      재사용 O    재사용 X
  통과한 파일           135          63
  아예 못 연 파일         0          70      @ 별칭이 사라져서
  실행됐는데 틀린 판      0           5      React Compiler 가 빠져서
  실행된 확인 수        954         422

mergeConfig 한 줄이 별칭과 컴파일러를 동시에 물어오고 있었던 겁니다. 그게 없으면 테스트는 "앱과 다른 조건"에서 돌게 되고, 그러면 통과하든 실패하든 앱에 대해 아무 말도 못 해요.

테스트가 앱과 같은 조건에서 돌아야 한다는 게 이 Step의 전부입니다. Vitest가 파는 것도 문법이 아니라 이거예요.

그럼 Jest는요

여러분이 회사에 가면 Jest로 된 저장소를 만날 가능성이 높습니다. 오랫동안 표준이었고 지금도 아주 많이 쓰여요. 문법은 거의 같아서 describe·it·expect를 그대로 씁니다. 다른 점은 방금 본 그 지점인데, Jest는 번들러와 별개로 도는 도구라 앱이 쓰는 별칭·TypeScript 변환·JSX 처리 같은 것을 테스트 쪽에도 따로 적어줘야 합니다. 그러다 보니 앱 설정을 고칠 때 테스트 설정도 같이 고쳐야 하고, 둘이 어긋나면 오늘 본 것과 같은 종류의 일이 벌어져요. 옛 저장소에서 만나면 그렇게 이해하시면 되고, 새로 시작하는 Vite 기반 프로젝트에서 굳이 Jest를 고를 이유는 이제 거의 없습니다.

💡 한 줄 정리

mergeConfig를 끊으면 135개 중 63개만 살아남고, 못 연 70개는 @ 별칭 때문에, 틀린 5개는 React Compiler가 빠졌기 때문이다.

🙋 학생 질문 — "70개는 요란하게 터졌는데 5개는 조용히 틀렸잖아요. 실무에서 더 위험한 쪽은요?"

당연히 5개 쪽입니다. 그리고 이건 이 과목에서 계속 반복되는 이야기이기도 해요.

70개는 못 열려서 터졌습니다. 빨간 글씨가 잔뜩 나오고 원인도 메시지에 그대로 적혀 있어서, 고치기는 쉬워요.

5개는 멀쩡히 돌았습니다. 문법도 맞고, 파일도 다 열렸고, 그냥 숫자가 다르게 나왔을 뿐이에요. 만약 저 판들이 없었다면 아무도 컴파일러가 빠진 걸 몰랐을 텐데, 앱은 여전히 돌고 조금 느려질 뿐이거든요.

C-7에서 봤던 것과 같은 종류예요. 그때는 테스트가 전부 초록인데 타입 검사가 4건을 잡았죠. D-6에서는 빌드도 통과하고 상태 코드도 200 인데 화면만 깨져 있었고요.

통과했다는 것과 맞다는 것은 다릅니다. 오늘 Step 7에서 이 이야기를 한 번 더 하게 될 거예요.


Step 6: "커버리지를 켭니다"

954개가 있다는 건 알았습니다. 그럼 이 954개가 앱을 얼마나 덮고 있는지 볼까요.

이걸 재주는 도구를 커버리지라고 부릅니다. 테스트를 돌리는 동안 어느 줄이 실제로 실행됐는지 세는 거예요. 실행된 적 없는 줄은 아무도 확인한 적이 없는 줄이겠죠.

Vitest는 이 기능을 따로 떼어놨습니다. 설치부터 할게요.

Bash
npm install -D -w web-spa @vitest/coverage-v8@4.1.10

버전을 Vitest와 맞춰서 깝니다. 러너 내부를 들여다보는 도구라 짝이 맞아야 해서, 명령도 하나 만들어둘게요.

JSON
// apps/web-spa/package.json
  "scripts": {
    "test": "vitest run",
    "test:coverage": "vitest run --coverage"
  }

돌려봅시다.

Bash
npm run test:coverage --workspace web-spa
텍스트
 Test Files  135 passed (135)
      Tests  954 passed (954)

 % Coverage report from v8
-------------------|---------|----------|---------|---------|
File               | % Stmts | % Branch | % Funcs | % Lines |
-------------------|---------|----------|---------|---------|
All files          |   87.24 |    76.22 |   91.19 |   95.25 |

첫 숫자가 나왔습니다. 그런데 이 숫자, 그대로 믿어도 될까요.

숫자가 왜 네 개인가요

여기서 바로 걸리는 게 있죠. "커버리지 87% 다"라고 할 때 어느 숫자를 말하는 걸까요.

네 개가 세는 게 다 다릅니다.

  • Lines (95.25%) — 실행된 줄의 비율입니다.
  • Stmts (87.24%) — 문장 단위예요. 한 줄에 문장이 여러 개일 수 있어서 줄과 다릅니다.
  • Funcs (91.19%) — 한 번이라도 불린 함수의 비율입니다.
  • Branch (76.22%) — 갈림길입니다. if의 참 쪽과 거짓 쪽, ?:의 양쪽, ??의 양쪽을 각각 셉니다.

줄은 95% 인데 갈래는 76% 입니다. 19 포인트 차이예요.

이게 왜 벌어지냐면, 이런 코드 때문입니다.

TypeScript
const label = post.liked ? '좋아요 취소' : '좋아요';

한 줄이죠. 테스트가 좋아요를 한 번 눌러보면 이 줄은 실행됐습니다. 줄 기준으로는 100% 예요. 그런데 liked가 참인 경우만 지나갔다면 거짓 쪽은 아무도 안 가본 겁니다. 갈래 기준으로는 50% 고요.

버그는 대부분 안 가본 갈래에 숨어 있습니다. 그래서 일반적으로는 네 숫자 중에 Branch를 가장 믿을 만한 것으로 칩니다.

누가 "커버리지 95% 입니다"라고 하면, 같은 코드를 두고 "76% 입니다"라고 말할 수도 있다는 뜻입니다. 어느 숫자인지 정하지 않으면 그 말은 아무 말도 아닙니다.

Branch도 우리 저장소에서는 한 번 더 의심해봐야 하는데, 왜 그런지는 다음 Step에서 직접 재보겠습니다.

💡 한 줄 정리

커버리지는 한 숫자가 아니라 넷이고, 우리 앱은 줄 95.25% 인데 갈래 76.22%로 19 포인트가 벌어진다.

🙋 학생 질문 — "그럼 목표를 100% 로 잡으면 되는 거 아닌가요?"

거의 모든 팀이 안 그렇게 합니다. 이유가 두 가지예요.

하나는 비용입니다. 마지막 10% 를 채우는 데 드는 노력이 앞의 90% 보다 클 때가 많은 데다, 그 마지막 10% 는 대개 잘 안 터지는 곳이에요.

둘은 더 중요한데요. 100% 는 만들 수 있지만 의미가 없을 수 있습니다. 모든 줄을 한 번씩 실행만 시키고 아무것도 비교하지 않는 테스트를 쓰면 숫자는 100% 가 되지만, 그건 아무것도 안 지켜줘요.

게다가 원리적으로 못 채우는 갈래도 있습니다. A-2에서 배운 never 완전성 검사 기억하시죠. switch에서 빠뜨린 게 있으면 타입 검사가 잡아주게 하는 그 장치요. 그 마지막 default 갈래는 타입이 맞는 한 실행될 수 없습니다. 실행되면 안 되는 코드니까요. 커버리지는 그걸 "안 덮인 줄"로 셉니다.

숫자는 목표가 아니라 어디를 안 보고 있는지 찾는 지도로 쓰는 게 맞습니다. 다음 시간에 이 지도를 실제로 읽고 구멍을 메울 거예요.


Step 7: "그 숫자를 의심합니다"

숫자가 나왔으니 이제 믿으면 될까요. 세 가지를 확인하고 나서 정하겠습니다.

하나 — 무엇을 세고 있는지 봅니다

표를 위에서부터 읽어보면 이런 줄들이 보입니다.

텍스트
 scratch           |   85.13 |    70.89 |   88.15 |    95.5 |
  ...snapshots.tsx |   83.76 |    55.69 |     100 |   98.48 |
  ...ry-answer.tsx |   85.41 |    59.37 |     100 |     100 |

scratch는 뭘까요. 우리가 지난 모듈들에서 예전 판을 보관해둔 곳입니다. C-7에서 "박제"라고 부르며 옮겨둔 FeedSectionBeforeServer 같은 것들, 과제 예시답안, 측정용 도우미가 들어 있어요.

세어보면 이렇습니다.

텍스트
  커버리지가 세고 있는 파일   148개
    그중 scratch/            45개  (30%)
    그중 src/               103개

세 개 중 하나가 앱이 아닙니다. 보관해둔 옛날 코드와 과제 답안이 앱의 성적표에 섞여 들어가 있는 거예요.

빼봅시다.

TypeScript
// apps/web-spa/vitest.config.ts
    test: {
      environment: 'jsdom',
      globals: true,
      setupFiles: ['./src/test-setup.ts'],
      coverage: {
        // scratch/ 는 앱이 아니라 보관해 둔 예전 판과 과제 답안이다.
        // 세는 대상에 두면 앱의 숫자를 가린다 — 148개 중 45개가 여기였다.
        exclude: ['scratch/**'],
      },
    },

다시 재면 이렇게 바뀝니다.

제외 전 (148 파일) 제외 후 (103 파일)
Stmts 87.24% 89.75%
Branch 76.22% 82.83%
Funcs 91.19% 94.32%
Lines 95.25% 94.97%

갈래가 76.22에서 82.83으로 6.6 포인트 올랐습니다. 테스트는 한 줄도 안 썼는데요.

한 줄도 안 쓰고 숫자를 6 포인트 올렸다는 게 이 표의 요점입니다. 커버리지는 코드의 성질이 아니라 우리가 "무엇을 셀지" 정한 결과예요. 세는 범위를 바꾸면 숫자가 바뀝니다.

하나는 반대로 갔는데, Lines는 95.25에서 94.97로 내려갔습니다. scratch 안의 보관 파일들이 줄 기준으로는 앱보다 잘 덮여 있었던 거고, 네 숫자가 늘 같은 방향으로 움직이지 않는다는 것도 여기서 보이는 거예요.

그런데 앱만 남겨도 우리가 쓴 코드가 아닙니다

scratch를 뺐으니 이제 103개 파일 전부가 우리 앱입니다. 그럼 이 숫자는 우리 코드에 대한 숫자일까요.

src/components/Avatar.tsx를 열어봅시다. 커버리지 표에서 갈래가 50% 로 나오는 파일이에요.

tsx
// apps/web-spa/src/components/Avatar.tsx
export function Avatar({ username, profileImageUrl }: AvatarProps) {
  return (
    <div className="flex items-center gap-2.5 p-3">
      <img
        className="size-8 rounded-full object-cover"
        src={profileImageUrl}
        alt={`${username} 프로필 사진`}
      />
      <span className="text-sm font-semibold">{username}</span>
    </div>
  );
}

if가 없습니다. 삼항 연산자도, &&도 없어요. 갈림길이 하나도 없는데 "갈래 50%"가 나옵니다. 절반을 안 지나갔다는 그 갈래는 대체 어디 있는 걸까요.

Step 5를 떠올려보세요. mergeConfigvite.config.ts에서 React Compiler를 물어온다고 했죠. 컴파일러는 빌드할 때 자동으로 메모이제이션을 넣습니다. C-8에서 캐시 칸을 세어봤던 것 기억하시죠.

그 캐시는 이렇게 생긴 코드로 들어갑니다. "지난번 값과 같은가? 같으면 재사용하고, 다르면 새로 만든다." 그게 갈림길입니다. 우리가 안 썼지만 실제로 실행되는 코드고, 커버리지는 실행되는 코드를 셉니다.

한 번만 그리는 테스트는 늘 "캐시에 없다" 쪽만 지나가고 "있다" 쪽은 안 지나가는데, 그게 50% 의 정체예요.

얼마나 되는지 재봤습니다. 컴파일러를 껐다 켜고 src의 갈래 총 개수를 세는 방식으로요.

갈래 총 개수
React Compiler 켬 1,410
React Compiler 끔 387

1,410개 중 1,023개, 그러니까 73% 가 우리가 쓰지 않은 갈래입니다.

이게 Step 6에서 "Branch를 가장 믿을 만한 것으로 친다" 고 해놓고 한 번 더 의심해보자고 한 이유예요. 일반적으로는 맞는 말인데, 컴파일러가 코드를 바꾸는 저장소에서는 그 숫자의 대부분이 우리 것이 아닙니다.

그렇다고 컴파일러를 끄는 게 답은 아닙니다. C-8에서 봤듯이 그것 때문에 앱이 빨라졌으니까요. 답은 숫자를 볼 때 그 안에 무엇이 섞여 있는지 알고 보는 것입니다.

둘 — 누가 실행했는지에 따라 화면이 달라집니다

이건 제가 이 수업을 준비하면서 실제로 걸려 넘어진 부분이라 그대로 보여드릴게요.

방금 그 표에는 파일이 몇 줄이나 나올까요. 103개 파일을 셌으니 103줄이 나와야 할 것 같죠. 그런데 아니었습니다.

제 화면에는 45줄이 나왔습니다. 여러분 화면에는 103줄이 나올 거예요.

같은 코드, 같은 명령인데요.

원인은 Vitest 안에 있습니다. 설치된 러너의 코드를 열어보면 이런 부분이 있어요.

JavaScript
// node_modules/vitest/dist/chunks/coverage.DM_a_rWm.js
	if (isAgent) {
		// default to `skipFull` and add `text-summary` reporter when `text` reporter is used on agents
		const text = resolved.coverage.reporter.find(([name]) => name === "text");
		if (text) {
			text[1] = { skipFull: true, ...text[1] };

isAgent 일 때 skipFull을 켭니다. 네 지표가 전부 100% 인 파일을 표에서 빼버리는 설정이에요.

isAgent는 AI 코딩 도구 안에서 돌고 있는지를 봅니다. AI_AGENT, CLAUDECODE, CURSOR_AGENT 같은 환경 변수가 있는지 확인해요. 제가 이 수업 자료를 준비한 환경에 그게 켜져 있었던 겁니다.

직접 확인해보면 이렇습니다.

텍스트
  이 환경 그대로                45줄   + 요약 블록이 따로 붙음
  환경 변수를 걷어내고         103줄   + 요약 블록 없음

제외 전 숫자로도 똑같습니다. 148개 중 81줄만 보였고, 그 81개는 "네 지표 중 하나라도 100% 미만인 파일"의 개수와 정확히 일치했어요. 100% 인 67개가 통째로 안 보였던 겁니다.

여러분 중에도 Cursor 나 비슷한 도구 안에서 터미널을 여는 분이 있을 텐데, 그러면 옆자리 동료와 같은 명령을 돌렸는데 표가 다르게 나옵니다. 코드가 다른 게 아니라 재는 쪽이 다른 거예요.

⚠️ 한 가지 더. 이걸 --coverage.skipFull=false로 되돌리려고 하면 안 됩니다. 그 옵션은 다른 곳으로 가요. 위 코드가 건드리는 건 표 출력기의 옵션이라, 굳이 되돌리려면 출력기를 직접 지정해야 합니다.

셋 — 재는 행위가 재는 대상을 바꿉니다

커버리지를 켜면 코드에 계측이 붙습니다. 어느 줄이 실행됐는지 세야 하니까요. 당연히 조금 느려집니다.

텍스트
  커버리지 끔    19.54s
  커버리지 켬    21.32s

1.8초, 9% 정도예요. 별것 아닌 것 같지만, 우리 판 중에 시간에 기대는 것이 하나 있습니다. C-7에서 만든 낙관적 업데이트 판이요. 서버가 답하기 전에 화면이 먼저 바뀌는지를 확인하려면, 답이 아직 안 온 그 짧은 순간에 화면을 봐야 하거든요.

이 판이 커버리지를 켜면 가끔 깨집니다. 판 135개가 CPU를 나눠 쓰는 전체 실행에서만요. 그 파일만 따로 돌리면 열두 번 돌려도 멀쩡합니다.

여기서 제가 처음에 틀린 이야기를 하나 하겠습니다. 저는 처음에 이걸 "커버리지가 실행 시간을 2.2배로 만든다"로 봤는데, 47초가 나왔거든요. 그건 제가 다른 작업을 같이 돌리고 있어서였고, 단독으로 재니 21초였습니다.

C-6에서 똑같은 실수를 기록해뒀는데도 제가 다시 밟았습니다. 재는 동안엔 다른 걸 돌리지 않는다. 이건 여러분도 나중에 성능을 잴 때 그대로 쓰게 될 거예요.

그래서 숫자를 어떻게 쓰나

세 가지를 다 보고 나면 이렇게 정리됩니다.

커버리지는 낮은 곳을 찾는 데는 아주 좋습니다. 82% 인데 어떤 파일이 50% 라면, 거기는 확실히 안 보고 있는 겁니다. 지도로는 훌륭해요.

반대로 높은 숫자는 아무것도 보증하지 않습니다. 세는 범위를 좁히면 오르고, 아무것도 비교하지 않는 테스트로도 오르니까, 팀에서 쓸 때는 "몇 % 이상"보다 "이번 변경으로 내려가지 않았는가"를 봅니다. 절대값보다 방향이요.

💡 한 줄 정리

커버리지 숫자는 세는 범위(scratch를 빼니 갈래가 6.6 포인트 오르고, 남은 갈래의 73% 는 컴파일러가 만든 것이었다)와 재는 환경(같은 명령에 45줄과 103줄)에 따라 달라지므로, 낮은 곳을 찾는 지도로 쓰고 높은 값을 성적으로 쓰지 않는다.

🙋 학생 질문 — "그 시간에 기대는 판은 그냥 놔둬도 되나요? 가끔 깨진다면서요."

놔두면 안 됩니다. 가끔 깨지는 판은 팀에서 가장 나쁜 종류입니다. 사람들이 빨간 불을 보고도 "또 걔야" 하면서 넘기기 시작하거든요. 그러면 진짜 문제가 생겨도 같이 묻힙니다.

이번엔 두 가지를 했습니다.

먼저 기다리는 한도를 넓혀봤는데, 안 잡혔습니다. 느려서가 아니라 부하가 심할 때 그 순간을 통째로 놓치는 거라서요.

그 판에만 재시도를 붙였는데, 실패하면 두 번 더 해보고 그래도 안 되면 진짜 실패로 처리하게 했어요. 일부러 깨뜨려서 재시도가 진짜 걸리는지, 진짜 깨진 것을 감추지는 않는지도 확인했는데, 세 번 시도한 뒤 정상적으로 빨개지더군요.

재시도는 남용하면 위험한 도구입니다. 진짜 버그를 숨길 수 있으니까요. 시간에 기대는 판처럼 왜 흔들리는지 원인을 알고 있는 경우에만 쓰고, 쓸 때는 이유를 코드 옆에 적어둡니다.


마무리

오늘 배운 핵심 세 가지

💡 하나 — 확인을 코드로 적는 이유는 속도가 아니라 범위입니다. 손으로 하면 방금 고친 곳만 보게 되는데, 회귀는 정확히 그 바깥에서 생깁니다. 좋아요를 고쳤는데 댓글 삭제가 망가지는 일이요. 그 사고는 이 저장소에서 이미 막혀 있었는데, removeById가 원본을 안 건드리는지 보는 줄이 A-6 때부터 있었으니까요.

💡 둘 — 테스트는 앱과 같은 조건에서 돌아야 합니다. Vitest 설정의 본체는 세 줄짜리 설정이 아니라 vite.config.ts를 물려받는 한 줄이었어요. 그 한 줄을 끊었더니 135개 중 63개만 살아남았고, 못 열린 70개보다 멀쩡히 돌면서 틀린 답을 낸 5개가 더 무서웠습니다.

💡 셋 — 숫자를 만들었으면 그 숫자가 무엇을 세는지부터 봅니다. 네 지표가 19 포인트나 벌어져 있었고, 보관 파일을 빼자 테스트를 한 줄도 안 쓰고 6.6 포인트가 올랐고, 남은 갈래의 73% 는 우리가 아니라 React Compiler가 만든 것이었고, 재는 환경에 따라 같은 명령이 45줄과 103줄을 냈습니다. 커버리지는 낮은 곳을 찾는 지도이지 성적표가 아닙니다.

다음 시간 예고

오늘은 이미 있는 것을 마주하고 재기만 했습니다. 한 줄도 직접 안 썼어요.

다음 시간엔 쓰는데, 어디에 쓸지를 우리가 고르지 않을 거예요. 오늘 만든 그 지도가 가리키는 곳으로 갑니다.

지도를 읽어보면 세 종류가 나옵니다. 하나는 아무도 안 부르는 함수예요. 954개의 확인은 물론이고 앱 코드 어디에서도 안 부르는 게 하나 있어서, 여기서 할 일은 테스트를 쓰는 게 아니라 다른 일입니다. 다른 하나는 원리적으로 못 덮는 갈래고요. A-2의 그 never 장치가 여기 걸립니다. 마지막 하나가 진짜 구멍입니다.

그 진짜 구멍에 describe·it·expect로 첫 판을 직접 쓰고, 확인하려는 대상이 혼자 안 돌고 다른 것을 불러야 할 때 어떻게 하는지도 다룹니다.


과제

[탐구] 여러분 화면에는 몇 줄이 나오는지 확인하세요

Step 7에서 제 화면은 45줄, 여러분 화면은 103줄이라고 했습니다. 직접 확인해보세요.

Bash
npm run test:coverage --workspace web-spa
  1. 표에 나온 파일 줄이 몇 개인가요. (앞이 두 칸 들여쓰기 된 줄들입니다. 한 칸짜리는 폴더 줄이에요.)
  2. 표 아래에 Coverage summary라는 요약 블록이 붙어 있나요.
  3. 만약 여러분이 AI 코딩 도구 안의 터미널을 쓰고 있다면, 그 도구 밖에서 같은 명령을 한 번 더 돌려서 두 결과를 비교해보세요.

이어서 답해보세요. 표에 안 보이는 파일들은 커버리지가 낮은 파일일까요, 높은 파일일까요. 그렇게 숨기는 것이 왜 대체로는 편하고, 언제 위험할까요.

[탐구] 갈래가 가장 낮은 곳을 셋 찾고, 그 갈래를 눈으로 확인하세요

커버리지 표에서 % Branch가 낮은 파일 세 개를 골라 적고, 각각에 대해 답해보세요.

  1. 그 파일을 열어서 if·삼항 연산자(? :&&·??가 몇 개나 있는지 세어보세요.
  2. 커버리지가 말하는 갈래 개수와 1번의 개수가 맞나요.
  3. 안 맞는다면, 그 차이는 어디서 온 걸까요. Step 7에서 본 것을 떠올려보세요.

세 파일 중 최소 하나는 1번의 답이 0개일 겁니다. 갈림길이 하나도 없는데 갈래 커버리지가 50% 로 나오는 파일이요.

마지막 질문입니다. 그런 파일의 갈래 커버리지를 100% 로 만들려면 무엇을 해야 할까요. 그렇게 하는 것이 이 앱에 무슨 도움이 될까요.

[구현] 보관 파일을 도로 넣어보세요

vitest.config.tsexclude: ['scratch/**']를 잠시 지우고 다시 재세요.

  1. 네 숫자가 각각 어떻게 바뀌나요.
  2. 넷 중 셋은 내려가고 하나는 올라갑니다. 올라가는 게 어느 것이고, 왜 그럴까요.
  3. 세는 파일 수는 몇 개에서 몇 개가 되나요.

확인한 뒤 되돌려놓고 나서, 생각해보세요. 만약 우리가 scratch를 빼지 않은 채로 "커버리지 87% 달성"이라고 팀에 보고했다면, 그 보고는 거짓말일까요 아닐까요. 무엇을 같이 말해야 거짓이 아니게 될까요.

[구현] 물려받기를 끊고 직접 확인하세요

Step 5를 여러분 손으로 재현하는 과제입니다.

vitest.config.ts에서 mergeConfig(viteConfig, ...)를 걷어내고 defineConfig만 남기세요. 나머지 세 줄은 그대로 둔 채로 npm test를 돌립니다.

  1. 몇 개 파일이 통과하고 몇 개가 실패하나요.
  2. 실행된 확인의 총 개수가 954에서 몇으로 줄었나요. 왜 줄었을까요.
  3. 실패한 것 중에 "파일을 못 열어서"인 것과 "돌았는데 결과가 틀려서"인 것을 갈라보세요. 후자는 몇 개고 어느 파일인가요.
  4. 후자의 파일을 열어서 그 판이 무엇을 확인하려는 것이었는지 읽어보세요. 주석에 답이 적혀 있습니다.

확인한 뒤 반드시 되돌려놓으세요. 되돌린 뒤 npm test가 다시 135 파일 954 통과로 돌아오는 것까지 보고 마칩니다.


생각해볼 주제

1. 커버리지를 팀 규칙으로 만든다면

어떤 팀이 이런 규칙을 세웠습니다. "커버리지 80% 미만이면 병합할 수 없다."

의도는 좋아 보이지만, 오늘 우리가 본 것들을 떠올려보세요. 세는 범위를 바꾸면 숫자가 6 포인트 움직였고, 아무것도 비교하지 않는 테스트로도 숫자는 오릅니다.

이 규칙이 팀에서 실제로 어떤 행동을 만들어낼까요. 만약 여러분이 이 규칙을 고쳐 쓴다면 어떻게 바꾸시겠어요.

2. 이미 있는 954개를 우리가 믿어도 될까

오늘 우리는 954개가 통과하는 것을 봤지만, 통과한다는 것이 그 954개가 제대로 된 확인이라는 뜻은 아닙니다.

실제로 이 저장소에서도 그런 일이 있었어요. C-1에서는 이미 없어진 것을 지우고 없다고 확인하던 판이 발견됐고, C-7에서는 아무 일도 안 일어나는데 기대값이 우연히 맞아서 통과하던 판이 두 개 있었습니다.

이렇게 초록불인데 아무것도 안 지키고 있는 테스트를 어떻게 찾아낼 수 있을까요. 커버리지로는 왜 못 찾을까요.

3. 도구가 우리를 보고 있다는 것

Step 7에서 본 isAgent를 다시 생각해봅시다. Vitest는 AI 도구 안에서 돌고 있다는 걸 알아채고 출력을 바꿨습니다. 100% 인 파일을 숨기고 요약을 덧붙였죠.

이건 친절한 기능입니다. 긴 표를 다 읽을 필요 없게 해주니까요. 실제로 그런 의도로 넣은 것이고요.

오늘은 그 친절이 저를 틀리게 만들 뻔했는데, 제가 본 화면을 그대로 여러분에게 옮겼다면 여러분은 전혀 다른 화면을 보고 "내 것만 이상한가" 했을 거예요.

도구가 실행 환경을 감지해서 동작을 바꾸는 일은 앞으로 더 늘어날 겁니다. 이런 종류의 어긋남을 미리 알아채려면 무엇을 습관으로 만들어두어야 할까요.

✅ 예시 답안정답 보기

과제 4개(각 25점, 합 100점)와 생각해볼 주제 3개의 예시답안입니다.


🎯 [과제 1 예시답안] 여러분 화면에는 몇 줄이 나오는지 확인하세요

채점 포인트

항목 배점 확인
표의 파일 줄 수를 세고, 폴더 줄(한 칸 들여쓰기)과 갈라 셌다 7 103줄(또는 도구 안이면 45줄)
요약 블록(Coverage summary)의 유무를 확인했다 5 있음/없음이 위 줄 수와 짝을 이룬다
두 환경에서 비교했거나, 비교가 불가능한 이유를 설명했다 5 도구 밖 터미널에서 재실행
숨겨진 파일이 커버리지가 높은 쪽임을 밝혔다 8 낮은 쪽이라고 답하면 0점
합계 25

풀이 예시

Bash
npm run test:coverage --workspace web-spa

표의 파일 줄만 세는 방법입니다. 파일 줄은 두 칸, 폴더 줄은 한 칸 들여쓰기예요.

Bash
npm run test:coverage --workspace web-spa 2>&1 \
  | grep -cE '^  [^ ].*\|'

일반 터미널에서는 103이 나옵니다. scratch를 뺀 뒤 커버리지가 세는 파일 수와 정확히 같아요. 요약 블록은 없습니다.

AI 코딩 도구 안의 터미널에서 돌리면 45가 나오고, 표 아래에 이런 블록이 따로 붙습니다.

텍스트
=============================== Coverage summary ===============================
Statements   : 89.75% ( 1858/2070 )
Branches     : 82.83% ( 1168/1410 )
...

숨겨진 파일은 어느 쪽인가

커버리지가 높은 쪽입니다. 정확히는 네 지표가 전부 100% 인 파일이 빠집니다.

scratch를 빼기 전 숫자로 확인하면 더 분명합니다. 148개 파일 중 도구 안에서는 81줄만 보였는데, 이 81은 "네 지표 중 하나라도 100% 미만인 파일"의 개수와 정확히 일치합니다. 148 − 81 = 67이 전부 100% 짜리였어요.

src/lib/collections.ts로 확인해볼 수 있습니다. A-6에서 만든 그 파일은 문장·갈래·함수·줄이 전부 100% 인데, 도구 안에서 돌리면 표에 행 자체가 없습니다. 안 만든 게 아니라 안 보여주는 거예요.

왜 대체로 편하고, 언제 위험한가

편한 이유는 명백합니다. 볼 필요가 없는 줄을 지워주니까요. 커버리지 표를 보는 목적은 대개 "어디가 비었나"를 찾는 것이고, 100% 인 파일은 그 목적에 도움이 안 됩니다. 103줄을 스크롤하는 것보다 45줄을 보는 게 낫죠.

위험한 경우는 표를 목록으로 읽을 때입니다.

  • "우리 앱에 파일이 45개구나"라고 세면 틀립니다. 103개예요.
  • "collections.ts가 표에 없네, 테스트가 없나 보다"라고 결론 내면 정반대입니다. 100% 라서 없는 거예요.
  • 동료와 화면을 비교하다 "네 것만 이상하다"는 엉뚱한 디버깅을 시작하게 됩니다.

정리하면, 없는 것과 안 보이는 것을 구분할 수 없게 만드는 것이 이 기능의 값입니다.

자주 나오는 실수

  • 폴더 줄까지 세는 것. src/components 같은 줄은 그 폴더의 합계지 파일이 아닙니다. 들여쓰기 칸 수로 갈라야 해요.
  • "숨겨진 건 커버리지가 낮은 파일"이라고 답하는 것. 직관과 반대라 많이 틀립니다. 이름이 skipFull, 즉 "가득 찬 것을 건너뛴다"예요.

💡 튜터의 한마디

이 과제의 진짜 목적은 커버리지가 아니라 "내가 보고 있는 화면이 표준 화면인가"를 의심하는 습관입니다.

우리는 이 과목에서 이 함정을 여러 번 만났어요. D-2에서는 브라우저 탭이 뒤에 있어서 미리 받기가 0건으로 나왔고, D-3에서는 다른 앱이 3000 번 포트를 잡고 있는 걸 모른 채 그 앱을 재고 있었습니다. 이번엔 도구가 스스로 출력을 바꿨고요.

세 번 다 코드는 멀쩡했고 재는 쪽이 달랐습니다. 그리고 숫자 자체는 그럴듯해 보여서 "이상하다"를 느끼기가 어려웠어요.


🎯 [과제 2 예시답안] 갈래가 가장 낮은 곳을 셋 찾고, 그 갈래를 눈으로 확인하세요

채점 포인트

항목 배점 확인
갈래가 낮은 파일 셋을 숫자와 함께 골랐다 5 표에서 % Branch 기준
각 파일의 if·? :·&&·?? 개수를 직접 셌다 6 소스를 열어본 흔적
갈림길이 0개인데 갈래가 50% 인 파일을 찾아냈다 8 Avatar.tsx
그 차이의 원인을 React Compiler로 짚었다 4 Step 5·Step 7 연결
100% 로 만드는 것이 무의미하다는 결론에 이르렀다 2
합계 25

풀이 예시

표에서 % Branch가 낮은 순으로 뽑으면 이렇습니다.

파일 Branch 갈래
src/routes/PostDetailPage.tsx 48.71% 19/39
src/AppProviders.tsx 50.00% 3/6
src/components/Avatar.tsx 50.00% 5/10
src/components/ui/avatar.tsx 50.98% 26/51

Avatar.tsx를 열어봅니다.

tsx
// apps/web-spa/src/components/Avatar.tsx
export function Avatar({ username, profileImageUrl }: AvatarProps) {
  return (
    <div className="flex items-center gap-2.5 p-3">
      <img
        className="size-8 rounded-full object-cover"
        src={profileImageUrl}
        alt={`${username} 프로필 사진`}
      />
      <span className="text-sm font-semibold">{username}</span>
    </div>
  );
}

if 0개, 삼항 0개, && 0개, ?? 0개인데, 커버리지는 갈래 10개 중 5개를 지나갔다고 말해요.

PostDetailPage.tsx도 마찬가지입니다. 소스에 갈림길이 하나도 없는데 갈래 39개가 잡힙니다.

그 차이는 어디서 오나

React Compiler입니다. Step 5에서 mergeConfigvite.config.ts의 컴파일러 설정까지 물어온다고 했었기 때문에, 테스트에서 도는 컴포넌트도 앱과 똑같이 자동 메모이제이션이 들어간 상태로 도는 거예요.

메모이제이션은 "지난번 값과 같으면 재사용"이라는 검사를 넣는 일이고, 그 검사가 곧 갈림길입니다. C-8에서 세어봤던 캐시 칸이 여기서 갈래로 잡히는 거예요.

직접 확인하려면 vite.config.ts에서 컴파일러를 잠시 빼고 다시 재면 됩니다.

src 갈래 총 개수
React Compiler 켬 1,410
React Compiler 끔 387

1,023개가 컴파일러 몫입니다. 전체의 73% 예요.

Avatar.tsx 하나만 보면 켰을 때 10개, 껐을 때 0개입니다.

100% 로 만들려면, 그리고 그게 도움이 되는가

컴파일러가 넣은 갈래의 안 지나간 쪽은 "캐시에 이미 있다" 쪽입니다. 한 번만 그리는 테스트는 늘 캐시가 비어 있으니 그쪽을 안 지나가요.

그러니 숫자만 올리려면 같은 컴포넌트를 같은 props로 한 번 더 그리는 테스트를 쓰면 됩니다.

그게 이 앱에 무슨 도움이 될까요. 거의 없습니다.

  • 그 테스트가 지키는 것은 우리 코드가 아니라 컴파일러의 생성물입니다. 컴파일러 버전이 올라가면 통째로 바뀔 코드예요.
  • 우리가 그것을 검증할 이유도 없습니다. React 팀이 이미 검증합니다.
  • 무엇보다 그 테스트는 앱이 망가져도 안 빨개집니다. 아무것도 안 지켜요.

정답은 이겁니다. 이 파일들의 갈래 숫자는 올릴 대상이 아니라, 읽을 때 걸러야 할 숫자입니다.

자주 나오는 실수

  • 낮은 숫자를 보고 바로 테스트를 쓰기 시작하는 것. 이 과제의 함정이 정확히 그겁니다. 숫자를 보면 손이 먼저 움직이는데, 먼저 소스를 열어서 그 갈래가 우리 것인지부터 봐야 합니다.
  • ui/avatar.tsx를 우리 코드가 아니라고 넘기는 것. E-5에서 배웠듯이 그 파일은 복사돼 와서 우리 소유인데, 안 지나간 부분이 우리가 안 쓰는 컴포넌트(AvatarBadge 등)라서 답은 "테스트를 쓴다"가 아니라 "안 쓰면 지운다" 쪽에 가깝습니다.

💡 튜터의 한마디

이 과제가 오늘 수업에서 가장 중요합니다.

Step 7에서 저는 커버리지 숫자가 세는 범위에 따라 달라진다고 했어요. scratch를 뺐더니 6.6 포인트가 올랐죠. 그건 "앱이 아닌 파일"을 뺀 겁니다.

이 과제는 한 겹 더 들어갑니다. 앱 파일만 남겼는데도 그 안에 우리가 쓰지 않은 코드가 73% 나 섞여 있었어요. 파일 단위로는 못 걸러내는 오염입니다.

결론이 이렇게 됩니다. 그런데 커버리지는 어디를 안 보고 있는지 찾는 지도로는 여전히 유용하지만 그 지도의 축척을 믿으면 안 됩니다. 낮은 곳을 찾아가서 눈으로 확인하는 것까지가 한 세트예요.

다음 시간에 우리가 첫 테스트를 쓸 때, 어디에 쓸지를 숫자가 아니라 눈으로 확인한 결과로 고르는 이유가 이것입니다.


🎯 [과제 3 예시답안] 보관 파일을 도로 넣어보세요

채점 포인트

항목 배점 확인
네 숫자의 변화를 전부 적었다 8 표로 정리
올라가는 것이 Lines 임을 찾아냈다 6
그 이유를 보관 파일의 성격으로 설명했다 6 줄은 잘 덮이고 갈래는 안 덮인다
파일 수 변화를 적었다 (103 → 148) 2
보고의 정직함에 대해 답했다 3 범위를 함께 말해야 한다
합계 25

풀이 예시

vitest.config.ts에서 exclude를 잠시 지웁니다.

TypeScript
// apps/web-spa/vitest.config.ts  (과제용 — 되돌릴 것)
    test: {
      environment: 'jsdom',
      globals: true,
      setupFiles: ['./src/test-setup.ts'],
      // coverage 블록을 통째로 비운다
    },
지표 제외 후 (103 파일) 도로 넣음 (148 파일) 방향
Stmts 89.75% 87.24% 내려감
Branch 82.83% 76.22% 내려감
Funcs 94.32% 91.19% 내려감
Lines 94.97% 95.25% 올라감

세는 파일은 103개에서 148개가 됩니다. 늘어난 45개가 전부 scratch예요.

왜 Lines만 올라가나

scratch 안에 있는 것들의 성격 때문입니다. 거기 있는 파일은 크게 두 종류예요.

하나는 예전 판을 얼려둔 것입니다. C-7의 FeedSectionBeforeServer처럼요. 다른 하나는 과제 예시답안이고요.

이 파일들의 공통점은 그 파일만을 위해 만든 테스트가 붙어 있다는 겁니다. 얼려둔 판은 "그 시점에는 이랬다"를 재려고 만든 것이고, 답안은 답안 확인용 테스트와 짝입니다. 그러니 줄 단위로는 거의 다 지나가요.

반면 갈래는 다릅니다. 그 파일들은 한두 가지 경우만 보려고 만든 것이라, 조건의 반대쪽까지 챙기지 않았습니다. 실제로 scratch만 따로 재면 갈래가 70.89%로 src의 82.84% 보다 훨씬 낮아요.

줄은 잘 덮이고 갈래는 안 덮인 코드 덩어리가 섞여 들어오니, Lines는 올라가고 Branch는 크게 내려가는 겁니다.

"커버리지 87% 달성"은 거짓말인가

거짓말은 아니지만, 아무 정보가 없는 말입니다.

87.24% 는 실제로 나온 숫자예요. 조작한 게 아닙니다. 문제는 듣는 사람이 그 말을 들으면 자동으로 "우리 앱 코드의 87%"라고 해석한다는 거고, 그건 사실이 아닙니다. 앱만 재면 89.75% 고, 셋 중 하나는 앱 코드도 아니었으니까요.

거짓이 되지 않으려면 세 가지를 함께 말해야 합니다.

  1. 어느 지표인가 — 같은 코드가 82.83% 이기도 하고 94.97% 이기도 합니다.
  2. 무엇을 셌는가 — 파일 몇 개를 대상으로 했고 무엇을 뺐는지.
  3. 언제 잰 것인가 — 마지막 커밋 뒤에 다시 잰 값인지.

이 셋 없이 숫자만 말하는 보고는 틀린 건 아니지만 쓸 수가 없습니다.

💡 튜터의 한마디

여기서 배울 것은 커버리지 사용법이 아니라 숫자를 보고할 때의 태도입니다.

여러분이 나중에 "응답 시간을 200ms 로 줄였습니다"라고 보고할 일이 생길 거예요. 그때 누군가 물을 겁니다. 어느 화면이요, 몇 번 재셨어요, 캐시가 더워진 상태였나요, 하고요.

이 과목에서 우리는 이미 그 맛을 봤습니다. E-7에서 모달 오른쪽 칸을 한 번은 422px, 한 번은 406px 로 쟀는데 둘 다 참이었어요. 상자 크기가 달랐거든요. 숫자만 적어두고 조건을 안 적었더니 나중에 서로 안 맞았습니다.

숫자는 조건과 함께 적습니다. 조건 없는 숫자는 나중에 자기 자신과 싸웁니다.


🎯 [과제 4 예시답안] 물려받기를 끊고 직접 확인하세요

채점 포인트

항목 배점 확인
통과/실패 파일 수를 적었다 (63 / 72) 5
실행된 확인이 954 → 422 로 준 것과 그 이유를 설명했다 6 못 연 파일의 판은 세어지지 않는다
"못 열어서" 와 "돌았는데 틀려서" 를 갈랐다 8 70건 / 5건
틀린 5건의 파일을 지목하고 주석을 읽었다 4 React Compiler
되돌린 뒤 135/954 복귀를 확인했다 2
합계 25

풀이 예시

TypeScript
// apps/web-spa/vitest.config.ts  (과제용 — 되돌릴 것)
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
  },
});
텍스트
 Test Files  72 failed | 63 passed (135)
      Tests  5 failed | 417 passed (422)

954가 422로 준 이유

실행되지 못한 확인은 세어지지 않기 때문입니다.

실패한 72개 중 70개는 판이 틀린 게 아니라 파일을 아예 못 열었습니다. 파일을 못 열면 그 안에 it이 몇 개 있었는지도 알 수 없습니다. 그래서 총계에서 통째로 빠집니다.

954 − 422 = 532개가 그렇게 사라졌습니다. 이 숫자가 중요한 이유는, 화면에 "5 failed"만 보고 "5개만 문제구나"라고 읽으면 완전히 틀리기 때문이에요. 532개는 통과한 것도 실패한 것도 아니고, 아무도 확인하지 않은 상태입니다.

두 종류를 가르기

못 열어서 (70건) — 메시지가 원인을 그대로 말해줍니다.

텍스트
Error: Failed to resolve import "@/lib/utils" from "src/components/ui/button.tsx".
Does the file exist?

@ 별칭은 vite.config.ts에 있었고 우리가 그 연결을 끊었으니까요. button.tsx를 못 읽으면 그걸 쓰는 카드가 안 열리고, 카드가 안 열리면 피드가 안 열립니다.

돌았는데 틀려서 (5건) — 두 파일입니다.

텍스트
 FAIL  src/contexts/c3-rerender-scope.test.tsx   (2건)
 FAIL  src/hooks/a5-identity-probe.test.tsx      (3건)

c3-rerender-scope.test.tsx를 열어보면 답이 주석에 있습니다.

TypeScript
// apps/web-spa/src/contexts/c3-rerender-scope.test.tsx
    // props 가 그대로인 자식 엘리먼트를 컴파일러가 붙들고 있기 때문이다.
    // (같은 판을 컴파일러 없이 돌리면 셋 다 1 -> 2 가 된다)
    expect(counts.reader).toBe(before.reader);

C-3에서 이 판을 만들 때 적어둔 예측이, 지금 컴파일러를 뺀 상태에서 그대로 재현된 것입니다. 실패 메시지도 expected 2 to be 1로 정확히 그 말이에요.

a5-identity-probe.test.tsx의 3건은 함수의 정체성입니다. 컴파일러가 있으면 렌더가 다시 돌아도 같은 함수인데, 없으면 매번 새 함수가 됩니다.

되돌리기

Bash
git checkout apps/web-spa/vitest.config.ts
npm test
텍스트
 Test Files  135 passed (135)
      Tests  954 passed (954)

자주 나오는 실수

  • "5 failed"만 보고 끝내는 것. 화면 맨 아래 요약이 가장 눈에 띄는데, 거기엔 사라진 532개가 안 나옵니다. Test Files 줄의 72와 Tests 줄의 422를 같이 봐야 해요.
  • 못 연 70개를 고치려고 @ 별칭을 vitest.config.ts에 따로 적는 것. 그렇게 하면 통과는 하지만, 그게 바로 오늘 배운 문제입니다. 앱 설정과 테스트 설정이 두 벌이 되는 겁니다. 별칭 하나를 그렇게 넘기면 다음엔 컴파일러를, 그다음엔 다른 것을 계속 옮겨 적게 됩니다.

💡 튜터의 한마디

이 과제에서 진짜 무서운 것은 70이 아니라 5입니다.

70개는 요란하게 터졌어요. 빨간 글씨가 가득하고 원인도 메시지에 적혀 있습니다. 이런 건 무조건 고치게 됩니다.

5개는 조용히 틀렸습니다. 문법도 맞고 파일도 다 열렸고 그냥 숫자가 달랐어요. 만약 C-3과 A-5에서 그 판을 안 만들어뒀다면 아무도 컴파일러가 빠진 걸 몰랐을 겁니다. 앱은 여전히 도니까요. 조금 느려질 뿐이죠.

여러분이 실무에서 만날 사고의 대부분이 이 5개 쪽입니다. 터지지 않고, 로그도 안 남고, 그냥 조금씩 다르게 동작하는 것들이요.

테스트를 쓸 때는 그래서 "터지는지"만 보지 말고 "내가 기대한 그 값이 맞는지"를 적어야 하는데, 다음 시간에 첫 판을 쓸 때 이 이야기를 다시 하겠습니다.


🤔 [생각해볼 주제 1] 커버리지를 팀 규칙으로 만든다면

문제 상황 요약

어떤 팀이 "커버리지 80% 미만이면 병합할 수 없다"는 규칙을 세웠습니다. 의도는 좋아 보이지만, 오늘 우리는 그 숫자가 세는 범위와 재는 환경에 따라 크게 움직이는 것을 봤습니다.

튜터의 가이드 및 해설

이 규칙이 실제로 만들어내는 행동부터 봅시다.

규칙이 숫자를 걸면, 사람은 숫자를 맞추는데, 숫자를 맞추는 가장 싼 방법은 대개 테스트를 잘 쓰는 게 아니에요.

  • 세는 범위를 좁힙니다. 우리가 오늘 한 것과 똑같은 일이죠. scratch를 빼서 6.6 포인트를 벌었고 나쁜 짓도 아니었지만, 같은 손놀림으로 "복잡해서 테스트하기 어려운 폴더"를 빼면 숫자는 오르고 위험은 그대로 남습니다.
  • 덮기 쉬운 곳부터 씁니다. 화면을 그리기만 하는 컴포넌트에 테스트를 붙이면 줄이 왕창 덮입니다. 정작 위험한 계산 로직은 조건이 많아 손이 많이 가고요. 숫자로 보상하면 쉬운 쪽으로 몰립니다.
  • 아무것도 안 지키는 테스트가 생깁니다. 렌더만 시키고 아무것도 비교하지 않아도 커버리지는 오릅니다.

여기에 오늘 본 함정이 하나 더 겹칩니다. 우리 저장소는 갈래의 73% 가 React Compiler가 만든 것이었어요. 컴파일러 버전이 올라가서 캐시 전략이 바뀌면 우리가 코드를 한 줄도 안 고쳐도 숫자가 움직이는데, 그런 숫자에 병합을 걸어두면 어느 날 아무도 아무것도 안 했는데 병합이 막힙니다.

그렇다면 어떻게 고쳐 쓸까요.

실무에서 자리를 잡은 방식은 절대값이 아니라 방향을 보는 것입니다.

  • "이번 변경으로 커버리지가 내려가지 않았는가"를 봅니다. 기존 코드의 낮은 숫자는 그대로 두되, 새로 들어오는 코드는 덮이게 하는 거예요.
  • 더 좁히면 "이 PR이 바꾼 줄"만 봅니다. 100줄을 고쳤는데 그중 10줄만 덮였다면 그건 물어볼 만한 일이고, 전체 숫자와 무관하게 판정할 수 있습니다.
  • 숫자는 차단이 아니라 표시로 씁니다. 병합을 막는 대신 "이 PR은 커버리지가 3% 내려갑니다"를 리뷰어에게 보여주는 거예요. 판단은 사람이 합니다. 정당한 이유로 내려가는 경우가 실제로 있으니까요. 죽은 코드를 지웠는데 그게 잘 덮여 있던 코드였다면 숫자는 내려갑니다.

핵심은 이겁니다. 커버리지는 대화를 시작하기 좋은 숫자지, 대화를 끝내기 좋은 숫자가 아닙니다.

🎯 면접관을 홀리는 핵심 멘트

"커버리지에 하한선을 걸면 사람들이 테스트를 잘 쓰는 게 아니라 숫자를 잘 맞추게 됩니다. 세는 범위를 좁히거나 덮기 쉬운 곳부터 채우는 쪽이 늘 더 싸거든요. 저희 프로젝트는 자동 메모이제이션 때문에 갈래의 73%가 우리가 쓰지 않은 코드였는데, 그런 숫자에 병합을 걸면 아무도 손대지 않아도 어느 날 막힙니다. 그래서 절대값에 문을 걸지 않고 변경분이 덮였는지를 리뷰어에게 보여주는 쪽으로 씁니다. 차단이 아니라 표시로요."


🤔 [생각해볼 주제 2] 이미 있는 954개를 우리가 믿어도 될까

문제 상황 요약

954개가 전부 통과합니다. 그런데 통과한다는 것이 그 954개가 제대로 된 확인이라는 뜻은 아닙니다. 실제로 이 저장소에서도 아무것도 안 지키면서 초록불이던 판이 발견된 적이 있습니다.

튜터의 가이드 및 해설

왜 커버리지로는 못 찾을까요.

커버리지는 실행됐는지만 셉니다. 그 줄을 지나갔으면 덮인 걸로 치고, 무엇을 비교했는지는 안 봐서 이런 테스트도 커버리지 100% 를 만듭니다.

TypeScript
it('피드가 그려진다', () => {
  render(<FeedSection posts={feedPosts} />);
});

렌더는 했으니 그 안의 모든 줄이 실행됩니다. 그런데 아무것도 확인하지 않았어요. FeedSection이 빈 화면을 그려도 이 판은 초록입니다.

초록인데 아무것도 안 지키는 테스트의 종류를 정리하면 이렇습니다.

  • 비교가 없는 것. 위 예시처럼 실행만 하고 끝나는 경우입니다. 터지지만 않으면 통과예요.
  • 없는 것을 없다고 확인하는 것. C-1에서 실제로 나온 사례입니다. 회원가입을 다른 화면으로 분리한 뒤에도, 피드 화면에 회원가입이 없다는 것을 확인하는 코드가 남아 있었어요. 분리했으니 당연히 없죠. 그 판은 앱이 어떻게 망가지든 영원히 초록입니다.
  • 우연히 기대값이 맞는 것. C-7에서 두 건 나왔습니다. 아무 일도 안 일어나는데 기대한 값과 현재 값이 우연히 같아서 통과하고 있었어요.

그렇다면 어떻게 찾을까요.

가장 확실한 방법은 일부러 깨뜨려보는 것입니다.

지키려는 코드를 고의로 망가뜨리고 테스트를 돌립니다. 빨개지면 그 테스트는 일하고 있는 거고, 초록이면 그 테스트는 아무것도 안 지키고 있는 겁니다.

이 과목에서 우리는 이걸 계속 해왔어요. C-4에서 location.key 검사를 지워보고 1건이 깨지는 것을 확인했고, E-7에서는 아이콘에 이름표를 일부러 붙여서 숨김 확인이 걸리는지 봤습니다. 오늘도 재시도를 넣고 나서 일부러 틀린 값을 넣어 세 번 시도한 뒤 정상적으로 빨개지는지 확인했고요.

이걸 자동으로 해주는 도구도 있습니다. 코드를 조금씩 바꿔보고(부호를 뒤집거나 조건을 반대로 하거나) 테스트가 그걸 잡아내는지 세는 방식이에요. 잡아내지 못한 변형이 남으면 그만큼 구멍입니다. 그런데 코드를 수백 번 바꿔가며 매번 전체 테스트를 돌려야 해서 아주 느립니다. 그래서 매번 돌리기보다 핵심 모듈에 가끔 돌려보는 쪽으로 씁니다.

현실적인 답은 습관 쪽입니다. 새 테스트를 쓸 때 반드시 한 번은 빨간 것을 보고 나서 초록으로 만듭니다. 처음부터 초록인 테스트는 그것이 무언가를 지키는지 확인할 기회를 영영 놓친 거예요.

🎯 면접관을 홀리는 핵심 멘트

"테스트가 통과한다는 것과 그 테스트가 일을 한다는 것은 다릅니다. 커버리지는 실행 여부만 세지 무엇을 비교했는지는 안 보니까, 렌더만 하고 아무것도 확인 안 하는 판도 100%를 만듭니다. 저희 저장소에서도 이미 분리한 화면이 없다는 걸 확인하던 판, 우연히 기대값이 맞아서 통과하던 판이 실제로 나왔어요. 그래서 저는 새 테스트는 반드시 한 번 빨간 것을 보고 초록으로 만듭니다. 그 순간이 그 테스트가 무언가를 지킨다는 유일한 증거거든요."


🤔 [생각해볼 주제 3] 도구가 우리를 보고 있다는 것

문제 상황 요약

Vitest는 AI 코딩 도구 안에서 돌고 있다는 것을 알아채고 출력을 바꿉니다. 100% 인 파일을 표에서 빼고 요약 블록을 덧붙여요. 친절한 기능인데, 그 친절 때문에 제가 본 화면과 여러분이 볼 화면이 달라졌습니다.

튜터의 가이드 및 해설

먼저 이게 왜 생긴 기능인지 짚고 갑시다.

AI 도구는 터미널 출력을 통째로 읽습니다. 표가 148줄이면 그걸 다 읽느라 정작 중요한 부분을 놓치거나, 읽는 양 자체가 비용이 됩니다. 그래서 "볼 필요 없는 줄을 미리 빼주는" 게 합리적이에요. 나쁜 의도가 전혀 아닙니다.

문제는 이런 종류의 배려가 조용하다는 것입니다.

경고가 안 뜹니다. "지금 45줄만 보여드리고 있습니다" 같은 안내가 없어요. 그래서 화면을 그대로 믿게 됩니다. 제가 이 수업을 준비하면서 딱 그렇게 됐고요.

이건 앞으로 훨씬 흔해질 겁니다. 지금도 도구들은 여러 가지를 감지해서 동작을 바꿉니다. 터미널인지 파일로 넘기는 중인지 보고 색을 끄고, 자동화 환경인지 보고 진행 표시를 끄고, 창 너비를 보고 표를 자릅니다. 여기에 "AI가 읽는 중인가"가 한 축 더 붙은 거예요.

그럼 무엇을 습관으로 만들어야 할까요.

세 가지를 제안합니다.

하나, 숫자를 옮길 때는 어떻게 얻었는지도 함께 적습니다. 명령, 실행한 곳, 그리고 이상하다 싶으면 환경까지요. 이 과목에서 우리는 이걸 여러 번 배웠습니다. E-7에서 같은 칸을 422px 과 406px 로 쟀는데 둘 다 참이었던 것처럼요.

둘, 남이 재현할 화면과 내 화면이 같은지 한 번은 확인합니다. 특히 그 숫자를 다른 사람에게 전달할 때요. 오늘 제가 한 것이 그겁니다. 환경 변수를 걷어내고 다시 돌려서 103줄을 확인했어요. 그 한 번이 없었으면 이 수업 자료 전체가 틀렸을 겁니다.

셋, 되돌려도 안 바뀌면 재는 쪽을 의심합니다. D-2에서 우리는 미리 받기가 0건으로 나오는 것을 앱 문제로 알고 네 가지를 되돌려가며 찾았는데, 원인은 브라우저 탭이 뒤에 있어서였어요. 앱을 아무리 되돌려도 안 바뀌면 문제는 앱이 아니라 관측 장비 쪽입니다.

마지막으로 하나 덧붙이면, 이건 테스트에만 있는 이야기가 아닙니다. 여러분이 성능을 재든 로그를 읽든, 관측하는 행위가 대상을 바꾸거나 관측 결과가 관측자에 따라 달라지는 일은 계속 만나게 됩니다. 오늘 커버리지 계측이 테스트 하나를 흔든 것도 같은 종류였고요.

🎯 면접관을 홀리는 핵심 멘트

"요즘 도구들은 실행 환경을 감지해서 출력을 바꿉니다. 저희가 쓰는 테스트 러너는 AI 코딩 도구 안에서 돌면 커버리지 표에서 100%인 파일을 조용히 빼요. 편의 기능인데 경고가 없어서, 같은 명령이 제 화면엔 45줄, 동료 화면엔 103줄로 나옵니다. 그래서 저는 숫자를 남에게 전달하기 전에 그 사람이 볼 조건에서 한 번 더 재봅니다. 되돌렸는데도 값이 안 바뀌면 코드가 아니라 재는 쪽을 먼저 의심하고요."

전체 목록 리액트