Testing #2 — Vitest Setup and Your First Unit Test (describe / it / expect)
If #1 framed the picture, this post is where your hands start moving. The tool is Vitest.
Where this post sits in the Testing series:
- #1 Why Test?
- #2 Vitest Setup and Your First Unit Test — describe,it,expect ← this post
- #3 React Testing Library — Component Tests
- #4 Async and Network Mocking — MSW
- #5 User Events and Form Tests
- #6 E2E with Playwright and CI Integration
This post starts from a single pure function. Components / React / DOM begin in #3.
Vitest vs Jest — One Paragraph #
Vitest is a test runner that runs on top of Vite. Its API is nearly identical to Jest, so migration is easy, and ESM / TypeScript are integrated out of the box with no setup. It shares the Vite build, so the first run is fast and watch mode is genuinely instant. There are still places where Jest is strong (huge single monorepos, fine-grained jsdom compatibility), but for a new project there’s almost no reason not to pick Vitest.
This series uses Vitest. Jest users will only differ on the import path (jest instead of vi) and the vitest.config.ts part — everything else is the same.
Starting from an Empty Project #
Adding Vitest to a Vite + React + TS project is the most common starting point.
pnpm create vite my-app --template react-ts
cd my-app
pnpm install
pnpm add -D vitest @vitest/ui@vitest/ui is an optional package that shows watch mode in the browser — try it once at the start. It’s not required.
Add scripts to 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"
}
}What each script means:
vitest— watch mode (default). Re-runs only the affected tests when files change.vitest run— runs once and exits. The CI slot.vitest --ui— browser UI.vitest run --coverage— coverage report. Needs an extra package (below).
vitest.config.ts — Start Small
#
You can use the same config file as Vite, but separating a test-only config is cleaner.
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'],
},
});What the options mean:
globals: true— lets you usedescribe,it, andexpectwithout importing them in every file. Same shape as Jest.environment: 'jsdom'— needed once component tests come in. If you only test pure functions,'node'is faster.setupFiles— setup code that runs once before all tests. Put matcher extensions or global mocks here.
Leave vitest.setup.ts empty for now. We’ll fill it in #3 when we add @testing-library/jest-dom.
To use globals: true, TypeScript needs to know about the global types. Add one line to tsconfig.json.
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}The Simplest First Test #
Start with the thing you’ll test. A function that builds a slug.
export function slugify(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣\s-]/g, '')
.replace(/\s+/g, '-');
}The test:
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');
});
});Run pnpm test:
✓ src/lib/slugify.test.ts (5)
✓ slugify (5)
✓ 공백을 하이픈으로 바꾼다
✓ 대문자를 소문자로 바꾼다
✓ 특수문자를 제거한다
✓ 한글은 보존한다
✓ 앞뒤 공백을 제거한다
Test Files 1 passed (1)
Tests 5 passed (5)What describe / it / expect Mean
#
These three concepts form the skeleton of almost every test.
describe(name, fn)— a group that bundles tests. Usually one module / one function / one component. Nestable.it(name, fn)— a single test case. Name it as a sentence describing the behavior. (testworks the same way.)expect(value).matcher(expected)— the assertion. Matchers liketoBe,toEqual,toContain,toThrowcome in dozens.
The naming convention is more serious than it looks. Test names become documentation.
// X — 무엇을 테스트하는지 알기 어려움
it('test 1', ...)
it('slugify works', ...)
// O — 동작을 한 문장으로
it('공백을 하이픈으로 바꾼다', ...)
it('빈 문자열에 빈 문자열을 반환한다', ...)The latter form lets you tell what’s broken just by looking at the console when a test fails.
Common Matchers #
The expect slot covers many cases. No need to memorize them — pick them up by pattern.
// 동등 비교
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);The difference between toBe and toEqual is the most commonly confused part.
expect({ a: 1 }).toBe({ a: 1 }); // X — 다른 객체이므로 실패
expect({ a: 1 }).toEqual({ a: 1 }); // O — 깊은 동등성
expect(2).toBe(2); // O — primitive 는 그냥 toBe
Watch Mode — Genuinely Instant #
pnpm test (= vitest) launches watch mode. Each save re-runs only the affected tests.
Keyboard shortcuts:
a — 모든 테스트 다시 실행
f — 실패한 테스트만 다시 실행
p — 파일 패턴으로 필터
t — 테스트 이름 패턴으로 필터
q — 종료This is genuinely powerful in a large project. If five tests are broken, watch only those five with f — while you fix them, no other tests run, so there’s no noise.
UI mode (pnpm test:ui) shows the same thing in the browser. Try it once.
pnpm test:ui
# http://localhost:51204/__vitest__/ 같은 주소가 열림First Failure and First Pass — One TDD Cycle #
A common flow when writing a new function:
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);
}This cycle — red → green → refactor — is the skeleton of TDD. It’s not mandatory, but going through it once on a small function builds the right intuition.
setup / teardown — beforeEach / afterEach
#
When you need to reset or clean up state between tests.
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’s important that each it runs independently — state from the previous test must not leak into the next. beforeEach reinitializes right before every test.
There’s also beforeAll / afterAll (run once at the start/end of a describe). They suit expensive setup (e.g., creating a temp directory). But they break test isolation easily, so prefer beforeEach when possible.
expect.assertions — A Common Async Pitfall
#
Async tests get full coverage in #4, but here’s one trap to know up front.
it('비동기 검증', async () => {
try {
await fetchData();
} catch (e) {
expect(e.message).toBe('expected');
}
// fetchData 가 안 던지면 catch 가 안 도는데, 테스트는 통과
});The fix:
it('비동기 검증', async () => {
expect.assertions(1); // 정확히 1번 expect 가 도는 게 보장돼야 함
try {
await fetchData();
} catch (e) {
expect(e.message).toBe('expected');
}
});expect.assertions(N) enforces that “by the time the test ends, exactly N expects must have been called.” If the catch never runs, that’s 0 — and the test fails.
Or, more cleanly, use the .rejects / .resolves matchers:
it('비동기 검증', async () => {
await expect(fetchData()).rejects.toThrow('expected');
});Mocking — First Encounter with vi.fn()
#
Mocking gets full treatment in #4, but here’s the simplest slot up front.
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() is “a fake function that watches itself.” Whether it was called, how many times, with what arguments — all tracked. You can also define a return value.
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 });Coverage — How to Turn It On #
A single package, @vitest/coverage-v8, is enough.
pnpm add -D @vitest/coverage-v8Add a coverage option to vitest.config.ts:
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.htmlOpen it in a browser and the uncovered lines are colored. Don’t chase the number (back to #1) — just look at the parts that were skipped unintentionally.
Common Traps #
Test fails at the import stage — check the environment setting in vitest.config.ts. Importing code that uses DOM APIs (window, document) under the 'node' environment fails immediately.
describe / it not defined — you forgot globals: true, or types in tsconfig.json is unset. Or import them explicitly: import { describe, it, expect } from 'vitest'.
Watch mode is stuck — fs change detection sometimes fails on Docker / WSL / certain filesystems. Solve it with vitest --watch --watchExclude or polling options.
Tests interfere with each other — a sign of mutation on a module-level variable. Reset it in beforeEach, or reduce module-level mutable state altogether.
ESM package import errors — some libraries need deps optimization. Solve via optimizeDeps in vitest.config.ts (same option as Vite).
Summary #
- The core of a Vitest setup is one file,
vitest.config.ts—globals: true+environment: 'jsdom'+setupFilesis all you need. describe/it/expectare the skeleton of every test. Names should be one sentence describing behavior — test names become documentation.toBeis for primitives and reference equality;toEqualis for deep equality. For objects/arrays, almost alwaystoEqual.- The
fshortcut in watch mode (re-run only failed tests) is incredibly powerful in a large project. - Use
vi.fn()to mock callbacks / dependencies. Define return values withmockReturnValue/mockResolvedValue. - For the missing-
expectpitfall in async tests, useexpect.assertions(N)or the.rejects/.resolvesmatchers. - Coverage turns on with one line for the v8 provider. Look at the missing parts, not the number.
In the next post (#3 React Testing Library) we move to components. We’ll work through RTL’s philosophy (“see it like the user”), and the render / screen / queries (getByRole, getByLabelText) slots, one component at a time.