テスト講座 #2 Vitest のセットアップと最初のユニットテスト — describe・it・expect
#1 で全体像を掴んだなら、この記事は手を動かす最初の一歩です。ツールは Vitest を使います。
テスト講座 の中で、この記事の位置づけ。
- #1 なぜテストか
- #2 Vitest のセットアップと最初のユニットテスト — describe・it・expect ← この記事
- #3 React Testing Library — コンポーネントテスト
- #4 非同期とネットワークのモッキング — MSW
- #5 ユーザーイベントとフォームのテスト
- #6 Playwright で E2E と CI 統合
この記事は 純粋関数 1 つ から始めます。コンポーネント / 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 を使うには、TypeScript がグローバル型を認識する必要があります。tsconfig.json に 1 行追加します。
{
"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 の意味
#
この 3 つの概念が、ほぼすべてのテストの骨格です。
describe(name, fn)— テストをまとめるグループ。普通は 1 モジュール / 1 関数 / 1 コンポーネント単位。ネスト可能です。it(name, fn)— 1 つのテストケース。名前は 振る舞い を 1 文で。(testでも同じ動作です。)expect(value).matcher(expected)— 検証。matcher はtoBe、toEqual、toContain、toThrowなど数十種類あります。
名前の規約は実は真剣に考えるべきです。テスト名が ドキュメント になります。
// X — 무엇을 테스트하는지 알기 어려움
it('test 1', ...)
it('slugify works', ...)
// O — 동작을 한 문장으로
it('공백을 하이픈으로 바꾼다', ...)
it('빈 문자열에 빈 문자열을 반환한다', ...)後者の形式は、テストが壊れたときにコンソールを見るだけで何が壊れたかが分かります。
よく使う matcher #
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 1 サイクル #
新しい関数を書くときによくある流れ。
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 もあります(1 つの 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 の matcher を使う方法。
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 1 パッケージで足ります。
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 な状態自体を減らしてください。
ESM パッケージの import エラー — 一部のライブラリは deps optimization が必要です。vitest.config.ts の optimizeDeps 設定で解決します(Vite と同じオプション)。
まとめ #
- Vitest セットアップの核は
vitest.config.ts1 ファイル —globals: true+environment: 'jsdom'+setupFilesの 3 つを押さえれば十分です。 describe/it/expectがすべてのテストの骨格。名前は 振る舞いを 1 文で — テスト名がドキュメントになります。toBeは primitive と参照同等性、toEqualは深い同等性。オブジェクト/配列はほぼ常にtoEqual。- watch モードの
f(失敗したテストだけを再実行)は大きなプロジェクトで非常に強力な機能です。 vi.fn()でコールバック / 依存関係をモッキング。mockReturnValue/mockResolvedValueで戻り値を定義。- 非同期テストの
expect抜け落ちの罠は、expect.assertions(N)または.rejects/.resolvesの matcher で。 - カバレッジは v8 provider 1 行で有効化されます。数字ではなく抜けている部分 だけを見てください。
次回(#3 React Testing Library)ではコンポーネントへ。RTL の哲学(「ユーザーのように見る」)、render / screen / queries(getByRole、getByLabelText)の使い方を、コンポーネント 1 つずつ身につけていきます。