테스팅 강좌 #2 Vitest 셋업과 첫 단위 테스트 — describe,it,expect
#1 에서 그림을 잡았다면 이번 글은 손을 움직이는 첫 단계입니다. 도구는 Vitest로 갑니다.
테스팅 강좌에서 이번 글의 위치:
- #1 왜 테스트인가
- #2 Vitest 셋업과 첫 단위 테스트 — describe,it,expect ← 이번 글
- #3 React Testing Library — 컴포넌트 테스트
- #4 비동기와 네트워크 모킹 — MSW
- #5 사용자 이벤트와 폼 테스트
- #6 Playwright로 E2E와 CI 통합
이번 글은 순수 함수 한 개부터 시작합니다. 컴포넌트 / React / DOM은 #3 부터.
Vitest vs Jest — 한 단락 #
Vitest는 Vite 위에서 동작하는 테스트 러너. API가 Jest와 거의 동일해서 마이그레이션이 쉽고, ESM/TypeScript가 셋업 없이 바로 통합됩니다. Vite 빌드를 공유해서 첫 실행이 빠르고, watch 모드가 정말로 즉각적입니다. Jest가 강한 영역(거대한 단일 monorepo, jsdom의 미세 호환)은 여전히 있지만, 새 프로젝트에서 Vitest를 못 고를 이유는 거의 없습니다.
이 시리즈는 Vitest 기준으로 갑니다. Jest 사용자는 import 경로(vi 대신 jest)와 vitest.config.ts 부분만 다르고 나머지는 같습니다.
빈 프로젝트로 시작 #
Vite + React + TS 프로젝트에 Vitest를 붙이는 게 가장 흔한 출발점.
pnpm create vite my-app --template react-ts
cd my-app
pnpm install
pnpm add -D vitest @vitest/ui@vitest/ui는 watch 모드를 브라우저로 보여주는 부가 패키지 — 처음에는 한 번 켜보면 좋습니다. 강요는 아닙니다.
package.json에 스크립트 추가:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
}
}스크립트의 의미:
vitest— watch 모드 (기본). 파일이 바뀔 때마다 영향받은 테스트만 다시 실행.vitest run— 한 번만 실행 후 종료. CI에서 쓰는 명령입니다.vitest --ui— 브라우저 UI.vitest run --coverage— 커버리지 리포트. 별도 패키지 필요 (아래).
vitest.config.ts — 작게 시작
#
Vite와 같은 설정 파일을 쓸 수 있지만, 테스트 전용으로 분리하는 게 깔끔합니다.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true, // describe/it/expect 를 import 없이 쓸 수 있게
environment: 'jsdom', // DOM API 가 필요하면 (#3 부터 필수)
setupFiles: ['./vitest.setup.ts'],
},
});옵션 의미:
globals: true—describe,it,expect를 매 파일에서 import 하지 않아도 됨. Jest와 같은 모양을 다룹니다.environment: 'jsdom'— 컴포넌트 테스트가 들어가면 필요. 순수 함수만 테스트한다면'node'가 더 빠름.setupFiles— 모든 테스트 시작 전 한 번만 도는 셋업 코드. matcher 확장이나 글로벌 모킹을 여기.
vitest.setup.ts는 일단 비워둡니다. #3 에서 @testing-library/jest-dom을 추가할 때 채워요.
globals: true를 쓰려면 TS가 글로벌 타입을 알아야 합니다. tsconfig.json에 한 줄 추가.
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}가장 단순한 첫 테스트 #
테스트할 대상부터 짭니다. 슬러그 만드는 함수.
export function slugify(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣\s-]/g, '')
.replace(/\s+/g, '-');
}테스트:
import { slugify } from './slugify';
describe('slugify', () => {
it('공백을 하이픈으로 바꾼다', () => {
expect(slugify('hello world')).toBe('hello-world');
});
it('대문자를 소문자로 바꾼다', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('특수문자를 제거한다', () => {
expect(slugify('hello, world!')).toBe('hello-world');
});
it('한글은 보존한다', () => {
expect(slugify('안녕 하세요')).toBe('안녕-하세요');
});
it('앞뒤 공백을 제거한다', () => {
expect(slugify(' hello ')).toBe('hello');
});
});pnpm test를 띄우면:
✓ src/lib/slugify.test.ts (5)
✓ slugify (5)
✓ 공백을 하이픈으로 바꾼다
✓ 대문자를 소문자로 바꾼다
✓ 특수문자를 제거한다
✓ 한글은 보존한다
✓ 앞뒤 공백을 제거한다
Test Files 1 passed (1)
Tests 5 passed (5)describe / it / expect의 의미
#
세 개념이 거의 모든 테스트의 골격입니다.
describe(name, fn)— 테스트들을 묶는 그룹. 보통 한 모듈 / 한 함수 / 한 컴포넌트 단위입니다. 중첩할 수 있습니다.it(name, fn)— 한 개의 테스트 케이스. 이름은 동작 을 한 문장으로. (test로도 같은 동작.)expect(value).matcher(expected)— 검증. 매처(matcher)는toBe,toEqual,toContain,toThrow등 수십 종.
이름의 컨벤션이 사실 진지합니다. 테스트 이름이 문서가 됩니다.
// X — 무엇을 테스트하는지 알기 어려움
it('test 1', ...)
it('slugify works', ...)
// O — 동작을 한 문장으로
it('공백을 하이픈으로 바꾼다', ...)
it('빈 문자열에 빈 문자열을 반환한다', ...)뒤의 형식은 테스트가 깨졌을 때 콘솔만 봐도 무엇이 망가졌는지 알 수 있습니다.
자주 쓰는 매처 #
expect의 역할입니다. 외울 필요는 없고, 패턴으로 익히면 됩니다.
// 동등 비교
expect(2 + 2).toBe(4); // === 비교 (primitive)
expect({ a: 1 }).toEqual({ a: 1 }); // 깊은 비교 (객체/배열)
expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }); // 부분 일치
// 진위
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// 숫자
expect(0.1 + 0.2).toBeCloseTo(0.3); // 부동소수 비교
expect(value).toBeGreaterThan(5);
// 문자열
expect('hello world').toContain('world');
expect('hello').toMatch(/^h/);
// 배열
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
// 함수가 던지는 에러
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('expected message');
expect(() => fn()).toThrow(MyError);
// 부정
expect(value).not.toBe(false);toBe와 toEqual의 차이가 가장 자주 헷갈리는 부분입니다.
expect({ a: 1 }).toBe({ a: 1 }); // X — 다른 객체이므로 실패
expect({ a: 1 }).toEqual({ a: 1 }); // O — 깊은 동등성
expect(2).toBe(2); // O — primitive 는 그냥 toBe
watch 모드 — 진짜 즉각적 #
pnpm test (= vitest) 로 띄우면 watch 모드. 파일을 저장할 때마다 영향받은 테스트만 다시 실행합니다.
키 단축키:
a — 모든 테스트 다시 실행
f — 실패한 테스트만 다시 실행
p — 파일 패턴으로 필터
t — 테스트 이름 패턴으로 필터
q — 종료큰 프로젝트에서 진짜 강력한 기능입니다. 깨진 테스트가 5개라면 f로 그 5개만 watch — 고치는 동안 다른 테스트가 도느라 노이즈가 안 생겨요.
UI 모드(pnpm test:ui) 는 같은 걸 브라우저로 보여줍니다. 한 번 띄워보세요.
pnpm test:ui
# http://localhost:51204/__vitest__/ 같은 주소가 열림첫 실패와 첫 통과 — TDD 한 사이클 #
새 함수를 짤 때 흔한 흐름:
import { capitalize } from './capitalize'; // 아직 없음
it('첫 글자를 대문자로 만든다', () => {
expect(capitalize('hello')).toBe('Hello');
});× capitalize > 첫 글자를 대문자로 만든다
Cannot find module './capitalize'// src/lib/capitalize.ts
export function capitalize(s: string): string {
return s[0].toUpperCase() + s.slice(1);
}✓ capitalize > 첫 글자를 대문자로 만든다it('빈 문자열에 빈 문자열을 반환한다', () => {
expect(capitalize('')).toBe('');
});export function capitalize(s: string): string {
if (!s) return s;
return s[0].toUpperCase() + s.slice(1);
}이 사이클 — 빨강 → 초록 → 리팩터 — 이 TDD의 골격입니다. 강요는 아니지만, 작은 함수에서 한 번 경험해 두면 감이 잡힙니다.
setup / teardown — beforeEach / afterEach
#
테스트 사이에 상태를 초기화하거나 정리해야 할 때.
describe('Counter', () => {
let counter: Counter;
beforeEach(() => {
counter = new Counter();
});
it('초기값은 0', () => {
expect(counter.value).toBe(0);
});
it('increment 후 1', () => {
counter.increment();
expect(counter.value).toBe(1);
});
});각 it가 독립적으로 도는 게 중요합니다 — 이전 테스트의 상태가 다음에 새지 않게. beforeEach가 매 테스트 직전 새로 초기화.
beforeAll / afterAll도 있습니다 (한 describe 시작/끝에 한 번만). 비싼 셋업(예: 임시 디렉터리 생성) 에 적합. 단, 테스트 간 격리가 깨지기 쉬워서 가능하면 beforeEach 우선.
expect.assertions — 비동기에서 잘못 빠지는 함정
#
비동기 테스트는 #4 에서 깊게 다루지만, 한 가지 함정만 미리.
it('비동기 검증', async () => {
try {
await fetchData();
} catch (e) {
expect(e.message).toBe('expected');
}
// fetchData 가 안 던지면 catch 가 안 도는데, 테스트는 통과
});해결:
it('비동기 검증', async () => {
expect.assertions(1); // 정확히 1번 expect 가 도는 게 보장돼야 함
try {
await fetchData();
} catch (e) {
expect(e.message).toBe('expected');
}
});expect.assertions(N)은 “테스트 끝나는 시점에 N 번의 expect가 호출됐어야 한다” 를 강제합니다. catch가 안 돌면 0 번 — 테스트가 실패.
또는 더 깔끔하게 .rejects / .resolves 매처:
it('비동기 검증', async () => {
await expect(fetchData()).rejects.toThrow('expected');
});모킹 — vi.fn() 첫 만남
#
모킹은 #4 의 본격 주제지만, 가장 단순한 것만 미리.
import { vi } from 'vitest';
it('콜백이 호출됐는지 검증', () => {
const callback = vi.fn();
forEach([1, 2, 3], callback);
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledWith(1);
expect(callback).toHaveBeenCalledWith(2);
expect(callback).toHaveBeenCalledWith(3);
});vi.fn()은 “감시되는 가짜 함수”. 호출됐는지, 몇 번, 어떤 인자로 — 모두 추적됩니다. 반환값을 정의할 수도 있습니다.
const fetchUser = vi.fn().mockReturnValue({ id: 1, name: 'Alice' });
expect(fetchUser()).toEqual({ id: 1, name: 'Alice' });
// 비동기
const fetchUser = vi.fn().mockResolvedValue({ id: 1 });
expect(await fetchUser()).toEqual({ id: 1 });커버리지 — 켜는 법 #
@vitest/coverage-v8 한 패키지면 충분합니다.
pnpm add -D @vitest/coverage-v8vitest.config.ts에 coverage 옵션:
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
exclude: ['node_modules/', 'src/test/', 'src/**/*.test.{ts,tsx}'],
},
},
});pnpm coverage
# 콘솔에 표 + coverage/index.html 생성
open coverage/index.html브라우저로 열면 어떤 줄이 안 도는지 색으로 구분됩니다. 숫자에 매달리지 말고 (#1 의 교훈) 의도하지 않게 빠진 부분만 보세요.
흔한 함정 #
테스트가 import 단계에서 실패 — vitest.config.ts의 environment 설정 확인. DOM API (window, document)를 쓰는 코드를 'node' 환경에서 import 하면 즉시 실패.
describe/it가 not defined — globals: true 빠뜨림 또는 tsconfig.json의 types 미설정. 또는 명시적으로 import { describe, it, expect } from 'vitest'.
watch 모드가 멈춰 있음 — Docker / WSL / 일부 파일시스템에서 fs 변경 감지가 안 될 때. vitest --watch --watchExclude 또는 polling 옵션으로 해결.
테스트가 서로 영향을 준다 — 모듈 레벨 변수에 mutate 한 흔적. beforeEach로 초기화하거나, 모듈 레벨 mutable state 자체를 줄이세요.
ESM 패키지 import 에러 — 일부 라이브러리는 deps optimization이 필요. vitest.config.ts의 optimizeDeps 설정으로 해결 (Vite와 같은 옵션).
정리 #
- Vitest 셋업의 핵심은
vitest.config.ts한 파일 —globals: true+environment: 'jsdom'+setupFiles셋만 잡으면 됨. describe/it/expect가 모든 테스트의 골격. 이름은 동작을 한 문장 으로 — 테스트 이름이 문서가 됨.toBe는 primitive와 참조 동등성,toEqual은 깊은 동등성. 객체/배열은 거의 항상toEqual.- watch 모드의
f(실패 테스트만 다시) 는 큰 프로젝트에서 매우 강력한 기능입니다. vi.fn()으로 콜백 / 의존성 모킹.mockReturnValue/mockResolvedValue로 반환값 정의.- 비동기 테스트의
expect누락 함정은expect.assertions(N)또는.rejects/.resolves매처로. - 커버리지는 v8 provider 한 줄로 켜짐. 숫자가 아니라 빠진 부분만 보기.
다음 글(#3 React Testing Library)에서는 컴포넌트로. RTL의 철학(“사용자처럼 본다”), render / screen / queries (getByRole, getByLabelText) 의 쓰임을 한 컴포넌트씩 익힙니다.