F-2: 첫 테스트를 쓴다 — 지도가 가리킨 네 곳
목차 55
안녕하세요, 홍순구입니다.
질문 하나로 시작할게요.
여러분이 어떤 코드베이스에 새로 들어갔는데, 커버리지를 켜보니 테스트가 하나도 안 닿은 함수가 보여요. 자, 첫 번째로 할 일은 뭘까요?
거의 모두가 같은 답을 합니다. "테스트를 쓴다."
오늘 우리는 그런 곳을 네 군데 봅니다. 그리고 그중 둘은 테스트를 쓰는 게 답이 아니에요. 하나는 지우는 게 답이고, 하나는 아예 덮을 수가 없습니다. 덮을 수 있다 해도 덮으면 안 되고요.
지난 시간에 우리는 커버리지를 켜고 첫 숫자를 뽑았고, 그 숫자가 세는 범위와 재는 환경에 따라 어떻게 흔들리는지도 봤어요. 오늘은 그 표가 가리킨 곳으로 실제로 가는데, 가서 보면 표가 우리에게 말해주지 않은 것이 하나 있다는 걸 알게 될 거예요.
오늘의 여정
지도가 가리킨 네 곳 ── 같은 표인데 답이 넷 다 다르다
어떤 것은 지웁니다 ── 테스트 대신 삭제
어떤 것은 못 덮습니다 ── 덮을 수 있어도 덮으면 안 되는 것
첫 판을 직접 씁니다 ── describe · it · expect
같다는 것이 두 가지 ── toBe 와 toEqual
서버 없이 돌립니다 ── vi.mock
가짜에게 물어봅니다 ── 무엇을 물었는가
고치는 동안 계속 ── watch
💡 오늘 수업의 핵심 — "커버리지는 안 덮였다까지만 말한다. 그래서 무엇을 하라는 안 말한다"
🎯 학습 목표
- 커버리지가 가리킨 곳을 보고 지울 것·못 덮는 것·판을 쓸 것을 갈라 판단할 수 있다.
describe·it·expect로 순수 함수의 안 물어본 갈래에 직접 판을 쓰고,toBe와toEqual을 상황에 맞게 고를 수 있다.- 확인하려는 함수가 다른 것을 부를 때
vi.mock으로 갈아 끼우고,mock.calls로 무엇을 물었는지까지 확인할 수 있다.
Step 1: "지도가 가리킨 네 곳"
지난 시간 마지막에 뽑은 표를 다시 열어봅시다.
npm run test:coverage --workspace web-spa
103줄짜리 표가 나옵니다. 그중 우리가 오늘 볼 네 줄만 떼어내면 이렇게 생겼어요.
파일 Stmts Branch Funcs Lines 안 덮인 줄
─────────────────────────────────────────────────────────────────
src/data/feed.ts 44.44 100 0 57.14 154-159
src/lib/comments.ts 66.66 66.66 100 66.66 38-39
src/lib/likes.ts 100 75 100 100 8-10
src/api/dm.ts 50 100 50 50 13-15
숫자만 봐서는 넷 다 비슷해 보입니다. "덜 덮인 파일 네 개" 죠. 하나씩 열어보면 넷 다 다른 이유로 덜 덮여 있고, 따라서 해야 할 일도 넷 다 다릅니다.
첫 번째 — 함수 커버리지가 0입니다
src/data/feed.ts 줄을 다시 보세요. Funcs가 0인데, 이 파일에 함수가 있는데도 그중 어느 것도 한 번도 안 불렸다는 뜻이에요.
954개의 확인이 도는 동안 단 한 번도요.
두 번째 — 갈래가 딱 하나 남았습니다
src/lib/comments.ts는 38-39번 줄이 안 덮였습니다. A-2에서 만든 판별 유니온 리듀서예요. 그 두 줄이 무엇인지는 Step 3에서 열어봅니다.
세 번째 — 줄은 100인데 갈래는 75입니다
src/lib/likes.ts를 보세요. Lines가 100이라 모든 줄이 실행됐다는 뜻인데, Branch는 75예요.
모든 줄이 실행됐는데 어떻게 갈래는 덜 덮일까요? 줄이 실행되는 것과 그 줄의 모든 갈림길을 다 지나가는 것은 다르기 때문입니다. if가 있는 줄은 한 번만 지나가도 "실행됨"이 되지만, 갈래는 참일 때와 거짓일 때 둘 다 지나가야 채워져요.
네 번째 — 첫 번째와 똑같이 생겼습니다
src/api/dm.ts는 Funcs가 50이라, 함수 두 개 중 하나만 불렸어요.
여기서 잠깐 멈춰봅시다. 첫 번째와 네 번째는 표에서 거의 같은 이야기를 하고 있어요. 둘 다 "여기 아무도 안 부르는 함수가 있다"입니다. 그런데 오늘 우리가 할 일은 정반대예요. 하나는 지우고, 하나는 판을 씁니다.
표는 그 차이를 절대 못 알려줍니다. 숫자는 똑같이 생겼으니까요.
표가 말해주는 것 표가 말 안 해주는 것
───────────────── ──────────────────────
여기가 안 덮였다 그래서 무엇을 해야 하나
몇 퍼센트다 이 코드가 아직 필요한가
몇 번째 줄이다 누가 왜 이걸 만들었나
오늘 하루가 통째로 이 표의 오른쪽 칸을 채우는 시간입니다.
💡 한 줄 정리
커버리지는 어디가 안 덮였는지까지만 말해준다. 지울 것인지 판을 쓸 것인지는 코드의 이력과 의도를 봐야 알 수 있고, 그건 표에 안 나온다.
🙋 학생 질문 — "튜터님, 그럼 커버리지 표는 별로 안 쓸모 있는 건가요?"
아니에요. 오히려 반대입니다.
오늘 우리가 보는 네 곳은 전부 커버리지가 찾아준 것이에요. 954개의 확인이 있는 저장소에서 사람이 눈으로 훑어서 "아, 여기 아무도 안 부르는 함수가 있네"를 찾아낼 수 있을까요? 못 찾습니다.
표는 후보를 좁혀주는 도구예요. 3,000줄에서 볼 곳 네 군데를 골라준 겁니다. 그 네 군데를 열어서 판단하는 것은 사람의 일이고요.
문제가 되는 건 표를 목표로 삼을 때인데, "80% 를 넘겨라"가 규칙이 되면 사람은 판단하는 대신 숫자를 올리게 돼요. 오늘 Step 3에서 그렇게 했을 때 무슨 일이 벌어지는지 직접 보게 될 겁니다.
Step 2: "어떤 것은 지웁니다"
첫 번째 줄부터 갑니다. src/data/feed.ts를 열어보세요. 파일 맨 아래에 이런 게 있습니다.
// apps/web-spa/src/data/feed.ts
// 지금까지 데이터는 import 하는 순간 이미 있었다. 진짜 서버라면 그렇지 않다.
// 여기서는 기다리는 것만 흉내 낸다 — 진짜 요청으로 바뀌는 것은 C-6 다.
const NETWORK_DELAY_MS = 400;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function fetchPost(id: number): Promise<Post | undefined> {
await delay(NETWORK_DELAY_MS);
return allPosts.find((post) => post.id === id);
}
기억나시나요? C-2에서 만든 겁니다. 그때는 진짜 서버가 없어서 400ms 기다리는 것만 흉내 냈는데, 주석에도 그렇게 적혀 있죠. "진짜 요청으로 바뀌는 것은 C-6 다."
C-6에서 실제로 바뀌면서 진짜 Axios 인스턴스가 생겼고, src/api/posts.ts에 fetchPostById가 들어와서 화면은 그때부터 그쪽을 쓰는데도, 이 함수는 안 지워졌어요.
정말 아무도 안 부르는지 확인합니다
믿지 말고 세어봅시다.
grep -rn "fetchPost" src/
한 줄만 나옵니다.
src/data/feed.ts:157:export async function fetchPost(id: number): Promise<Post | undefined> {
자기 정의뿐이에요. 부르는 곳이 없습니다. 954개의 확인에서도, 앱 코드 어디에서도요.
비교로 살아 있는 쪽을 세어보면 확실해집니다.
grep -rn "fetchPostById" src/
이쪽은 여덟 줄이 나와요. 정의 하나, 화면이 쓰는 곳, 그리고 이미 있는 확인 판들까지요.
⚠️
grep에--include=*.ts같은 옵션을 붙이실 때 주의하세요. 별표를 따옴표로 안 감싸면 셸이 그걸 파일 이름 목록으로 바꾸려 들고, 맞는 파일이 없으면 명령 자체가 안 돕니다. 그런데 화면에는 아무것도 안 나오니까 "결과가 0건"처럼 보여요. 안 돈 것과 0건은 다릅니다.
지웁니다
// apps/web-spa/src/data/feed.ts
// 화면이 부르는 전체 피드. 앞의 두 장을 그대로 품고 있어서
// /p/1 로 들어오면 여전히 한강 노을이 나온다.
export const allPosts: Post[] = [...feedPosts, ...morePosts];
파일이 여기서 끝나게 됩니다. 160줄이던 파일이 148줄이 됐어요. 12줄이 사라졌습니다.
함수 하나만 지웠는데 왜 12줄일까요? delay()와 NETWORK_DELAY_MS도 이 함수 말고는 아무도 안 썼기 때문인데, 죽은 함수 하나를 걷어내니 그것만 쓰던 도우미 둘이 따라서 드러난 거예요.
이게 죽은 코드의 성질입니다. 혼자 안 죽어요. 뿌리를 뽑으면 딸려 나오는 게 있습니다.
확인합니다
npm test
Test Files 135 passed (135)
Tests 954 passed (954)
하나도 안 바뀌었습니다. 지우기 전과 똑같은데, 아무도 안 부르던 코드니까 당연한 결과죠. 그리고 커버리지를 다시 재보면 src/data/feed.ts 줄이 이렇게 바뀝니다.
전: src/data/feed.ts 44.44 100 0 57.14 154-159
후: src/data/feed.ts 100 100 100 100
100% 가 됐어요. 테스트를 한 줄도 안 쓰고요.
여기서 오늘 가장 중요한 것 하나를 짚고 갑니다. 커버리지를 올리는 방법 중 하나는 코드를 지우는 것입니다. 이건 편법이 아니라 대부분의 경우 가장 좋은 방법인데, 안 쓰는 코드에 테스트를 쓰면 안 쓰는 코드와 그걸 지키는 테스트 둘 다 앞으로 계속 관리해야 하니까요.
💡 한 줄 정리
안 덮인 코드를 만나면 "테스트를 쓸까"보다 "이게 아직 필요한가"를 먼저 묻는다. 필요 없으면 지우는 것이 답이고, 커버리지는 저절로 오른다.
🙋 학생 질문 — "지웠다가 나중에 필요하면 어쩌죠? 남겨두면 안 되나요?"
이 걱정이 죽은 코드가 안 지워지는 가장 큰 이유예요. 그래서 정면으로 답할게요.
필요하면 되살리면 됩니다. 우리는 git을 쓰고 있어요. 이 함수는 지금 이 순간에도 C-2 시절 커밋 안에 그대로 살아 있습니다. git log로 찾아서 꺼내오면 돼요. 지운다는 건 없애는 게 아니라 현재 코드에서 치우는 것입니다.
반대로 남겨두면 무슨 일이 생기냐면요.
- 새로 온 사람이 이걸 보고 "아, 게시물 하나 가져올 땐 이걸 쓰는구나" 하고 쓰는데, 이건 400ms 기다렸다 배열에서 찾는 흉내라 진짜 서버에 안 갑니다.
- 타입을 바꿀 때마다 이 함수도 같이 고쳐야 합니다. 아무도 안 쓰는데요.
- 커버리지 표에 계속 빨간 줄로 남아서, 진짜 봐야 할 곳을 가립니다.
실무에서 쓰는 기준은 이거예요. "지웠을 때 무엇이 깨지는지 모르겠다" 면 아직 지울 준비가 안 된 것이고, "아무것도 안 깨진다는 걸 확인했다" 면 지웁니다. 우리는 방금 954개로 확인했죠.
Step 3: "어떤 것은 못 덮습니다"
두 번째 줄로 갑니다. src/lib/comments.ts의 38-39번 줄이에요.
// apps/web-spa/src/lib/comments.ts
export function commentReducer(state: CommentState, action: CommentAction): CommentState {
switch (action.type) {
case 'add':
return {
items: [...state.items, { id: state.nextId, content: action.content }],
nextId: state.nextId + 1,
};
case 'remove':
return {
...state,
items: removeById(state.items, action.id),
};
default: {
// 위에서 빠뜨린 일이 있으면 이 줄에서 잡힌다
const missed: never = action;
return missed;
}
}
}
안 덮인 두 줄은 default: 안입니다. A-2에서 배운 완전성 검사예요.
CommentAction은 'add' 아니면 'remove' 둘뿐입니다. 위에서 둘 다 처리했으니 default:에 도착했을 때 남아 있는 타입은 아무것도 없어서 never입니다. 나중에 누가 세 번째 종류를 추가하고 case를 안 쓰면, 그 순간 이 줄에서 타입 검사가 터져서 빠뜨린 걸 알려주죠.
여기에 판을 쓰려면 어떻게 해야 할까요
정직하게 불러봅시다.
commentReducer(initialCommentState, { type: 'unknown' });
타입 검사가 막습니다.
error TS2322: Type '"unknown"' is not assignable to type '"add" | "remove"'.
당연해요. 그게 이 장치의 존재 이유니까요. 이 갈래에 도달하는 것 자체를 타입 검사가 막고 있으니, 이 줄은 "아직 테스트를 안 쓴 곳"이 아니라 원리적으로 정상적인 방법으로는 도달할 수 없는 곳입니다.
그런데 억지로 덮을 수는 있습니다
타입 검사에 거짓말을 하면 됩니다.
const lie = { type: 'unknown' } as unknown as CommentAction;
const result = commentReducer(initialCommentState, lie);
이러면 컴파일이 통과하고, 런타임에 default:로 들어갑니다. 실제로 돌려주는 값은 이거예요.
{ "type": "unknown" }
return missed가 action을 그대로 돌려준 겁니다. CommentState여야 하는 자리에 액션이 나온 거죠. 말이 안 되는 값입니다.
이 상태로 판을 하나 써서 돌리면 커버리지가 이렇게 바뀝니다.
전: src/lib/comments.ts 66.66 66.66 100 66.66 38-39
후: src/lib/comments.ts 100 100 100 100
100% 가 됐습니다. 표에서 빨간 줄이 사라졌고, 팀 대시보드 숫자도 올랐어요.
그런데 이 판이 지키는 게 뭘까요
한 문장으로 적어보면 이렇습니다.
"잘못된 액션을 넣으면 잘못된 값이 나온다."
이게 지킬 만한 약속인가요? 아니에요. 아무도 이런 동작에 기대면 안 되고, 애초에 이 상황에 도달하려면 타입 검사를 속여야 합니다. 앱이 진짜로 망가졌을 때 이 판이 빨개질까요? 앱과 아무 상관이 없어서 안 빨개집니다.
지난 시간 마지막에 던졌던 질문 기억하시죠. 초록불인데 아무것도 안 지키고 있는 테스트를 어떻게 찾아낼까. 방금 우리가 하나를 직접 만들어본 것인데, 커버리지 표는 이 판을 다른 954개와 똑같이 대접했어요. 표는 판의 품질을 안 봅니다. 그 줄을 지나갔는지만 봐요.
그러니 되돌립시다. 이 판은 저장소에 두지 않습니다.
덮을 수 없다 ← 타입 검사가 막는다 → 놔둔다
덮을 수 있다 ← 타입 검사를 속이면 → 그래도 놔둔다
(숫자만 오르고 지키는 게 없다)
src/lib/comments.ts는 66.66% 로 남는데, 그게 맞는 상태예요.
💡 한 줄 정리
never 완전성 검사는 도달 자체를 타입 검사가 막는 갈래다. 억지로 덮으면 숫자는 오르지만 그 판이 지키는 것은 없다. 100% 가 아닌 채로 두는 것이 정직하다.
🙋 학생 질문 — "그럼 커버리지 100% 를 요구하는 팀에서는 이걸 어떻게 설명하죠?"
실무에서 자주 겪는 상황이에요. 두 가지로 답할 수 있습니다.
첫째, 도구에 알려주는 방법이 있습니다. 대부분의 커버리지 도구는 "이 줄은 세지 마라"를 표시하는 주석을 지원해요. 그걸 붙이면 100% 가 되고, 거짓말하는 판도 안 생깁니다. 다만 이 방법도 남용하면 결국 "안 세는 줄" 목록이 길어져서 숫자가 또 의미를 잃어요.
둘째, 그리고 이쪽이 더 중요한데, 숫자를 목표로 삼는 규칙 자체를 다시 보게 하는 것입니다.
이렇게 말해볼 수 있어요. "이 두 줄을 덮으려면 타입 검사를 속이는 판을 써야 합니다. 그 판은 앱이 망가져도 안 빨개져요. 100% 를 맞추려고 그 판을 넣으면, 우리 커버리지 숫자는 이제 정직한 판과 그렇지 않은 판이 섞인 숫자가 됩니다. 그 다음부터는 이 숫자로 아무 판단도 못 해요."
숫자를 지키려다 숫자의 의미를 잃는 거죠. 이 이야기는 오늘 과제와 생각해볼 주제에서 한 번 더 다룹니다.
Step 4: "첫 판을 직접 씁니다"
이제 진짜 구멍으로 갑니다. src/lib/likes.ts예요.
// apps/web-spa/src/lib/likes.ts
// 화면이 안 바뀌는 버전 — 원래 있던 객체를 그 자리에서 고친다
export function toggleLikeInPlace(posts: Post[], id: number): Post[] {
const target = posts.find((post) => post.id === id);
if (target) {
target.liked = !target.liked;
target.likeCount = target.liked ? target.likeCount + 1 : target.likeCount - 1;
}
return posts;
}
B-2에서 만든 함수죠. 상태를 그 자리에서 고치면 React가 못 알아챈다는 걸 보여주려고 일부러 남겨둔 겁니다.
이미 확인 판이 있어요. src/components/b2-lifting.test.tsx에 이렇게 들어 있습니다.
// apps/web-spa/src/components/b2-lifting.test.tsx
describe('toggleLikeInPlace — 그 자리에서 고치는 버전', () => {
it('값은 바뀌지만 배열 참조가 그대로라 React 가 바뀐 걸 모른다', () => {
const posts = samplePosts();
const next = toggleLikeInPlace(posts, 1);
expect(next[0].liked).toBe(true);
expect(next).toBe(posts);
expect(next[0]).toBe(posts[0]);
});
});
판이 있는데 왜 갈래가 75% 일까요? 잘 보세요. toggleLikeInPlace(posts, 1). 1번은 있는 게시물입니다.
이 판은 번호가 있을 때만 부르고 있어요. if (target)이 거짓인 쪽으로는 아무도 안 가봤습니다.
없는 번호를 주면 어떻게 되나요? 아무도 안 물어봤어요.
판을 쓸 파일을 만듭니다
우리 저장소의 규칙은 간단합니다. 확인하려는 파일 옆에, 모듈 번호를 앞에 붙여서 .test.ts로 끝나게 둡니다.
src/lib/likes.ts ← 확인하려는 것
src/lib/f2-likes.test.ts ← 우리가 쓸 판
Vitest는 이 이름 규칙으로 판을 찾아냅니다. 따로 등록할 데가 없어요.
describe · it · expect
세 가지만 알면 판을 쓸 수 있습니다.
describe ── 묶음 이름. 무엇에 대한 확인인지
it ── 판 하나. 무엇이 참이어야 하는지
expect ── 실제 비교. 이 값이 저 값이어야 한다
describe와 it은 이름을 붙이는 일만 하는데, 실패했을 때 화면에 그 이름이 그대로 나와서 이름을 잘 지어두면 판이 깨졌을 때 무엇이 깨졌는지 읽기만 해도 알 수 있습니다.
써봅시다.
// apps/web-spa/src/lib/f2-likes.test.ts
import { describe, it, expect } from 'vitest';
import { feedPosts } from '../data/feed';
import { toggleLikeInPlace } from './likes';
import type { Post } from '../types/instagram';
// 원본을 건드리면 다음 판이 영향을 받는다. 판마다 새 사본으로 시작한다.
function samplePosts(): Post[] {
return feedPosts.map((post) => ({ ...post }));
}
describe('toggleLikeInPlace — 없는 번호를 줬을 때', () => {
it('숫자도 하트도 그대로다', () => {
const posts = samplePosts();
const next = toggleLikeInPlace(posts, 999);
expect(next[0].likeCount).toBe(1240);
expect(next[0].liked).toBe(false);
});
});
samplePosts()를 왜 함수로 뒀는지 짚고 갈게요. toggleLikeInPlace는 받은 객체를 그 자리에서 고치는 함수입니다. 원본 feedPosts를 그대로 넘기면 첫 판이 그걸 바꿔놓고, 두 번째 판은 이미 바뀐 데이터로 시작해서 판끼리 서로 오염시키기 때문에, 판마다 새 사본을 만듭니다.
돌려봅시다.
npx vitest run src/lib/f2-likes.test.ts
Test Files 1 passed (1)
Tests 1 passed (1)
여러분의 첫 테스트가 통과했습니다.
그런데 아직 100% 가 아닙니다
커버리지를 다시 재보세요.
전: src/lib/likes.ts 100 75 100 100 8-10
후: src/lib/likes.ts 100 87.5 100 100 10
75에서 87.5로 올랐지만 100이 아닌데, 안 덮인 줄이 8-10에서 10 하나로 줄었네요.
10번 줄이 뭐죠?
target.likeCount = target.liked ? target.likeCount + 1 : target.likeCount - 1;
삼항 연산자입니다. if 안에 또 갈림길이 있었어요. 우리는 방금 if의 거짓 쪽을 덮었고, 참 쪽은 원래 판이 덮고 있었는데, 참 쪽으로 들어간 뒤에 만나는 이 삼항은 한쪽만 지나갔어요.
기존 판이 liked: false인 게시물을 눌렀으니 더하기 쪽만 지나간 겁니다. 이미 눌린 것을 다시 눌러서 빼기 쪽으로 가본 사람이 없어요.
구멍이 두 겹이었던 거죠. 하나를 메우니 안쪽에 하나가 더 있었습니다.
toggleLikeInPlace(posts, id)
│
├─ 번호가 없으면 ─────────── 아무도 안 가봤다 (방금 메움)
│
└─ 번호가 있으면
│
├─ 안 눌려 있었으면 ── 더하기 (원래 판이 덮고 있었다)
│
└─ 이미 눌려 있었으면 빼기 아무도 안 가봤다 (남은 것)
두 개를 더 채웁니다.
// apps/web-spa/src/lib/f2-likes.test.ts
it('있는 번호를 주면 바뀐다 — 없을 때와 갈리는 것을 함께 본다', () => {
const posts = samplePosts();
const next = toggleLikeInPlace(posts, 1);
expect(next[0].likeCount).toBe(1241);
expect(next[0].liked).toBe(true);
});
it('이미 눌린 것을 다시 누르면 숫자가 내려간다', () => {
const posts = samplePosts();
toggleLikeInPlace(posts, 1);
const next = toggleLikeInPlace(posts, 1);
expect(next[0].likeCount).toBe(1240);
expect(next[0].liked).toBe(false);
});
});
src/lib/likes.ts 100 100 100 100
100% 가 됐는데, 이 100% 는 Step 3의 100% 와 완전히 다른 것이에요. 세 판 모두 앱이 진짜로 망가지면 빨개집니다.
💡 한 줄 정리
describe로 묶고 it로 판 하나를 쓰고 expect로 비교하는데, 구멍은 한 겹이 아닐 수 있어서 메운 뒤에 숫자를 다시 보는 것까지가 한 판이다.
🙋 학생 질문 — "판 이름을 한글로 써도 되나요?"
됩니다. 우리 저장소는 처음부터 한글로 쓰고 있어요.
describe와 it에 넘기는 것은 그냥 문자열이에요. 코드로 해석되는 게 아니라 실패했을 때 화면에 그대로 찍히는 이름입니다. 그러니까 읽는 사람이 가장 빨리 이해하는 말로 쓰는 게 맞아요.
Step 8에서 판을 일부러 깨뜨려볼 텐데, 그때 화면에 이렇게 나옵니다.
FAIL src/lib/f2-likes.test.ts > toggleLikeInPlace — 없는 번호를 줬을 때 > 숫자도 하트도 그대로다
describe 이름과 it 이름이 >로 이어져서 한 문장이 되니까, 이름을 지을 때 둘을 붙여 읽으면 문장이 되게 짓는 게 좋습니다. "toggleLikeInPlace, 없는 번호를 줬을 때, 숫자도 하트도 그대로다" 처럼요.
이름만 읽고도 무엇이 깨졌는지 알 수 있으면 잘 지은 겁니다.
Step 5: "같다는 것이 두 가지입니다"
방금 우리가 쓴 판에서 toBe를 썼는데, Vitest에는 toEqual도 있습니다. 둘이 어떻게 다를까요?
한 줄로 말하면 이렇습니다.
toBe ── 같은 것인가 (똑같은 그 물건인가)
toEqual ── 같은 내용인가 (안에 든 게 같은가)
숫자나 문자열처럼 단순한 값은 둘이 똑같이 동작합니다. expect(1240).toBe(1240)도 toEqual도 통과해요.
갈리는 건 객체와 배열입니다.
const a = { id: 1, liked: false };
const b = { id: 1, liked: false };
expect(a).toEqual(b); // 통과 — 내용이 같다
expect(a).toBe(b); // 실패 — 다른 물건이다
a와 b는 생긴 게 똑같지만 따로 만들어진 두 객체예요. toBe는 그걸 다르다고 봅니다.
왜 이 구분이 우리한테 중요한가요
B-2를 떠올려보세요. React가 "바뀌었다"를 판단하는 기준이 정확히 이거였습니다. 내용이 아니라 같은 물건인가라서 toggleLikeInPlace가 문제였던 것이었으니, toBe와 toEqual 중 무엇을 쓰느냐는 취향이 아니라 무엇을 확인하고 싶은가의 문제입니다.
없는 번호에서 두 함수가 갈립니다
같은 파일에 두 함수가 나란히 있어요.
// apps/web-spa/src/lib/likes.ts
// 제대로 동작하는 버전 — 바뀐 게시물만 새로 만들고 나머지는 그대로 쓴다
export function toggleLike(posts: Post[], id: number): Post[] {
return posts.map((post) =>
post.id === id
? {
...post,
liked: !post.liked,
likeCount: post.liked ? post.likeCount - 1 : post.likeCount + 1,
}
: post,
);
}
B-2에서 배운 대로 이쪽이 맞는 버전입니다. 바뀐 것만 새로 만들고 나머지는 그대로 쓰죠. 그런데 없는 번호를 줬을 때 둘이 어떻게 다른지 확인해봅시다.
// apps/web-spa/src/lib/f2-likes.test.ts
// 없는 번호는 "아무 일도 안 일어나는" 자리다. 그런데 두 함수가 서로 다르게
// 아무 일도 안 한다 — 하나는 있던 배열을 그대로 주고 하나는 새로 만든다.
describe('없는 번호에서 두 함수가 갈리는 곳', () => {
it('toggleLikeInPlace 는 받은 배열을 그대로 돌려준다', () => {
const posts = samplePosts();
const next = toggleLikeInPlace(posts, 999);
expect(next).toBe(posts);
});
it('toggleLike 는 내용이 같은 새 배열을 만든다', () => {
const posts = samplePosts();
const next = toggleLike(posts, 999);
// 내용은 같다
expect(next).toEqual(posts);
// 그런데 같은 배열은 아니다
expect(next).not.toBe(posts);
});
});
돌려보면 둘 다 통과하는데, 이 결과가 재미있어요.
같은 배열인가 내용이 같은가
toggleLikeInPlace(999) 예 예
toggleLike(999) 아니오 예
toggleLike는 .map()을 쓰니까 번호가 하나도 안 맞아도 새 배열을 만듭니다. 안에 든 게시물은 전부 그대로 재사용하지만, 그것들을 담은 배열은 새것이에요.
그러니까 없는 번호를 줬을 때 React는 이렇게 봅니다. toggleLikeInPlace는 "안 바뀌었네"라고 정확히 판단하고, toggleLike는 "바뀌었네"라고 잘못 판단해서 화면을 다시 그립니다. 실제로는 아무것도 안 바뀌었는데요.
B-2에서 "이쪽이 맞다" 고 배운 함수가, 이 가장자리에서는 헛일을 하는 겁니다.
여기서 오해하시면 안 되는 게 있어요. toggleLike가 틀린 게 아닙니다. 번호가 있을 때는 여전히 이쪽이 맞고, B-2의 결론은 그대로 유효해요. 화면에서 좋아요를 누르면 항상 있는 번호가 들어가는데, 아무도 안 물어본 가장자리에는 아무도 몰랐던 동작이 있었고 그걸 드러내준 게 커버리지 표의 한 줄이었다는 것, 오늘 이걸 보시면 됩니다.
💡 한 줄 정리
toBe는 같은 물건인지, toEqual은 내용이 같은지를 본다. React가 보는 것은 앞쪽이라, 무엇을 확인하려는지에 따라 골라야 한다.
🙋 학생 질문 — "그럼 웬만하면 toEqual을 쓰는 게 안전한 거 아닌가요?"
toEqual이 더 너그럽긴 해서 판이 덜 깨지는데, 바로 그게 위험할 수 있습니다.
방금 판에서 not.toBe를 not.toEqual로 바꿔서 돌려보세요.
× toggleLike 는 내용이 같은 새 배열을 만든다
실패합니다. 내용이 같으니까, 그 자리에서는 toBe 여야만 우리가 확인하려던 것을 확인합니다.
거꾸로 toBe를 아무 데나 쓰면 판이 너무 쉽게 깨져요. 예를 들어 어떤 함수가 매번 새 객체를 돌려주는 게 정상인데 toBe로 비교하면, 함수가 멀쩡한데도 판이 빨개집니다.
기준은 이렇게 잡으세요. "이 함수가 같은 물건을 돌려주는 것이 약속의 일부인가?" 그렇다면 toBe, 아니라면 toEqual입니다. 우리 경우엔 "새 배열을 만드는가" 자체가 확인하려던 것이었으니 toBe가 맞았어요.
Step 6: "서버 없이 돌립니다"
마지막 네 번째 줄로 갑니다. src/api/dm.ts예요.
// apps/web-spa/src/api/dm.ts
import type { Conversation, DirectMessage } from '../types/dm';
import { api } from './client';
// 통로가 열리기 전에 오간 것은 통로로 안 온다. 그건 요청으로 받아야 한다.
export async function fetchMessages(conversationId: number): Promise<DirectMessage[]> {
const response = await api.get<DirectMessage[]>(`/conversations/${conversationId}/messages`);
return response.data;
}
export async function fetchConversations(): Promise<Conversation[]> {
const response = await api.get<Conversation[]>('/conversations');
return response.data;
}
함수가 둘인데 커버리지는 50% 였죠. fetchMessages는 C-10에서 쓰이고 있고, fetchConversations는 아무도 안 부릅니다.
Step 2와 똑같은 상황이에요. 그럼 지울까요?
여기서 갈립니다
fetchPost를 지울 수 있었던 이유를 다시 떠올려보세요. 대체된 코드였기 때문입니다. C-6에서 진짜 API가 생겼고 화면이 그쪽으로 옮겨갔어요. 남은 쪽은 흉내 내는 옛날 판이었죠.
fetchConversations는 다릅니다. 이건 대화 목록을 가져오는 API를 부르는 함수예요. 옆에 있는 fetchMessages와 같은 종류이고, 같은 시기에 같은 이유로 만들어졌습니다. 백엔드에 그 주소가 실제로 있고요. 아직 그걸 쓰는 화면을 안 만들었을 뿐이에요.
표에서는 둘이 똑같이 생겼는데 답이 정반대입니다.
fetchPost 아무도 안 부른다 → 대체됐다 → 지운다
fetchConversations 아무도 안 부른다 → 아직 안 썼다 → 판을 쓴다
이 차이는 숫자 어디에도 안 나옵니다. 코드가 왜 거기 있는지를 알아야 갈릴 수 있어요. 오늘 첫 Step에서 "표가 말 안 해주는 것"이라고 했던 게 이겁니다.
그런데 이건 혼자 안 돕니다
지금까지 우리가 판을 쓴 toggleLikeInPlace는 편했어요. 배열을 넣으면 배열이 나옵니다. 밖에 있는 것에 기대는 게 없어요.
fetchConversations는 다릅니다. api.get을 부릅니다. 그리고 api는 진짜 서버로 요청을 보내는 물건이에요.
이대로 판을 쓰면 어떻게 될까요? 판을 돌릴 때마다 localhost:8090으로 진짜 요청이 나갑니다. 서버가 안 떠 있으면 실패하고요. 서버가 떠 있어도 그 안의 데이터에 따라 결과가 달라집니다. 판이 우리 코드가 아니라 서버 상태에 따라 빨개졌다 파래졌다 해요.
우리가 확인하고 싶은 건 "서버가 잘 도는가"가 아니라 "우리 함수가 받은 것을 제대로 처리하는가"이므로, 진짜 api를 치우고 우리가 만든 가짜를 그 자리에 놓습니다.
vi.mock과 vi.fn
// apps/web-spa/src/api/f2-dm.test.ts
import { describe, it, expect, vi } from 'vitest';
// 진짜 client 를 치우고 우리가 만든 가짜를 그 자리에 놓는다.
// 이 줄은 파일 맨 위로 끌어올려져서 아래 import 보다 먼저 실행된다.
vi.mock('./client', () => ({
api: { get: vi.fn() },
}));
import { api } from './client';
import { fetchConversations } from './dm';
describe('fetchConversations — 서버 없이 돌려본다', () => {
it('봉투를 벗긴 배열이 그대로 나온다', async () => {
vi.mocked(api.get).mockResolvedValue({
data: [{ id: 3, partnerName: 'minji' }],
});
const conversations = await fetchConversations();
expect(conversations).toEqual([{ id: 3, partnerName: 'minji' }]);
});
});
세 가지가 새로 나왔습니다.
vi.mock(경로, 만드는함수) — 그 모듈을 통째로 갈아 끼웁니다. 이제부터 누가 ./client에서 api를 가져가면 진짜 대신 우리가 만든 것을 받아요. fetchConversations 안에서도 마찬가지입니다.
vi.fn() — 가짜 함수를 하나 만듭니다. 아무것도 안 하지만, 불린 기록을 전부 기억해요. Step 7에서 그 기록을 꺼내 씁니다.
vi.mocked(...).mockResolvedValue(...) — 그 가짜가 무엇을 돌려줄지 정합니다. api.get은 원래 Promise를 돌려주니까 mockResolvedValue로 "이 값으로 성공하는 Promise"를 주는 거예요. vi.mocked()로 감싸는 이유는 TypeScript에게 "이건 진짜가 아니라 가짜야"라고 알려주기 위해서입니다. 그래야 mockResolvedValue 같은 걸 쓸 수 있어요.
돌려봅시다.
npx vitest run src/api/f2-dm.test.ts
Test Files 1 passed (1)
Tests 1 passed (1)
서버를 안 띄우고 통과했습니다. localhost:8090은 지금 꺼져 있어도 되고, 커버리지가 이렇게 바뀝니다.
전: src/api/dm.ts 50 100 50 50 13-15
후: src/api/dm.ts 100 100 100 100
이 판이 진짜로 지키는 것
한 문장으로 적어보면 이래요. "서버가 봉투에 담아 보낸 것을 우리 함수가 벗겨서 돌려준다."
이건 진짜 약속입니다. C-6에서 만든 인터셉터가 봉투를 벗기고, response.data에 알맹이가 들어 있죠. 누가 그 구조를 바꾸면 이 판이 빨개집니다. Step 3에서 만들었던 판과 완전히 다른 종류예요.
💡 한 줄 정리
확인하려는 함수가 밖의 것을 부르면 그 밖의 것을 가짜로 갈아 끼운다. vi.mock이 모듈을 바꿔치고, vi.fn()이 그 안에 들어갈 가짜 함수를 만든다.
🙋 학생 질문 — "vi.mock을 import보다 위에 써야 하나요? 아래에 쓰면 어떻게 되죠?"
직접 해보시면 놀라실 거예요. 아래에 써도 통합니다.
import { api } from './client';
import { fetchConversations } from './dm';
// 일부러 import 아래에 뒀다
vi.mock('./client', () => ({ api: { get: vi.fn() } }));
이렇게 두고 돌려도 가짜가 쓰입니다. 왜냐하면 Vitest가 vi.mock 호출을 파일 맨 위로 끌어올리기 때문이에요. 우리가 어디에 쓰든 실행 순서는 항상 import보다 앞입니다.
이걸 안 해주면 vi.mock은 쓸모가 없어요. import는 파일이 실행되기 전에 이미 처리되거든요. 우리가 "이제 갈아 끼워줘"라고 말할 때쯤이면 fetchConversations는 이미 진짜 api를 손에 쥔 뒤입니다.
그래도 위에 쓰시는 걸 권합니다. 동작이 같더라도, 읽는 사람은 위에서 아래로 읽으니까요. 아래에 있으면 "여기서 갈아 끼우네" 하고 읽다가 위쪽 import를 다시 봐야 합니다.
Step 7: "가짜에게 물어봅니다"
Step 6에서 만든 가짜는 우리가 정한 값을 돌려주기만 했는데, vi.fn()은 그것 말고 하나를 더 합니다. 불린 기록을 전부 기억해요.
이게 왜 필요한지 실제 예로 보겠습니다.
돌려받은 값으로는 절대 못 잡는 것
옆에 있는 fetchMessages를 보세요.
// apps/web-spa/src/api/dm.ts
export async function fetchMessages(conversationId: number): Promise<DirectMessage[]> {
const response = await api.get<DirectMessage[]>(`/conversations/${conversationId}/messages`);
return response.data;
}
여기서 방 번호가 주소에 제대로 들어가는지를 확인하고 싶습니다. 이게 틀리면 어떻게 될까요? 7번 방에 들어갔는데 1번 방 쪽지가 보이는 것인데, 이게 바로 남의 대화가 보이는 거예요.
이걸 돌려받은 값으로 확인할 수 있을까요? 못 합니다. 가짜가 무엇을 돌려주든 우리 함수는 그걸 그대로 넘겨줄 뿐이라, 주소를 틀리게 만들어도 결과 모양은 똑같습니다.
확인하려면 가짜에게 물어봐야 합니다. "너 방금 뭐라고 불렸니?"
mock.calls
// apps/web-spa/src/api/f2-dm.test.ts
// 가짜는 불린 기록을 계속 쌓아둔다. 판마다 지우고 시작한다.
beforeEach(() => {
vi.mocked(api.get).mockReset();
vi.mocked(api.get).mockResolvedValue({ data: [] });
});
// 돌려받은 값만 봐서는 절대 못 잡는 것이 있다 — 무엇을 물었는지.
// 방 번호가 주소에 안 들어가면 남의 방 쪽지를 가져오는데,
// 가짜가 무엇을 돌려주든 결과는 똑같아 보인다.
describe('fetchMessages — 무엇을 물었는지 가짜에게 확인한다', () => {
it('방 번호가 주소에 들어간다', async () => {
await fetchMessages(7);
expect(api.get).toHaveBeenCalledWith('/conversations/7/messages');
});
it('한 번만 묻는다', async () => {
await fetchMessages(7);
expect(api.get).toHaveBeenCalledTimes(1);
});
it('방마다 다른 주소로 묻는다', async () => {
await fetchMessages(1);
await fetchMessages(2);
expect(vi.mocked(api.get).mock.calls).toEqual([
['/conversations/1/messages'],
['/conversations/2/messages'],
]);
});
});
세 가지 방법이 나왔습니다.
toHaveBeenCalledWith(...) — 이 인자로 불린 적이 있는가. 가장 자주 씁니다.
toHaveBeenCalledTimes(n) — 몇 번 불렸는가. 같은 요청을 두 번 보내고 있진 않은지 확인할 때 유용해요.
mock.calls — 기록 전체를 배열로 꺼냅니다. 바깥 배열의 한 칸이 한 번의 호출이고, 안쪽 배열이 그때 받은 인자들이에요. 순서까지 보고 싶을 때 씁니다.
beforeEach를 왜 넣었나요
vi.fn()은 기록을 계속 쌓습니다. 판이 끝나도 안 지워져요. 그래서 첫 판에서 한 번 부르고, 두 번째 판에서 또 한 번 부르면, 두 번째 판에서 toHaveBeenCalledTimes(1)이 2로 나와서 실패합니다. 우리 코드는 멀쩡한데요.
beforeEach는 각 it이 시작하기 전에 매번 도는 자리입니다. 여기서 mockReset()으로 기록을 지우고, 돌려줄 기본값을 다시 정해줍니다. 판마다 깨끗한 상태로 시작하는 거예요.
Step 4에서 samplePosts()를 함수로 둔 것과 같은 이유입니다. 판은 서로 영향을 주면 안 됩니다.
정말 잡히는지 깨뜨려봅시다
판을 썼으면 그 판이 진짜로 일하는지 확인해야 해요. 방법은 간단합니다. 일부러 망가뜨려 보는 것.
dm.ts에서 방 번호를 빼고 1로 박아둡니다.
const response = await api.get<DirectMessage[]>(`/conversations/1/messages`);
돌려보면 이렇게 나옵니다.
× 방 번호가 주소에 들어간다
× 방마다 다른 주소로 묻는다
✓ 한 번만 묻는다
✓ 봉투를 벗긴 배열이 그대로 나온다
둘은 잡았고 둘은 못 잡았는데, 못 잡은 둘이 중요해요.
"한 번만 묻는다"는 횟수가 여전히 한 번이라 통과했고, "봉투를 벗긴 배열이 나온다"도 가짜가 준 걸 그대로 돌려주니까 통과했어요.
남의 방 쪽지를 가져오는 버그인데, 횟수를 세는 판도 결과를 보는 판도 못 잡습니다. 인자를 본 판만 잡았어요.
이게 mock.calls가 필요한 이유입니다. 확인하지 않은 것은 지켜지지 않아요.
확인했으면 되돌려놓으세요.
💡 한 줄 정리
가짜 함수는 돌려줄 값을 정하는 것 말고 불린 기록을 기억하는 일을 한다. 결과만 봐서는 못 잡는 버그가 있고, 그때는 무엇을 인자로 받았는지를 봐야 한다.
🙋 학생 질문 — "그럼 모든 함수 호출을 다 확인하는 게 좋은 건가요?"
아니에요. 오히려 과하게 하면 판이 나빠집니다.
지금 우리가 확인한 건 "방 번호가 주소에 들어간다"였어요. 이건 밖에서 보이는 약속입니다. 7번 방을 열면 7번 방 쪽지가 와야 하고, 안 그러면 사용자가 남의 대화를 봐요. 그런데 이런 걸 확인하기 시작하면 곤란해집니다.
api.get을 부르기 전에console.log를 부르는가- 내부 변수 이름이
response인가 await를 몇 번 쓰는가
이건 어떻게 만들었는지예요. 약속이 아니라 구현입니다. 이런 걸 판에 박아두면, 나중에 누가 코드를 더 좋게 고쳤을 때 동작은 그대로인데 판이 빨개진다면, 사람들은 판을 믿지 않게 되고 결국 판을 고쳐서 통과시켜요. 그 순간 판은 아무것도 안 지키게 됩니다.
기준은 이겁니다. "이게 바뀌면 사용자가 뭔가 다른 걸 겪게 되나?" 방 번호는 예 입니다. 내부 변수 이름은 아니오 고요.
이 구분은 다음 시간에 화면을 확인할 때 훨씬 더 중요해집니다.
Step 8: "고치는 동안 계속 돕니다"
지금까지 우리는 판을 고칠 때마다 이렇게 돌렸어요.
npx vitest run src/lib/f2-likes.test.ts
고치고, 돌리고, 결과 보고, 다시 고치고, 다시 돌리고. 하루에 수십 번 하게 됩니다.
run을 빼보세요.
npx vitest src/lib/f2-likes.test.ts
DEV v4.1.10
✓ src/lib/f2-likes.test.ts (6 tests) 3ms
Test Files 1 passed (1)
Tests 6 passed (6)
Duration 465ms
PASS Waiting for file changes...
press h to show help, press q to quit
안 끝납니다. 판을 한 번 돌리고 그 자리에서 기다려요. Waiting for file changes라고 적혀 있죠.
이 상태로 두고 코드를 고치면 저장하는 순간 알아서 다시 돕니다.
소스를 고쳐도 돕니다
여기가 중요해요. 다시 도는 조건이 "테스트 파일을 고쳤을 때"가 아닙니다.
src/lib/likes.ts를 열어서 더하기를 하나 늘려보세요.
target.likeCount = target.liked ? target.likeCount + 2 : target.likeCount - 1;
테스트 파일은 손도 안 댔는데 화면이 이렇게 바뀝니다.
- 1240
+ 1241
❯ src/lib/f2-likes.test.ts:37:31
35| const next = toggleLikeInPlace(posts, 1);
36|
37| expect(next[0].likeCount).toBe(1240);
| ^
38| expect(next[0].liked).toBe(false);
Test Files 1 failed (1)
Tests 2 failed | 4 passed (6)
Duration 75ms
FAIL Tests failed. Watching for file changes...
Vitest는 어떤 판이 어떤 파일에 기대고 있는지 알고 있어서, likes.ts가 바뀌면 그걸 쓰는 판만 다시 돌리는데, 걸린 시간을 보면 첫 실행이 465ms 였는데 이번엔 75ms입니다. 6배 넘게 빨라요. 이미 켜둔 판을 그대로 두고 바뀐 것만 다시 돌리기 때문입니다.
되돌려 저장하면 바로 다시 초록이 됩니다.
Test Files 1 passed (1)
Tests 6 passed (6)
Duration 71ms
PASS Waiting for file changes...
나갈 때는 q를 누르세요.
이게 왜 일하는 방식을 바꾸나요
오늘 Step 4를 떠올려보세요. 판을 하나 쓰고, 돌리고, 87.5% 인 걸 보고, 두 개를 더 쓰고, 다시 돌렸습니다. 그때마다 명령을 다시 쳤어요.
watch를 켜두면 그 왕복이 사라집니다. 편집기에서 코드를 고치고 저장하면 옆 창에서 결과가 바로 바뀌어요. 브라우저를 열어놓고 화면을 고치는 것과 같은 느낌이 됩니다. 그래서 실무에서는 대개 이렇게 나눠 씁니다.
개발하는 동안 npx vitest watch 로 켜두고 계속
올리기 전 확인 npm test 전부 한 번 돌리고 끝
우리가 지금까지 쓴 npm test는 vitest run이라 한 번 돌고 끝나요. 그게 맞습니다. 자동으로 도는 검사에서 watch로 들어가면 영원히 안 끝나니까요.
⚠️ 터미널을 어떤 도구 안에서 쓰고 계신지에 따라
npx vitest가 watch로 안 들어갈 수 있어요. Vitest는 사람이 직접 치는 터미널인지를 보고 기본값을 정합니다. watch로 안 들어가면npx vitest --watch로 직접 켜세요.
💡 한 줄 정리
run 없이 부르면 watch로 들어가서 파일이 바뀔 때마다 다시 돈다. 테스트 파일뿐 아니라 그 판이 기대는 소스가 바뀌어도 돌고, 두 번째부터는 훨씬 빠르다.
🙋 학생 질문 — "watch를 켜두면 954개가 매번 다 도나요? 느리지 않을까요?"
안 돕니다. 그게 watch의 핵심이에요.
Vitest는 파일이 바뀌면 그 파일에 기대고 있는 판만 골라서 돌립니다. likes.ts를 고치면 그걸 쓰는 판만 돌아요. 방금 우리가 본 75ms 가 그 결과이고, 954개를 다 돌리면 20초쯤 걸리기 때문에 실무에서는 대개 이렇게 씁니다.
- 파일 하나만 지정해서 켜기 — 지금 만지고 있는 곳에 집중할 때. 우리가 방금 한 방식이에요.
- 전부 켜기 (
npx vitest) — 폭넓게 고칠 때. 어차피 관련된 것만 돌아가니까 생각보다 안 느립니다.
watch로 켜둔 상태에서 h를 눌러보면 도움말이 뜨는데, 거기서 "실패한 것만 다시 돌리기" 같은 것도 고를 수 있습니다. 빨간 게 여러 개일 때 하나씩 잡을 때 좋아요.
마무리
오늘 우리는 커버리지 표가 가리킨 네 곳으로 실제로 가봤습니다. 그리고 네 곳에서 네 가지 다른 일을 했어요.
아무도 안 부른다 ─ 대체된 코드 ─ 지웠다 → 100%
갈래가 안 덮였다 ─ 타입이 막는다 ─ 놔뒀다 → 66.66% 그대로
갈래가 안 덮였다 ─ 안 물어봤다 ─ 판을 썼다 → 100%
아무도 안 부른다 ─ 아직 안 썼다 ─ 갈아 끼웠다 → 100%
전체 숫자는 82.83% 에서 82.97% 로 움직였습니다. 0.14 포인트예요. 오늘 한 일에 비하면 초라한 숫자지만, 그 안에서 실제로 일어난 일은 이렇습니다. 죽은 코드 12줄이 사라졌고, 아무도 안 물어봤던 갈래 세 개에 판이 생겼고, 서버 없이 확인할 수 있는 API 함수가 하나 늘었고, 숫자만 올리는 판 하나를 일부러 안 만들었습니다.
숫자는 오늘 한 일을 잘 요약하지 못합니다. 그게 오늘의 진짜 결론이에요.
오늘 배운 핵심 세 가지
💡 하나 — 안 덮인 곳을 만나면 "이게 아직 필요한가"를 먼저 묻는다. fetchPost와 fetchConversations는 표에서 똑같이 생겼지만 하나는 지우고 하나는 판을 썼습니다. 그 차이는 숫자가 아니라 코드가 왜 거기 있는지에서 나왔어요.
💡 둘 — describe·it·expect로 판을 쓰고, toBe와 toEqual을 골라 쓴다. 같은 물건인지 같은 내용인지는 다른 질문이고, React가 보는 것은 앞쪽인데, 구멍은 한 겹이 아닐 수 있어서 메운 뒤에 숫자를 다시 보는 것까지가 한 판이에요.
💡 셋 — 확인하려는 함수가 밖의 것을 부르면 vi.mock으로 갈아 끼운다. 가짜는 돌려줄 값을 정하는 것뿐 아니라 불린 기록을 기억하는데, 결과만 봐서는 못 잡는 버그가 있고 그때는 무엇을 물었는지를 봐야 해요.
다음 시간 예고
오늘 우리가 판을 쓴 것들은 전부 화면이 없는 코드였는데, 배열을 넣으면 배열이 나오고 주소를 물으면 데이터가 오는 것들이었죠.
그런데 우리 앱의 대부분은 화면이에요. 하트를 누르면 빨개지고, 댓글을 쓰면 목록에 붙고, 없는 사람을 찾으면 안내 문구가 뜹니다. 이런 걸 확인하려면 화면을 그려놓고 눌러봐야 합니다.
다음 시간에는 그걸 하는데, 시작하기 전에 곤란한 질문이 하나 있어요. 버튼을 어떻게 찾을 것인가.
우리는 지금까지 코드를 알고 있으니까 "그 컴포넌트의 그 버튼"이라고 말할 수 있었지만, 그렇게 찾으면 안 되는 이유가 있습니다. 오늘 마지막 질문에서 "어떻게 만들었는지"를 판에 박으면 안 된다고 했던 것, 그게 화면에서는 훨씬 더 크게 문제가 돼요.
사용자는 우리 코드를 모릅니다. 사용자는 "좋아요 라고 적힌 버튼"을 누르죠. 다음 시간에는 그 관점에서 화면을 확인하는 방법을 배웁니다.
과제
[구현] 남은 구멍 하나를 직접 찾아서 메우세요
오늘 우리는 src/lib/likes.ts를 100% 로 만들었습니다. 그런데 같은 일이 한 겹 위에서도 벌어지고 있어요.
src/lib/feed-state.ts의 커버리지를 확인해보세요. 그리고 안 덮인 줄을 열어보세요.
- 안 덮인 줄 중 하나는 오늘
toggleLikeInPlace에서 본 것과 똑같은 종류의 구멍입니다. 어느 줄이고 왜 안 덮였나요? - 안 덮인 줄 중 다른 하나는 오늘 Step 3에서 본 것과 똑같은 종류예요. 어느 줄인가요?
- 1번에 판을 쓰세요.
src/lib/f2-feed-state.test.ts를 만들고,describe·it·expect로 확인합니다. - 판을 쓴 뒤 커버리지를 다시 재세요. 100% 가 됐나요? 안 됐다면 무엇이 남았고, 그건 어느 종류인가요?
마지막으로 답해보세요. 이 파일은 100% 가 될 수 있나요?
[탐구] 판이 정말 일하는지 깨뜨려서 확인하세요
Step 7에서 우리는 dm.ts를 일부러 망가뜨려서 어떤 판이 잡고 어떤 판이 못 잡는지 봤습니다. 같은 것을 오늘 쓴 다른 판에도 해보세요.
src/lib/likes.ts를 아래 세 가지로 각각 망가뜨리고, 매번 몇 개의 판이 빨개지는지 적으세요. 한 번에 하나씩만 바꾸고, 확인한 뒤 되돌립니다.
if (target)을if (true)로 바꾼다target.likeCount + 1을target.likeCount + 2로 바꾼다toggleLike의.map(...)을 통째로return posts;로 바꾼다
마지막으로 답해보세요. 3번에서 빨개진 판은 몇 개인가요? 그 개수를 보고 무엇을 알 수 있나요? 만약 오늘 Step 5를 안 썼다면 3번이 잡혔을까요?
[탐구] 오늘 안 나온 세 번째 경우를 찾으세요
오늘 우리는 "아무도 안 부르는 것"을 두 가지로 갈랐습니다. 대체된 것은 지웠고, 아직 안 쓴 것은 판을 썼어요. 그런데 저장소에는 어느 쪽도 아닌 것이 있습니다.
- 커버리지 표에서
% Stmts가 낮은 파일을 훑어보세요.main.tsx는 빼고 고르되, 왜 빼야 하는지도 답에 적으세요. src/components/ui/card.tsx를 여세요. 이 파일이 밖으로 내보내는 것이 몇 개인가요?- 그중
ui폴더 밖에서 아무도 안 쓰는 것이 몇 개인지 세어보세요.grep으로 하나씩 세면 됩니다. - 그것들은 오늘 본
fetchPost와 똑같이 생겼습니다. 내보내고 있는데 아무도 안 불러요. 지워도 될까요?
마지막으로 답해보세요. 이 파일은 원래 어디서 왔나요? E-5에서 무슨 명령을 쳤는지 떠올려보세요. 지워도 당장은 아무것도 안 깨지는데, 그래도 지우면 안 되는 이유가 뭘까요?
[구현] watch를 켜두고 판을 먼저 써보세요
지금까지 우리는 코드를 먼저 보고 판을 나중에 썼습니다. 이번엔 순서를 바꿔보세요.
src/lib/collections.ts의 removeById에 대해, 코드를 안 보고 판부터 씁니다.
npx vitest src/lib/f2-remove.test.ts로 watch를 먼저 켜세요. 파일이 없다고 나올 텐데 괜찮습니다.- 파일을 만들고 판을 하나 쓰세요. 이름은 "없는 번호를 주면 원래 목록이 그대로 나온다"로 합니다. 어떤 결과를 기대하는지 여러분이 먼저 정하세요.
- 저장하는 순간 watch가 돕니다. 통과했나요, 실패했나요?
src/lib/collections.ts를 열어보세요. 여러분이 기대한 것과 실제 동작이 같았나요?
마지막으로 답해보세요. 3번에서 실패했다면, 틀린 것은 코드였을까요 여러분의 기대였을까요? 그걸 어떻게 판단하시겠어요?
생각해볼 주제
1. 지울지 남길지 어떻게 정할까
오늘 우리는 fetchPost를 지웠고 fetchConversations는 남겼습니다. 둘 다 아무도 안 부르는 함수였는데요.
판단 근거는 "하나는 대체됐고 하나는 아직 안 썼다" 였어요. 그런데 그걸 알려면 C-6에서 무슨 일이 있었는지, 백엔드에 어떤 주소가 있는지를 알아야 합니다. 새로 들어온 사람은 그걸 모릅니다.
여러분이 이 저장소에 처음 들어왔다고 해봅시다. 두 함수를 앞에 두고 무엇을 근거로 판단하시겠어요? 팀에 이런 판단을 매번 사람 기억에 의존하지 않게 하려면 무엇을 남겨둬야 할까요?
2. 판이 없는 것과 판이 나쁜 것
오늘 Step 3에서 우리는 커버리지를 100% 로 만들 수 있는 판을 만들었다가 버렸습니다. 그 판은 통과했고, 숫자를 올렸고, 아무것도 안 지켰어요. 그런데 이런 판이 저장소에 이미 들어와 있다면 어떻게 찾을까요? 통과하고 있으니 빨간불로는 안 보입니다. 커버리지로도 안 보여요. 오히려 커버리지는 그 판 덕분에 더 좋아 보이죠.
오늘 과제에서 우리가 한 것 중에 힌트가 하나 있습니다. 코드를 일부러 망가뜨렸을 때 안 빨개지는 판. 이걸 사람이 손으로 하지 않고 자동으로 할 수 있을까요? 그런 도구가 있다면 무엇을 하는 도구여야 할까요?
3. 무엇을 확인하고 무엇을 확인하지 않을까
Step 7에서 우리는 api.get이 어떤 주소로 불렸는지를 확인했는데, 마지막 질문에서 "내부 변수 이름 같은 건 확인하면 안 된다" 고도 했어요.
둘의 경계는 어디일까요?
조금 더 어려운 예를 하나 드릴게요. fetchMessages가 api.get을 정확히 한 번 부르는 것을 확인하는 판이 있습니다. 어느 날 누가 성능을 위해 캐시를 넣어서, 같은 방을 두 번 물으면 두 번째는 요청을 안 보내게 고쳤어요. 판이 빨개집니다.
이 판은 좋은 판이었을까요, 나쁜 판이었을까요? 이런 일을 미리 피하려면 판을 쓸 때 무엇을 기준으로 삼아야 할까요?
✅ 예시 답안정답 보기
과제 4개(각 25점, 합 100점)와 생각해볼 주제 3개의 예시답안입니다.
🎯 [과제 1 예시답안] 남은 구멍 하나를 직접 찾아서 메우세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
안 덮인 줄 세 개(50·70-71)를 찾아냈다 |
5 | 커버리지 표에서 feed-state.ts 줄 |
50이 없는 번호 갈래임을 밝혔다 |
5 | if (!target) return state |
70-71이 never 완전성 검사임을 밝혔다 |
5 | Step 3과 같은 종류 |
판을 써서 50을 덮었다 |
5 | describe·it·expect 사용 |
| 이 파일은 100% 가 될 수 없다고 답했다 | 5 | "된다" 고 답하면 0점 |
| 합계 | 25 |
풀이 예시
먼저 커버리지에서 그 줄을 확인합니다.
src/lib/feed-state.ts 78.57 75 100 76.92 50,70-71
세 줄을 열어보면 두 종류예요.
// apps/web-spa/src/lib/feed-state.ts
case 'toggleLike': {
const target = findById(state.posts, action.id);
if (!target) {
return state; // ← 50번 줄. 오늘 Step 4 와 같은 종류
}
default: {
// 위에서 빠뜨린 일이 있으면 이 줄에서 잡힌다
const missed: never = action; // ← 70-71번 줄. Step 3 과 같은 종류
return missed;
}
50번 줄은 없는 번호를 아무도 안 물어본 자리입니다. toggleLikeInPlace와 똑같아요. 한 겹 위에서 같은 질문이 또 안 물어진 겁니다.
판을 씁니다.
// apps/web-spa/src/lib/f2-feed-state.test.ts
import { describe, it, expect } from 'vitest';
import { createFeedState, feedReducer } from './feed-state';
import { feedPosts } from '../data/feed';
import type { Post } from '../types/instagram';
function samplePosts(): Post[] {
return feedPosts.map((post) => ({ ...post }));
}
describe('feedReducer — 없는 번호로 좋아요를 누르면', () => {
it('상태가 그대로 나온다', () => {
const state = createFeedState(samplePosts());
const next = feedReducer(state, { type: 'toggleLike', id: 999 });
expect(next).toBe(state);
});
it('알림도 안 생긴다', () => {
const state = createFeedState(samplePosts());
const next = feedReducer(state, { type: 'toggleLike', id: 999 });
expect(next.toast).toBeNull();
});
});
여기서 toBe를 쓴 게 중요합니다. return state는 받은 것을 그대로 돌려주는 코드예요. toEqual로 쓰면 리듀서가 내용만 같은 새 객체를 만들어 돌려줘도 통과하고, React는 "바뀌었다" 고 보고 화면을 다시 그리죠. 오늘 Step 5에서 본 그대로예요.
다시 재보면
전: src/lib/feed-state.ts 78.57 75 100 76.92 50,70-71
후: src/lib/feed-state.ts 85.71 87.5 100 84.61 70-71
50번 줄이 사라졌고 70-71이 남았습니다.
이 파일은 100% 가 될 수 있나
안 됩니다. 그래도 됩니다.
남은 70-71은 오늘 Step 3에서 본 것과 완전히 같은 자리예요. FeedAction은 세 종류뿐이고 위에서 셋 다 처리했으니, default:에 도달하려면 타입 검사를 속여야 합니다. 속여서 덮으면 숫자는 100이 되지만 그 판이 지키는 것은 없어요.
그러니 이 파일의 정직한 최종 상태는 85.71 / 87.5 / 100 / 84.61입니다.
한 파일 안에 오늘 배운 두 결론이 나란히 들어 있는 셈이에요. 하나는 메우는 게 맞고, 하나는 두는 게 맞습니다.
자주 나오는 실수
toBe대신toEqual을 쓴다. 통과는 합니다. 그런데 리듀서가 새 객체를 만들어도 안 잡혀요. "그대로 돌려준다"를 확인하려면toBe여야 합니다.70-71도 덮으려고 캐스팅한다. 100% 가 되지만 Step 3에서 버린 판을 다시 만든 거예요.samplePosts()없이feedPosts를 그대로 넘긴다. 이 과제에서는 리듀서가 원본을 안 고쳐서 티가 안 나지만, 습관으로 굳으면 다음에 물립니다.
💡 튜터의 한마디
이 과제의 진짜 목적은 "판 하나 더 써보기"가 아니에요. 오늘 배운 두 판단을 같은 파일에서 동시에 해보는 것입니다.
실무에서 커버리지 표를 보면 대개 이렇게 생겼어요. 한 파일에 안 덮인 줄이 서너 개인데, 그중 하나는 메워야 하고 하나는 두는 게 맞고 하나는 아예 코드를 지워야 합니다. 줄 번호만 보고 일괄로 판을 쓰기 시작하면 그 구분이 사라져요.
표는 줄 번호를 주고, 판단은 파일을 열어서 합니다.
🎯 [과제 2 예시답안] 판이 정말 일하는지 깨뜨려서 확인하세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 세 변형을 각각 하나씩만 적용하고 되돌렸다 | 4 | 한꺼번에 바꾸면 숫자가 섞인다 |
| 변형 1 에서 빨개진 판의 수와 위치를 적었다 | 5 | 3개 · 전부 오늘 쓴 판 |
| 변형 2 에서 빨개진 판의 수를 적었다 | 4 | 2개 · 전부 오늘 쓴 판 |
| 변형 3 의 개수가 앞의 둘과 크게 다름을 밝혔다 | 6 | 35개 · 12개 파일 |
| 그 차이의 이유를 "얼마나 널리 쓰이는가" 로 설명했다 | 6 | 개수만 적으면 3점 |
| 합계 | 25 |
풀이 예시
한 번에 하나씩만 바꾸고, 매번 npm test로 전체를 돌린 뒤 되돌립니다.
변형 1 — if (target)을 if (true)로
Tests 3 failed | 963 passed (966)
FAIL src/lib/f2-likes.test.ts > toggleLikeInPlace — 없는 번호를 줬을 때 > 숫자도 하트도 그대로다
FAIL src/lib/f2-likes.test.ts > 없는 번호에서 두 함수가 갈리는 곳 > toggleLikeInPlace 는 받은 배열을 그대로 돌려준다
FAIL src/lib/f2-likes.test.ts > 없는 번호에서 두 함수가 갈리는 곳 > 안에 든 게시물은 두 함수 모두 그대로 쓴다
세 개 전부 오늘 우리가 쓴 판입니다. target이 undefined인데 target.liked를 읽으려 해서 터져요.
변형 2 — + 1을 + 2로
Tests 2 failed | 964 passed (966)
FAIL src/lib/f2-likes.test.ts > ... > 있는 번호를 주면 바뀐다 — 없을 때와 갈리는 것을 함께 본다
FAIL src/lib/f2-likes.test.ts > ... > 이미 눌린 것을 다시 누르면 숫자가 내려간다
이것도 전부 오늘 쓴 판입니다.
변형 3 — toggleLike의 .map(...)을 return posts;로
Tests 35 failed | 931 passed (966)
35개이고 12개 파일에 흩어져 있는데, 목록을 보면 이렇습니다.
c6-optimistic 낙관적 업데이트
c6-rollback 실패 롤백
c5-stale-server-state 서버 값으로 되돌아오기
a4-typing 더블클릭 좋아요
b2-lifting 상태 끌어올리기
b4-toast 탭 제목
e7-visual-polish 하트 아이콘
...
이 차이가 말하는 것
세 변형 다 likes.ts를 망가뜨렸는데 결과가 완전히 달라요.
변형 1 (없는 번호 갈래) 3개 ← 오늘 쓴 판만
변형 2 (더하기 계산) 2개 ← 오늘 쓴 판만
변형 3 (toggleLike 통째) 35개 ← 앱 전체
이유는 그 코드가 얼마나 널리 쓰이는가입니다.
toggleLike는 피드·상세·낙관적 업데이트·롤백이 전부 기대는 함수라서, 망가뜨리면 앱의 절반이 무너지는데, 이미 33개의 판이 그걸 지키고 있었어요. 오늘 우리가 판을 안 썼어도 잡혔을 겁니다.
반대로 변형 1·2는 오늘 전이었다면 0개가 잡았습니다. 아무도 안 가보던 갈래니까요.
만약 Step 5를 안 썼다면 3번이 잡혔을까
네, 아주 많이 잡혔을 겁니다. 33개가요.
이 질문의 답이 "아니오" 일 거라고 예상하셨다면, 그게 오늘 얻어갈 것입니다. 새 판의 값어치는 그 코드가 얼마나 중요한가가 아니라, 그 갈래를 이미 누가 지키고 있는가에서 나옵니다.
toggleLike는 앱에서 가장 중요한 함수 축에 들지만 이미 33겹으로 덮여 있었어요. 오늘 우리가 판을 쓸 값어치가 있었던 곳은 아무도 안 가보던 가장자리였습니다.
자주 나오는 실수
- 세 변형을 한꺼번에 적용한다. 그러면 어느 변형이 어느 판을 깼는지 못 가릅니다.
- 되돌리는 것을 잊는다. 다음 과제가 전부 빨간불에서 시작해요.
- 개수만 적고 위치를 안 본다. 3개와 35개의 차이는 개수가 아니라 어디에 흩어져 있는가에 있습니다.
💡 튜터의 한마디
판을 쓰고 나서 "이 판이 진짜 일하나"를 확인하는 습관은 생각보다 드뭅니다. 초록불을 보면 다 된 것 같거든요.
초록불은 두 가지 뜻이 있는데, "지켜지고 있다"와 "아무것도 안 보고 있다"입니다. 둘을 가르는 유일한 방법이 일부러 망가뜨려보는 겁니다.
오늘 Step 7에서 dm.ts를 깨뜨렸을 때 판 넷 중 둘만 잡았죠. 만약 그걸 안 해봤다면 우리는 "네 개의 판이 이 함수를 지킨다" 고 믿었을 거예요. 실제로는 방 번호를 지키는 판이 둘뿐이었습니다.
판을 믿기 전에 한 번은 깨뜨려보세요.
🎯 [과제 3 예시답안] 오늘 안 나온 세 번째 경우를 찾으세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
main.tsx를 빼는 이유를 설명했다 |
4 | 앱 시작점이라 판이 부르지 않는다 |
card.tsx가 내보내는 것을 세었다 |
4 | 7개 |
ui 밖에서 안 쓰는 것을 찾아냈다 |
5 | CardTitle·CardDescription·CardAction |
| 이 파일이 shadcn 명령으로 들어왔음을 밝혔다 | 6 | E-5 회수 |
| 지우면 안 되는 이유를 댔다 | 6 | "안 깨진다" 만 적으면 2점 |
| 합계 | 25 |
풀이 예시
커버리지에서 낮은 순으로 보면 이렇습니다.
main.tsx 0 0 0 0
ui/avatar.tsx 50.74 50.98 50 52.94 55-96
ui/card.tsx 57.69 57.62 57.14 60 38-70
ui/dialog.tsx 73.63 63.41 80 85.71 22-23,84-90,101
main.tsx를 빼는 이유부터요. 이건 앱의 시작점입니다. createRoot로 화면을 띄우고 라우터를 세우는 파일이에요. 판이 이걸 import 하면 판을 돌릴 때마다 앱이 통째로 뜹니다. 그래서 아무 판도 안 부르고, 0% 가 정상이에요. 여기에 판을 쓰는 것은 앱을 켜보는 것이지 함수를 확인하는 게 아닙니다.
ui/card.tsx를 열어봅니다. 내보내는 게 일곱 개예요.
Card CardHeader CardTitle CardDescription CardAction CardContent CardFooter
ui 폴더 밖에서 쓰는 곳을 세어보면요.
Card 7개 파일
CardHeader 2개
CardContent 2개
CardFooter 1개
CardTitle 0개 ←
CardDescription 0개 ←
CardAction 0개 ←
세 개는 아무도 안 씁니다. 내보내고 있는데 부르는 곳이 없어요. fetchPost와 똑같이 생겼습니다.
그런데 지우면 안 됩니다
이 파일이 어디서 왔는지 떠올려보세요. E-5에서 우리가 친 명령이 만들어준 파일입니다.
npx shadcn@latest add card
우리가 한 줄씩 쓴 게 아니에요. 라이브러리가 만들어 놓은 컴포넌트 묶음을 소스째 받아서 우리 저장소에 둔 것이라서, 우리 것이면서 동시에 우리 것이 아니에요.
지우면 안 되는 이유가 셋입니다.
첫째, 다음에 그 명령을 또 치면 돌아옵니다. E-6에서 봤듯이 add를 다시 돌리면 그 파일을 덮어써요. 지운 세 개가 그대로 살아 돌아옵니다. 지우는 일이 영구적이지 않은 거예요.
둘째, 하나의 완결된 묶음입니다. Card를 쓰면서 제목 자리가 필요해지면 CardTitle을 씁니다. 지금 안 쓴다고 해서 앞으로도 안 쓸 게 아니에요. fetchPost는 대체돼서 다시 쓸 일이 없었지만, 이건 아직 차례가 안 온 것뿐입니다.
셋째, 우리가 만든 게 아니라서 판단 근거가 약합니다. fetchPost는 우리가 C-2에서 만들고 C-6에서 대체한 것이라 이력을 다 알아요. 이 세 개는 라이브러리 설계자가 왜 넣었는지 우리가 모릅니다.
그래서 세 번째 경우
오늘 배운 것에 하나가 더 붙습니다.
대체됐다 → 지운다 (fetchPost)
아직 안 썼다 → 판을 쓴다 (fetchConversations)
들여온 것이다 → 그대로 둔다 (CardTitle 등)
세 번째는 판도 안 씁니다. 우리 약속이 아니라 라이브러리의 약속이라, 우리가 판으로 지킬 것이 없어요. 우리가 지킬 것은 그걸 가져다 쓰는 우리 코드입니다.
자주 나오는 실수
- "안 깨지니까 지워도 된다"로 끝낸다. 안 깨지는 건 맞아요. 그런데 그게 지워도 되는 이유는 아닙니다.
fetchConversations도 안 깨졌죠. - 판을 써서 커버리지를 올리려 한다.
CardTitle을 그려보는 판을 쓰면 숫자는 오릅니다. 그런데 그건 라이브러리가 잘 동작하는지 확인하는 거예요. 우리 일이 아닙니다. main.tsx에 판을 쓰려 한다. 0% 가 눈에 밟혀서 시도하게 되는데, 그건 앱 켜기지 확인이 아닙니다.
💡 튜터의 한마디
"안 쓰는 코드는 지운다"는 좋은 규칙이지만, 오늘 우리는 그 규칙이 세 번 중 한 번만 맞는다는 걸 봤습니다.
실무에서 이 판단을 빠르게 하는 질문이 하나 있어요. "이걸 내가 썼나?"
내가 썼고 대체됐으면 지웁니다. 내가 썼고 아직 안 붙였으면 판을 씁니다. 내가 안 썼으면 대체로 놔둡니다. 남이 만든 것의 일부를 우리 판단으로 뜯어내면, 그 라이브러리를 다시 받는 순간 우리가 한 일이 사라지거든요.
🎯 [과제 4 예시답안] watch를 켜두고 판을 먼저 써보세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 코드를 보기 전에 판을 먼저 썼다 | 5 | 순서가 뒤바뀌면 이 과제의 의미가 없다 |
| watch가 파일 생성을 감지해 돌았다 | 4 | 없던 파일을 만들어도 잡는다 |
| 통과/실패 결과를 기록했다 | 4 | 어느 매처를 썼는지에 따라 갈린다 |
| 코드를 열어 실제 동작과 대조했다 | 5 | .filter()를 확인 |
| 틀린 것이 코드도 기대도 아닌 「매처 선택」임을 밝혔다 | 7 | "코드가 틀렸다" 고 답하면 0점 |
| 합계 | 25 |
풀이 예시
watch부터 켭니다. 아직 없는 파일을 지정해도 괜찮아요.
npx vitest src/lib/f2-remove.test.ts
No test files found, exiting with code 0
라고 나오거나 기다림 상태로 들어갑니다. 이 상태에서 파일을 만들면 바로 잡아요.
지금부터 코드를 안 보고 판을 씁니다. "없는 번호를 주면 원래 목록이 그대로 나온다"를 어떻게 적으시겠어요? 여기서 두 갈래가 생깁니다.
// apps/web-spa/src/lib/f2-remove.test.ts
import { describe, it, expect } from 'vitest';
import { removeById } from './collections';
const comments = [
{ id: 1, content: '첫째' },
{ id: 2, content: '둘째' },
];
describe('removeById — 없는 번호를 주면', () => {
it('원래 목록이 그대로 나온다', () => {
expect(removeById(comments, 999)).toBe(comments); // 갈래 A
// expect(removeById(comments, 999)).toEqual(comments); 갈래 B
});
});
갈래 A(toBe)는 실패합니다. 갈래 B(toEqual)는 통과해요.
코드를 열어보면
// apps/web-spa/src/lib/collections.ts
export function removeById<T extends { id: number }>(items: T[], id: number): T[] {
return items.filter((item) => item.id !== id);
}
.filter()입니다. .filter()는 하나도 안 걸러내도 항상 새 배열을 만드는데, 없는 번호를 줘도 "내용이 같은 다른 배열"이 나옵니다.
오늘 Step 5에서 toggleLike가 .map() 때문에 그랬던 것과 완전히 같은 일이에요. .map()도 .filter()도 원본을 안 건드리는 대신 항상 새것을 만듭니다.
틀린 것은 무엇이었나
여기가 이 과제의 핵심입니다. toBe로 썼다가 실패했다면, 틀린 것은 셋 중 무엇일까요?
- 코드가 틀렸나? 아닙니다.
.filter()로 새 배열을 만드는 건 의도된 동작이에요. 원본을 안 건드리려면 그래야 합니다. - 기대가 틀렸나? 이것도 아니에요. "원래 목록이 그대로 나온다"는 맞는 기대입니다. 내용이 그대로 나오니까요.
- 매처 선택이 틀렸습니다. "그대로 나온다"를 "같은 배열이다"로 번역했는데, 실제 뜻은 "같은 내용이다"였어요.
한국어의 "그대로"가 두 가지를 다 뜻해서 생긴 일인데, 이게 toBe와 toEqual이 따로 있는 이유예요. 말로는 안 갈리는 것을 코드로는 갈라야 합니다.
그럼 어떻게 판단하나
이 함수가 같은 배열을 돌려주는 것이 약속의 일부인지를 물으면 됩니다.
removeById는 "원본을 안 건드린다"가 약속이에요. 그러려면 새 배열을 만들어야 하죠. 그러니 toBe로 확인하면 약속과 반대되는 것을 확인하는 셈입니다.
반대로 오늘 Step 5의 toggleLikeInPlace는 "받은 배열을 그대로 준다"가 특징이었어요. 거기서는 toBe가 맞았습니다.
자주 나오는 실수
- 코드를 먼저 열어본다.
.filter()를 보고toEqual을 쓰게 되는데, 이 과제에서 배울 게 사라집니다. 틀려보는 게 목적이에요. - 실패하자마자 코드를 고치려 한다. 실무에서 가장 위험한 반응입니다. 판이 빨개졌을 때 코드가 틀렸는지 판이 틀렸는지를 먼저 가려야 해요.
toEqual로 바꿔서 통과시키고 끝낸다. 통과는 하는데 왜 그런지 모르면 다음에 또 만납니다.
💡 튜터의 한마디
이 과제가 판을 먼저 쓰게 한 이유가 있어요. 코드를 보고 판을 쓰면 코드를 베끼게 됩니다.
.filter()를 보고 나면 "아, 새 배열이 나오는구나" 하고 toEqual을 씁니다. 판이 통과해요. 그 판은 코드가 하는 일을 그대로 옮겨 적은 것이지, 그 코드가 무엇을 약속하는지를 적은 게 아닙니다. 코드가 나중에 틀리게 바뀌면 판도 같이 바뀌겠죠.
먼저 쓰면 순서가 뒤집힙니다. 무엇이어야 하는가를 먼저 정하고, 코드가 거기 맞는지 봐요. 오늘처럼 어긋나면 그 자리에서 "내 기대가 틀렸나, 코드가 틀렸나"를 묻게 되고, 그 질문이 이 함수를 훨씬 깊이 이해하게 만듭니다.
🤔 [생각해볼 주제 1] 지울지 남길지 어떻게 정할까
문제 상황 요약
오늘 우리는 표에서 똑같이 생긴 두 함수를 정반대로 처리했습니다. fetchPost는 지웠고 fetchConversations는 판을 썼어요.
근거는 "하나는 대체됐고 하나는 아직 안 썼다" 였는데, 그걸 알려면 C-6에서 무슨 일이 있었는지, 백엔드에 어떤 주소가 있는지를 알아야 해요. 새로 들어온 사람은 그 맥락이 없습니다.
튜터의 가이드 및 해설
먼저 인정할 것 — 이 판단은 코드만 봐서는 못 합니다.
두 함수를 나란히 놓고 보면 정말 구분이 안 돼요. 둘 다 export 되어 있고, 둘 다 아무도 안 부르고, 둘 다 문법적으로 멀쩡합니다. 차이는 코드 밖에 있어요.
새로 들어온 사람이 쓸 수 있는 단서가 세 가지 있습니다.
첫째, git 이력입니다.
git log --oneline -- src/data/feed.ts
fetchPost가 언제 들어왔고 그 뒤에 api/posts.ts가 언제 생겼는지가 나옵니다. "이 함수가 만들어진 뒤에 같은 일을 하는 다른 함수가 생겼다"는 강한 신호예요. 실제로 fetchPost의 주석에는 "진짜 요청으로 바뀌는 것은 C-6 다"라고 적혀 있었고, C-6는 이미 지났습니다. 주석이 스스로 시한부라고 말하고 있었던 거예요.
둘째, 이웃입니다.
fetchConversations는 혼자 있지 않았어요. 바로 위에 fetchMessages가 있고, 그건 쓰이고 있습니다. 같은 파일에서 같은 모양으로 같은 서버를 부르는 함수 둘 중 하나만 안 쓰인다면, "대체됐다"보다 "아직 안 붙였다" 쪽이 훨씬 그럴듯해요.
반면 fetchPost의 이웃은 데이터 배열뿐이었습니다. 파일 성격 자체가 옛날 것이었죠.
셋째, 바깥 세계입니다.
fetchConversations가 부르는 /conversations 주소가 백엔드에 실제로 있나요? 있다면 그건 누군가 그 API를 만들었다는 뜻이고, 프런트가 아직 안 붙인 것뿐입니다. 없다면 이야기가 달라져요.
그럼 무엇을 남겨둬야 하나
가장 실질적인 답은 코드가 스스로 시한을 말하게 하는 것입니다.
fetchPost의 주석은 사실 잘 쓰인 편이었어요. "C-6에서 바뀐다" 고 적어뒀으니까요. 문제는 그 시점이 왔을 때 아무도 안 돌아봤다는 것이라서, 팀에서 쓸 수 있는 방법이 몇 가지 있습니다.
- 대체할 때 지우는 것을 같은 작업에 넣습니다. 새 것을 만드는 커밋과 옛 것을 지우는 커밋이 붙어 있으면 잊을 일이 없어요. 나중으로 미루면 맥락이 사라집니다.
- 아직 안 붙인 것에는 그렇다고 적습니다.
fetchConversations위에 "대화 목록 화면은 아직 없다" 한 줄이 있었다면 오늘 우리가 5분을 아꼈을 거예요. - 커버리지를 주기적으로 봅니다. 오늘 이 둘을 찾아준 게 커버리지였어요. 안 켰으면 3,000줄 안에 그대로 묻혀 있었을 겁니다.
마지막으로 하나. 애매하면 지우는 쪽이 대체로 나은데, git에 남아 있으니까요. 지우는 비용은 "필요하면 되살리기"이고, 남기는 비용은 "모두가 계속 이게 뭔지 궁금해하기"인데, 후자가 매일 발생해요.
🎯 면접관을 홀리는 핵심 멘트
"안 쓰는 코드를 만나면 저는 먼저
git log로 이웃을 봅니다. 같은 일을 하는 새 코드가 그 뒤에 생겼다면 대체된 거라 지우고, 옆에 같은 모양의 형제가 쓰이고 있다면 아직 안 붙인 거라 테스트를 씁니다. 코드만 보면 둘이 똑같이 생겼거든요. 그리고 대체할 때는 옛것을 지우는 걸 같은 작업에 넣습니다. 나중으로 미루면 그 맥락을 아는 사람이 사라져요."
🤔 [생각해볼 주제 2] 판이 없는 것과 판이 나쁜 것
문제 상황 요약
Step 3에서 우리는 커버리지를 100% 로 만드는 판을 만들었다가 버렸습니다. 그 판은 통과했고, 숫자를 올렸고, 아무것도 안 지켰어요.
이런 판이 이미 저장소에 들어와 있다면 어떻게 찾을까요? 통과하고 있으니 빨간불로는 안 보입니다. 커버리지로도 안 보여요. 오히려 커버리지는 그 판 덕분에 더 좋아 보입니다.
튜터의 가이드 및 해설
커버리지가 왜 이걸 못 찾는지부터 짚고 갑시다.
커버리지가 재는 것은 딱 하나인데, 판이 도는 동안 그 줄을 지나갔는가입니다. 지나갔으면 덮인 것이고, 그 판이 무엇을 확인했는지는 안 봐요.
극단적으로, expect를 한 줄도 안 쓴 판도 커버리지를 올립니다.
it('아무것도 확인 안 함', () => {
toggleLike(samplePosts(), 1);
});
이 판은 통과하고, toggleLike의 모든 줄을 덮습니다. 지키는 건 "터지지 않는다" 뿐이에요. 지난 시간에 "아무것도 비교하지 않는 테스트로도 숫자는 오른다" 고 했던 게 이겁니다.
그럼 어떻게 찾나 — 과제 2에 답이 있습니다.
우리가 오늘 한 것을 떠올려보세요. 코드를 일부러 망가뜨리고, 빨개지는 판을 셌습니다.
toggleLike를 망가뜨렸더니 35개가 빨개졌어요. 그 35개는 진짜로 일하고 있는 판입니다. 반대로 아무리 망가뜨려도 안 빨개지는 판이 있다면, 그 판은 그 코드를 안 지키고 있는 거예요.
이걸 뒤집으면 이런 도구가 됩니다.
1. 코드 한 곳을 자동으로 살짝 바꾼다 (+ 를 - 로, > 를 >= 로, true 를 false 로)
2. 전체 판을 돌린다
3. 아무도 안 빨개지면 → 그 자리는 아무도 안 지키고 있다
4. 되돌리고, 다음 자리로
이걸 코드의 모든 자리에 대해 자동으로 반복하는 도구가 실제로 있습니다. 바꾼 코드를 "변이"라고 불러서 변이 테스트라고 해요.
커버리지와 무엇이 다른가
커버리지 이 줄을 지나갔는가 → 지나가기만 하면 100%
변이 테스트 이 줄을 바꿨을 때 누가 알아채는가 → 알아채야 100%
Step 3에서 만들었다 버린 판을 이 도구에 넣으면 바로 걸립니다. never 갈래의 return missed를 무엇으로 바꿔도 그 판은 안 빨개지거든요. 덮여 있는데 아무도 안 지키는 상태가 그대로 드러납니다.
그런데 왜 다들 안 쓸까요
정직하게 말하면 비쌉니다. 코드의 모든 자리를 하나씩 바꿔가며 매번 전체 판을 돌려야 해요. 우리 저장소는 한 번 도는 데 20초쯤 걸리는데, 바꿀 자리가 1,000군데면 5시간이 넘습니다.
그래서 실무에서는 대개 이렇게 씁니다.
- 바뀐 파일에만 돌립니다. 전체가 아니라 이번에 손댄 곳만요.
- 가끔 돌립니다. 매번이 아니라 주 단위로요.
- 가장 중요한 부분에만 돌립니다. 결제나 인증처럼 틀리면 큰일 나는 곳이요.
손으로 하는 것도 충분히 값어치가 있는데, 오늘 우리가 Step 7에서 한 게 그거예요. 판을 쓴 직후에 그 코드를 한 번 망가뜨려 보는 것. 30초 걸리고, 그 판이 진짜인지 아닌지를 그 자리에서 알려줍니다.
🎯 면접관을 홀리는 핵심 멘트
"커버리지는 그 줄을 지나갔는지만 보고, 무엇을 확인했는지는 안 봅니다. 그래서
expect가 하나도 없는 테스트도 커버리지를 올려요. 통과하는데 아무것도 안 지키는 테스트를 찾으려면 반대로 물어야 합니다. 이 코드를 망가뜨렸을 때 누가 알아채는가. 그걸 자동화한 게 변이 테스트고, 비싸서 전체에 돌리진 못하더라도 테스트를 새로 쓸 때 한 번 손으로 깨뜨려보는 것만으로 대부분 걸러집니다."
🤔 [생각해볼 주제 3] 무엇을 확인하고 무엇을 확인하지 않을까
문제 상황 요약
Step 7에서 우리는 api.get이 어떤 주소로 불렸는지를 확인했습니다. 그런데 같은 Step에서 "내부 변수 이름 같은 건 확인하면 안 된다" 고도 했어요.
둘의 경계는 어디일까요?
구체적인 상황을 하나 놓고 봅시다. fetchMessages가 api.get을 정확히 한 번 부르는 것을 확인하는 판이 있습니다. 어느 날 누가 성능을 위해 캐시를 넣어서, 같은 방을 두 번 물으면 두 번째는 요청을 안 보내게 고쳤어요. 판이 빨개집니다.
이 판은 좋은 판이었을까요, 나쁜 판이었을까요?
튜터의 가이드 및 해설
먼저 이 질문에 하나의 정답이 없다는 걸 말씀드릴게요. 그리고 정답이 없다는 게 이 주제의 요점입니다.
경계를 가르는 질문은 이겁니다. "이게 바뀌면 누가 알아채는가."
사용자가 알아챈다 → 확인할 값어치가 있다
옆 팀 개발자가 알아챈다 → 대체로 확인할 값어치가 있다
나만 알아챈다 → 확인하면 안 된다
fetchMessages가 7번 방을 물을 때 /conversations/7/messages로 가는 것. 이게 틀리면 사용자가 남의 대화를 봅니다. 확실히 확인해야 해요.
내부 변수 이름이 response인지 res인지. 아무도 안 알아챕니다. 확인하면 안 돼요.
그럼 호출 횟수는 어느 쪽인가
여기가 애매합니다. 그리고 애매한 이유가 있어요. 호출 횟수는 상황에 따라 양쪽 다 될 수 있습니다.
호출 횟수가 사용자가 알아채는 것인 경우가 있어요.
- 결제 요청을 두 번 보내면 두 번 결제됩니다. 이건 반드시 확인해야 해요.
- 알림을 두 번 보내면 사용자 폰이 두 번 울립니다.
- C-6에서 봤던 인터셉터의 토큰 갱신이요. 동시에 다섯 요청이 401을 받았을 때 갱신을 다섯 번 하면 안 됐죠. 그래서 그때 판을 썼습니다.
반대로 호출 횟수가 그냥 구현인 경우도 있어요.
- 읽기 요청을 몇 번 보내든 결과는 같습니다. 한 번이든 두 번이든 사용자는 같은 화면을 봐요.
fetchMessages는 읽기입니다. 그러니 문제의 판은 나쁜 쪽에 가깝습니다.
"정확히 한 번"을 약속으로 박아둔 순간, 그 함수는 앞으로 캐시를 못 넣게 됐어요. 성능을 개선했는데 판이 빨개지고, 그럼 사람들은 판을 고쳐서 통과시킵니다. 그때쯤이면 그 판을 왜 썼는지 아무도 기억 못 해요.
그런데 완전히 나쁘다고 하기도 어렵습니다
만약 그 판이 이런 이름이었다면 어떨까요?
it('한 번만 묻는다') ← 무엇을 지키는지 모르겠다
it('같은 방을 두 번 그리면 요청이 두 배가 된다') ← 성능 회귀를 지킨다
두 번째라면 의도가 분명합니다. "요청이 늘어나는 걸 막고 싶다"는 거예요. 그렇다면 캐시를 넣어서 요청이 줄었을 때 빨개지는 건 이상하죠. 판을 "두 배가 되지 않는다"로 썼어야 합니다.
즉 문제는 호출 횟수를 본 것 자체가 아니라 정확한 숫자로 못 박은 것이에요. toHaveBeenCalledTimes(1) 대신 "늘어나지 않는다"를 표현했다면 캐시가 들어와도 안 깨졌을 겁니다.
실무에서 쓰는 기준
판을 쓰기 전에 이 문장을 완성해보세요.
"이게 깨지면 ___ 가 곤란해진다."
빈칸에 "사용자"가 들어가면 확인합니다. "나"가 들어가면 안 합니다. "나중에 이 코드를 고칠 사람"이 들어가면 판이 아니라 주석을 쓰는 게 맞을 수도 있어요.
오늘 우리가 쓴 판들을 이 문장에 넣어보면 이렇습니다.
- 방 번호가 주소에 들어간다 → 안 되면 사용자가 남의 대화를 본다
- 없는 번호를 주면 안 바뀐다 → 안 되면 사용자 화면이 헛돈다
- 봉투를 벗겨서 돌려준다 → 안 되면 화면이 통째로 안 뜬다
셋 다 사용자예요. 그래서 오늘 쓴 판들은 오래갈 겁니다.
🎯 면접관을 홀리는 핵심 멘트
"테스트를 쓰기 전에 저는 이 문장을 완성해봅니다. 이게 깨지면 누가 곤란해지는가. 사용자면 씁니다. 나만 곤란하면 그건 구현을 박아두는 거라 안 씁니다. 호출 횟수처럼 애매한 건 상황에 따라 갈려요. 결제 요청이 두 번 나가면 사용자 돈이 두 번 빠지니까 반드시 확인하고, 읽기 요청이면 몇 번이든 화면은 같으니까 확인하면 오히려 나중에 캐시를 못 넣게 됩니다. 그리고 그럴 때도 정확한 숫자가 아니라 늘어나지 않는다는 쪽으로 씁니다."