テスト講座 #2 Vitest のセットアップと最初のユニットテスト — describe・it・expect

読了 9分

#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 にスクリプトを追加します。

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 と同じ設定ファイルを使うこともできますが、テスト専用に分けるほうがすっきりします。

vitest.config.ts
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: truedescribeitexpect を各ファイルで import しなくてよくなります。Jest と同じ形です。
  • environment: 'jsdom' — コンポーネントテストが入ると必要になります。純粋関数だけをテストするなら 'node' のほうが速いです。
  • setupFiles — すべてのテスト開始前に一度だけ走るセットアップコード。matcher の拡張やグローバルなモッキングはここに置きます。

vitest.setup.ts はいったん空のままにしておきます。#3@testing-library/jest-dom を追加するときに埋めます。

globals: true を使うには、TypeScript がグローバル型を認識する必要があります。tsconfig.json に 1 行追加します。

tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

もっとも単純な最初のテスト #

テスト対象から作ります。スラッグを生成する関数です。

src/lib/slugify.ts
export function slugify(input: string): string {
  return input
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9가-힣\s-]/g, '')
    .replace(/\s+/g, '-');
}

テスト。

src/lib/slugify.test.ts
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 は toBetoEqualtoContaintoThrow など数十種類あります。

名前の規約は実は真剣に考えるべきです。テスト名が ドキュメント になります。

좋은 이름 vs 나쁜 이름
// 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);

toBetoEqual の違いが、いちばん混同されやすい部分です。

toBe vs 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 モードです。ファイルを保存するたびに、影響を受けたテストだけを再実行します。

ショートカットキー。

watch 모드 단축키
a — 모든 테스트 다시 실행
f — 실패한 테스트만 다시 실행
p — 파일 패턴으로 필터
t — 테스트 이름 패턴으로 필터
q — 종료

大きなプロジェクトで本当に強力な機能です。失敗しているテストが 5 個なら、f でその 5 個だけを watch — 修正している間に他のテストが走らないので、ノイズが出ません。

UI モード(pnpm test:ui)は同じことをブラウザで見せてくれます。一度起動してみてください。

UI 모드
pnpm test:ui
# http://localhost:51204/__vitest__/ 같은 주소가 열림

最初の失敗と最初の成功 — TDD 1 サイクル #

新しい関数を書くときによくある流れ。

1. 먼저 깨지는 테스트
import { capitalize } from './capitalize';   // 아직 없음

it('첫 글자를 대문자로 만든다', () => {
  expect(capitalize('hello')).toBe('Hello');
});
실행 — 실패
× capitalize > 첫 글자를 대문자로 만든다
   Cannot find module './capitalize'
2. 가장 단순한 구현 — 통과
// src/lib/capitalize.ts
export function capitalize(s: string): string {
  return s[0].toUpperCase() + s.slice(1);
}
실행 — 통과
✓ capitalize > 첫 글자를 대문자로 만든다
3. edge case 추가 — 다시 실패
it('빈 문자열에 빈 문자열을 반환한다', () => {
  expect(capitalize('')).toBe('');
});
4. 구현 보강 — 통과
export function capitalize(s: string): string {
  if (!s) return s;
  return s[0].toUpperCase() + s.slice(1);
}

このサイクル — 赤 → 緑 → リファクタ — が TDD の骨格です。必須ではありませんが、小さな関数で一度経験しておくと感覚がつかめます。

setup / teardown — beforeEach / afterEach #

テスト間で状態を初期化したり片付けたりする必要があるとき。

setup / teardown
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 가 안 도는데, 테스트는 통과
});

解決策。

expect.assertions 로 호출 횟수 강제
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 を使う方法。

rejects 매처
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-v8

vitest.config.ts に coverage オプションを追加します。

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.tsenvironment 設定を確認してください。DOM API(windowdocument)を使うコードを 'node' 環境で import すると即座に失敗します。

describe / it が not definedglobals: true の漏れ、または tsconfig.jsontypes が未設定。あるいは明示的に import { describe, it, expect } from 'vitest' で。

watch モードが止まっている — Docker / WSL / 一部のファイルシステムで fs の変更検知が効かないとき。vitest --watch --watchExclude または polling オプションで解決します。

テストが互いに影響する — モジュールレベルの変数を mutate した跡。beforeEach で初期化するか、モジュールレベルの mutable な状態自体を減らしてください。

ESM パッケージの import エラー — 一部のライブラリは deps optimization が必要です。vitest.config.tsoptimizeDeps 設定で解決します(Vite と同じオプション)。

まとめ #

  • Vitest セットアップの核は vitest.config.ts 1 ファイル — 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(getByRolegetByLabelText)の使い方を、コンポーネント 1 つずつ身につけていきます。

X