Vitest 사용하면서 개념 다시 다지기
useSearch 훅에 테스트를 붙이기로 하고 Vitest를 설치하는데, 설치 명령어 한 줄에 라이브러리가 5개나 딸려 들어갔다. vitest, @testing-library/react, jsdom, @vitejs/plugin-react... 일단 예제 코드를 따라 설치하고 돌리면 되긴 했지만, 이렇게 이해 없이 넘어가면 나중에 에러가 났을 때 뭐가 문제인지 하나도 못 짚을 것 같았다. 그래서 하나씩 "이게 왜 필요하지?"를 파고들었다.
1. 모킹이란? - 테스트 대상을 격리하기 위한 도구 (단위 테스트의 본질)
테스트 코드를 보다 보니 vi.mock('react-router', ...) 같은 게 계속 나왔다. 처음엔 그냥 "가짜로 뭘 만드는 거겠지" 정도로만 이해했는데, 곱씹어보니 여기엔 중요한 판단이 하나 숨어 있었다.
내가 검증하고 싶은 건 "내 훅의 디바운싱 로직이 맞는가"지, "React Router가 URL을 잘 관리하는가"가 아니다. React Router는 이미 검증된 라이브러리인데, 이걸 같이 테스트에 끌고 들어오면 오히려 방해가 된다. 테스트가 실패했을 때 "내 코드가 잘못됐나, 라이브러리가 잘못됐나"를 매번 구분해야 하기 때문이다.
vi.mock('react-router', () => ({
useSearchParams: () => [mockSearchParams, mockSetSearchParams],
}));
이렇게 바꿔치기하면, setSearchParams가 몇 번 호출됐는지만 셀 수 있는 가짜 함수로 만들 수 있다. 그러니까 모킹은 "테스트 대상을 순수하게 격리하기 위한 도구"였다. 이걸 이해하고 나니 "왜 이 부분만 가짜로 바꿨는지"가 코드를 볼 때마다 명확해졌다.
2. VItest가 정확히 무슨 일을 해주는가 - 문법 + 실행 + 도구
모킹이 이해되고 나니 다음 궁금증은 "그럼 이 모든 걸 가능하게 하는 Vitest 자체는 뭘 해주는 도구인가"였다.
정리해보니 세 가지 역할이 합쳐진 거였다.
- 문법 제공: describe, it, expect 처럼 "이건 이래야 한다"를 코드로 선언하는 언어
- 실행기: 그 선언들을 찾아서 실제로 돌리고 통과/실패를 집계
- 도구 세트: 가짜 함수(vi.fn), 시간 조작(vi.useFakeTimers), 모듈 바꿔치기(vi.mock) 같은 유틸
이게 없으면 결국 console.log로 값 찍어보고 눈으로 확인하는 수준에 머문다는 걸 깨달았다. Vitest가 있어야 npm run test 한 번으로 자동으로, 반복 가능하게 검증할 수 있는 거였다.
3. 왜 라이브러리가 이렇게 많이 필요한가
Vitest 하나만 깔면 될 줄 알았는데 아니었다. 이유를 따져보니, Vitest는 "테스트 실행 엔진"일 뿐이고, React 코드를 테스트하려면 브라우저 환경을 흉내 내는 것과 React 전용 도구가 별도로 필요했다.
라이브러리 역할
| vitest | 테스트 실행 엔진 (본체) |
| @testing-library/react | 훅/컴포넌트를 테스트 환경에서 렌더링하는 어댑터 (renderHook, act) |
| jsdom | Node 위에 가짜 브라우저(DOM, localStorage)를 만들어줌 |
| @vitejs/plugin-react | JSX를 실행 가능한 JS로 변환 |
특히 jsdom이 왜 필요한지가 인상적이었다. 내 훅은 localStorage.getItem을 쓰는데, Node에는 원래 localStorage가 없다. 그래서 이걸 흉내 내주는 환경이 따로 있어야 테스트가 돌아간다는 걸 알고 나니, "라이브러리가 많다"가 아니라 "각자 다른 문제를 하나씩 풀어주고 있었다"는 게 보였다.
4. Vite 위에서 Vitest가 빠른 이유
여기서 원래 내가 갖고 있던 가설은 이거였다. "Vite는 최신 브라우저가 ESM(import/export)을 네이티브로 지원하니까, 개발 중엔 전체를 번들링하지 않고 필요한 모듈만 그때그때 브라우저에 넘겨준다. Vitest가 Vite에서 나온 거니까 이 방식을 그대로 물려받아서 빠른 게 아닐까?"
방향은 맞았는데, 메커니즘은 조금 달랐다. Vitest는 Node에서 도는 거라 "브라우저가 요청하는 시점에 서빙한다"는 전제 자체가 그대로 적용되진 않는다. 대신 Vitest가 진짜로 이어받는 건 두 가지였다.
1) esbuild 기반의 빠른 변환 엔진 Vite가 이미 갖고 있는 초고속 변환기(esbuild)를 그대로 써서 TS→JS, JSX→JS 변환을 처리한다. Jest처럼 Babel로 매번 무겁게 변환하는 것보다 빠르다.
2) 모듈 그래프(Module Graph) 기반의 영향 범위 계산
이 부분이 제일 흥미로웠다. "파일 하나 바꿨는데 어떻게 관련된 파일만 골라서 다시 처리하지?"가 궁금해서 더 파봤다.
원리는 생각보다 단순했다. 모든 파일의 import 문 자체가 이미 "누가 누구를 가져다 쓰는지"에 대한 지도였다.
// useSearch.ts
import { useSearchParams } from 'react-router';
import { validateSearchKeyword } from '@/shared/utils/validation';
Vite는 이 import 문을 파싱하는 순간 "useSearch.ts는 validation.ts에 의존한다"는 관계를 알아낸다. 이걸 모든 파일에 대해 하면 자연스럽게 의존 관계 그래프가 만들어지고, 각 파일마다 두 방향의 정보를 들고 있게 된다.
- importers: 나를 가져다 쓰는 파일들 (역방향)
- importedModules: 내가 가져다 쓰는 파일들 (정방향)
validation.ts를 저장하면, Vite는 이 파일의 importers를 조회해서 useSearch.ts를 찾고, 다시 그 파일의 importers를 조회해서 SearchInput.tsx를 찾는 식으로 위쪽으로 타고 올라가며 영향받는 파일을 전파한다. 더 올라갈 게 없을 때까지 반복한 다음, 그렇게 모인 파일들만 다시 변환·실행하고 나머지는 그대로 둔다. (이 부분은 아직 한 번에 안 들어온다. 여러 번 보거나 좀 더 알아보자.)
예전 번들러 방식은 "이 파일이 어디서 쓰이는지 몰라서" 안전하게 전체를 다시 훑었다면, Vite/Vitest는 이 관계를 이미 그래프로 알고 있으니까 딱 영향받는 부분만 정확히 짚어낼 수 있는 것이었다. 이게 watch 모드에서 "바뀐 파일과 관련된 테스트만 빠르게 재실행"되는 이유였다.
다만 한 가지 짚어둘 건, 이 그래프 추적은 정적으로 분석 가능한 import에 대해서만 정확하다는 점이다. vi.mock처럼 모듈을 통째로 갈아끼우는 경우는 실제 의존 관계와 살짝 어긋날 수 있어서, 필요하면 캐시를 무시하고 전체를 다시 돌릴 수 있는 옵션도 남겨둔다고 한다.
정리하며
처음엔 "일단 설치하고 예제 코드 따라 치면 되겠지" 하고 넘어갈 수도 있었다. 근데 하나씩 "이게 왜 필요한가"를 따라가다 보니, 모킹은 격리를 위한 도구였고, Vitest는 세 가지 역할이 합쳐진 도구였고, 여러 라이브러리는 각자 다른 문제(변환, 렌더링, 브라우저 환경 흉내)를 풀고 있었고, 속도의 비결은 마법이 아니라 import 문이라는 정직한 정보를 그래프로 정리해서 활용하는 것이었다.
결국 도구를 쓰기 전에 "이게 왜 이렇게 설계됐는가"를 한 번 물어보는 게, 나중에 문제가 생겼을 때 어디를 봐야 할지 아는 유일한 방법이라는 걸 다시 느꼈다.