Vitest 와 테스트의 결
Vitest 와 테스트의 결
Vitest 는 Vite 생태계에서 자라난 테스트 프레임워크입니다. Jest 와 결을 비슷하게 두고 ESM · TypeScript · 워치 모드를 더 가볍게 다룹니다.
1. Vitest 에 대한 이야기
Vite 팀 (Anthony Fu 외) 이 2021 년에 시작했고 1.0 이 2023 년에 공개됐습니다. 이후 활발히 변화 중입니다. 사이트는 vitest.dev, 라이선스는 MIT.
특징적인 자리:
- Vite 의 빠른 ESM·HMR 인프라 위에 동작.
- TypeScript 가 추가 설정 없이 동작.
- Jest 와 거의 호환되는 API (
describe·it·test·expect). - 워치 모드의 빠른 피드백.
- 같은 프로세스에서 여러 모듈을 동시 테스트.
- 브라우저 모드·in-source testing 같은 추가 기능.
2. Jest 와의 비교
Jest 는 Christopher Pojer · Meta 가 2014 년에 공개했습니다. 한 시기 React 진영의 사실상 표준 자리였습니다. CommonJS 출발이라 ESM 에서 추가 설정이 필요한 자리가 있고, Vite 환경과의 결합에 마찰이 있는 편입니다. 그래서 Vite 기반 프론트엔드 진영이 Vitest 로 옮겨가는 흐름이 있습니다.
대부분의 단언·매처 (toBe · toEqual · toHaveBeenCalled 등) 는 두 프레임워크에서 그대로 동작합니다.
3. 동시성과 격리
Vitest 는 워커 (스레드 또는 프로세스) 를 띄우고 테스트 파일을 분배합니다. 파일 단위 격리가 기본입니다.
[main] vitest CLI
├── worker-1 (a.test.ts)
├── worker-2 (b.test.ts)
└── worker-3 (c.test.ts)
테스트 안의 describe · test 는 같은 워커에서 순차 실행되지만 옵션에 따라 병렬도 가능합니다. 격리가 약해지면 테스트 간 간섭이 발생할 수 있습니다.
4. 워치 모드와 인소스 테스팅
vitest 또는 vitest --watch 로 시작하면 변경된 파일 + 그 파일에 의존하는 테스트만 다시 돕니다. 큰 코드베이스에서도 즉시 피드백이 가능한 자리입니다.
소스 파일 안에 if (import.meta.vitest) 블록으로 작은 테스트를 두는 옵션 (in-source testing) 도 있습니다. 작은 유틸 함수의 사용 예를 곁에 두는 자리에 어울립니다. 파일이 커지면 별도 테스트 파일로 옮기는 모양이 일반적입니다.
5. 모킹·커버리지
vi.fn() · vi.spyOn() · vi.mock() 의 모양이 Jest 와 비슷합니다. ESM 모킹은 CommonJS 보다 까다로운 자리가 있습니다 (자동 호이스팅 등). 도구별 가이드를 따릅니다. expect.poll · expect.assertions(n) 같은 비동기 보조가 있습니다.
커버리지는 v8 또는 istanbul 두 가지 백엔드를 옵션으로 가집니다. v8 은 빠르고 istanbul 은 보고가 풍부한 자리입니다. 임계값 (thresholds) 을 설정해 회귀를 잡습니다.
6. 다른 후보들
| 도구 | 결 |
|---|---|
| Jest | Meta · 2014. CommonJS 우선. |
| Vitest | Vite 팀 · 2021~. ESM · TypeScript 친화. |
| Mocha | OpenJS · 2011. 매처는 보통 chai 와 결합. |
| AVA | 2015. 병렬 우선. |
Node 내장 node:test |
2022 부터 표준 라이브러리. 가벼움. |
| Bun · Deno 내장 | 자체 테스트 러너 보유. |
E2E·통합 도구는 Playwright (Microsoft, 2020) · Cypress (2017) · Testing Library (Kent C. Dodds 등, 2018) · Storybook + Test runner. Vitest 는 단위·통합 자리, Playwright 는 브라우저 E2E 자리가 자연스럽습니다.
7. 빠른 시작
import { describe, it, expect } from "vitest";
import { sum } from "./sum";
describe("sum", () => {
it("두 수를 더한다", () => {
expect(sum(1, 2)).toBe(3);
});
});
설정 파일 (vitest.config.ts) 은 Vite 설정과 통합됩니다. 별도 빌드·트랜스파일 설정이 거의 없는 자리입니다.
React · Vue 컴포넌트는 @testing-library/{react,vue} 와의 결합이 잘 알려져 있습니다.
import { render, screen } from "@testing-library/react";
import Counter from "./Counter";
it("버튼을 누르면 1 증가한다", async () => {
render(<Counter />);
await userEvent.click(screen.getByRole("button"));
expect(screen.getByText("1")).toBeInTheDocument();
});
8. 단위 vs 통합 vs E2E
- 단위 (unit) — 외부 의존이 없는 작은 함수·로직.
- 통합 (integration) — 모듈 합쳐 동작 검증. DB 가 필요하면 Testcontainers · in-memory.
- E2E — 브라우저·실제 환경. 별도 도구.
같은 폴더에 섞으면 피드백 시간이 늘어납니다. 디렉터리 또는 파일명 컨벤션으로 분리합니다.
9. 테스트의 비용
테스트는 자유롭게 더 추가할수록 좋은 자리가 아닙니다. 다음 비용이 함께 자랍니다.
- 유지보수 — 코드 변경 시 테스트도 함께 고칩니다. 잘못 짠 테스트는 변경을 가로막습니다.
- 실행 시간 — 통합·E2E 가 늘면 CI 가 길어집니다.
- 거짓 신호 — 의도와 어긋난 테스트는 회귀 알람을 흐립니다.
- 결합도 — 구현 세부에 묶인 테스트는 리팩터를 막습니다.
10. 테스트는 사양이라는 시각
테스트 코드는 "이 함수는 이렇게 동작해야 한다" 의 실행 가능한 사양입니다. 사양으로 읽히도록 이름·구조를 짭니다. 매처보다 테스트 이름이 먼저입니다.
it("빈 배열은 0 을 반환한다", () => {});
it("음수가 포함되면 무시한다", () => {});
기능 단위 (behavior) 테스트는 외부에서 본 행동을 검증합니다. 리팩터에 강합니다. 단위 단위 (structure) 테스트는 내부 함수·메서드 단위입니다. 정밀하지만 결합도가 높습니다.
기능 단위 비중을 키우고 단위 단위는 위험·복잡 함수에 한정하는 모양이 일반적입니다.
11. 어디에 더 둘까
가치가 높은 자리:
- 결제·과금·인증·데이터 손실 같은 비가역 자리.
- 도메인 핵심 로직 (가격 계산·권한·상태 머신).
- 회귀가 자주 났던 자리.
가벼운 표현 컴포넌트의 픽셀 일치 검증보다 도메인 로직의 경계 케이스가 더 가치가 있다는 평이 다수입니다.
12. 자주 걸리는 자리
눈에 띄는 커버리지 100% — 행 커버리지가 의미 있는 회귀 보호와 동치는 아닙니다.
Jest → Vitest 이주 — API 가 비슷하지만 ESM 모킹·타이머·환경 차이로 일부 테스트가 깨집니다.
테스트 환경 차이 — jsdom · happy-dom · 브라우저 모드의 차이. DOM API 일부가 누락될 수 있습니다.
시간·랜덤 의존 — Date.now · Math.random 직접 사용 테스트는 깨집니다. vi.useFakeTimers() · 의존성 주입.
공유 상태 — 모듈 최상위에서 만든 객체가 테스트끼리 공유돼 간섭합니다. 격리·beforeEach 리셋.
실행 순서 의존 — 어떤 테스트가 다른 테스트를 셋업하는 의존. 무작위 순서로 돌려도 통과해야 합니다.
모킹 과잉 — 모든 의존을 모킹하면 결국 모킹의 정합성을 테스트하게 됩니다. 진짜 의존 (가능한 경우) 또는 통합 테스트.
하고픈 말
테스트는 코드 작성보다 코드 변경에 더 큰 비용이 듭니다. 변경에 강한 테스트는 인터페이스 단위로, 약한 테스트는 구현 세부 단위로 갈립니다. 사양 단위로 쓰는 습관이 가장 큰 차이를 만듭니다.
warragon 사례 — 라운드 6~9 (2026-05-04)
추상 원칙은 실제 모노레포에서 어떻게 적용되는가. 라운드 6~9 의 변경에서 실측된 사례 (commit hash + 파일 단위) 를 기록합니다.
vi.hoisted — server-action mock 의 실패 패턴
admin pryzeet/points/actions.test.ts 가 라운드 6 직후 PROD 빌드에서 깨졌습니다. 원인은 next/cache mock 의 revalidateTag 누락:
// ❌ 라운드 6 의 잔여 결함 (revalidateTag 미모킹)
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
// ✅ 라운드 7 fix (commit 87ad987a)
vi.mock('next/cache', () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn() }));
adjustPoints server-action 이 revalidateTag 를 호출하는데 mock 이 export 안 한 것. mock 시그니처가 실 모듈과 정합하지 않으면 빌드 시 발견되지 않고 런타임 첫 호출에서야 깨집니다. vi.hoisted 패턴 + 실 모듈 export 전수 모킹 의무.
sed 일괄 마이그레이션 — 51 file 동기 → 비동기
라운드 8 직후 PROD da2ari-prod 로그에서 JsonWebTokenError: invalid algorithm 이 수십 건 반복 발견됐습니다. 원인은 Supabase Cloud 가 access_token 을 ES256 (asymmetric EC) 로 발급하는데, 코드의 jwt.verify(token, JWT_SECRET) 는 HS256 만 지원이라는 것.
fix 는 134 호출처 (51 file) 의 verifyJwt(...) → await verifyJwtAsync(...) 마이그레이션 (commit aa91c142). sed 패턴:
sed -i 's/\bverifyJwt\b/verifyJwtAsync/g' "$f"
sed -i 's/\(payload\s*=\s*\)verifyJwtAsync(/\1await verifyJwtAsync(/g' "$f"
sed -i 's/^\(\s*\)verifyJwtAsync(/\1await verifyJwtAsync(/g' "$f"
이후 pnpm tsc --noEmit 으로 await 누락된 3개 파일 검출 (top-level await 금지 + Promise 가 JwtPayload 가 아님 진단). 즉, sed → tsc 검증 → fix → 재실행 의 사이클이 일괄 마이그레이션의 표준 SOP.
testcontainers 컴파일 검증 vs 실 실행 분리
da2ari-api 30 ControllerTest 신설 (라운드 8) 시 testcontainers 30 클래스가 한꺼번에 부트하면 PG 17 메모리 + 21 supabase migrations 적용으로 ~5분 소요. CI 가 무거워질 우려가 있어 컴파일 검증 (./gradlew :da2ari-api:compileTestJava) 만 PR 단계에서 의무화하고 실 실행은 nightly CI 또는 사용자 환경에 위임.
// MockMvc smoke 룰 — DB 시드 부재 시 200/401/4xx 모두 통과
private void assertRouted(int s) {
assertTrue(s >= 200 && s < 600, "라우팅 비정상 status=" + s);
}
이 룰은 200 페이로드 검증을 포기하는 대신 컨트롤러 빈 등록 + 라우팅 + 인증 필터 정합 만 보장. PROD 배포 후 실제 endpoint 가 5xx 가 아님을 보장하는 첫 게이트.
TS6133 미사용 import — Dockerfile.prod 의 tsc 차단
dmddksl idb.test.ts 가 라운드 8 후 PROD 빌드에서 TS6133: 'getUnreadBackgroundMessages' is declared but its value is never read 로 차단 (commit 205a40e4). DEV vitest 는 이 경고를 무시했지만 frontend/dmddksl/Dockerfile.prod 의 pnpm run build 가 tsc && vite build 를 수행해 미사용 import 를 ERROR 로 격상.
교훈: 테스트 파일도 tsc --noEmit 통과해야 한다. 미사용 import 를 빠르게 정리하려면 ESLint unused-imports/no-unused-imports 룰 + tsconfig.json 의 "noUnusedLocals": true. 라운드 6~9 누적 효과로 4 워크스페이스 pnpm tsc --noEmit 모두 clean.
인증 회귀 차단 — DEV/PROD 검증 명령
라운드 8-9 후 PROD 인증 정상화 검증 명령:
# Next BFF JsonWebTokenError 카운트 (배포 직후 0 기대)
docker logs da2ari-prod 2>&1 | Select-String "JsonWebTokenError" | Measure-Object
# Spring 의 unused security noise 0 기대
docker logs da2ari-api-prod 2>&1 | Select-String "inMemoryUserDetailsManager|generated security password" | Measure-Object
docker logs da2ari-api-prod 2>&1 | Select-String "JWKS init|JWT Verification Failed" | Measure-Object
세 명령 모두 Count: 0 이어야 정상. 1 이상이면 fix 회귀.
Next
- observability-minimal
- vitest-pytest-실전
Vitest 공식 · Jest 공식 · Node test runner · Testing Library · Playwright · Test Pyramid (Martin Fowler) 을 참고합니다.