T-CREATOR

Vitest Matchers 技術チートシート:`expect.extend` で表現力を拡張する定石

Vitest Matchers 技術チートシート:`expect.extend` で表現力を拡張する定石

Vitest のテストコードを書いていて、「このアサーションをもっとわかりやすく表現できないかな」と感じたことはありませんか。標準のマッチャーだけでは、ドメイン固有のロジックを検証するときに冗長になってしまいます。

expect.extend を使えば、独自のカスタムマッチャーを定義できます。テストの意図が明確になり、再利用性も向上するでしょう。本記事では、expect.extend の基礎から TypeScript 対応、実践的なパターンまでを網羅的に解説します。

カスタムマッチャー早見表

基本構文パターン

#パターン用途コード例
1基本マッチャー単純な真偽値判定toBeDivisibleBy(divisor)
2引数付きマッチャーパラメータを受け取る検証toBeWithinRange(min, max)
3非同期マッチャーPromise や async/await を扱うtoResolveWithin(ms)
4not 対応マッチャー否定形の検証をサポートisNot フラグで制御
5複合マッチャー複数条件を組み合わせるtoMatchSchema(schema)

戻り値プロパティ一覧

#プロパティ必須説明
1passbooleanテスト成功なら true、失敗なら false
2message() => string失敗時のエラーメッセージを返す関数
3actualunknown-実際の値(デバッグ用)
4expectedunknown-期待値(デバッグ用)

利用可能なヘルパー一覧

#ヘルパー用途使用例
1this.isNotnot 修飾子が付いているか判定if (this.isNot) { ... }
2this.promisePromise マッチャーか判定if (this.promise === 'resolves')
3this.equals(a, b)深い等価性チェックthis.equals(actual, expected)
4this.utils.matcherHint()エラーメッセージのヒント生成matcherHint('toBeDivisibleBy')
5this.utils.printReceived()実際の値を整形して出力printReceived(actual)
6this.utils.printExpected()期待値を整形して出力printExpected(expected)

TypeScript 型定義テンプレート

#定義箇所コード例
1マッチャーインターフェースinterface CustomMatchers<R = unknown>
2expect の型拡張expect: CustomMatchers
3Assertion の型拡張Assertion: CustomMatchers
4AsymmetricMatchersContainingAsymmetricMatchersContaining: CustomMatchers

背景

Vitest 標準マッチャーの限界

Vitest は Jest 互換のマッチャーを豊富に提供していますが、標準マッチャーだけでは以下のような課題が生じます。

プロジェクト固有のドメインロジックを検証するとき、toBetoEqualtoBeTruthy などの汎用マッチャーを組み合わせても、テストの意図が読み取りにくくなってしまいます。たとえば、「このオブジェクトが有効なユーザーデータか」を検証する場合、複数のプロパティチェックを並べる必要があるでしょう。

typescripttest('ユーザーデータの検証', () => {
  const user = {
    id: 1,
    name: 'Alice',
    email: 'alice@example.com',
  };

  // 複数のアサーションが必要
  expect(user.id).toBeGreaterThan(0);
  expect(user.name).toBeTruthy();
  expect(user.email).toMatch(/@/);
});

このようなコードは動作しますが、「ユーザーデータとして妥当か」という高レベルの意図が埋もれてしまいます。

カスタムマッチャーのメリット

expect.extend でカスタムマッチャーを定義すれば、テストコードの可読性が劇的に向上します。ドメインロジックをマッチャーとしてカプセル化することで、テストの意図が一目瞭然になるでしょう。

カスタムマッチャーの主な利点:

  • 意図の明確化toBeValidUser() のような名前で検証内容を表現できる
  • 再利用性:共通の検証ロジックを複数のテストで使い回せる
  • メンテナンス性:検証ロジックの変更が一箇所で済む
  • エラーメッセージのカスタマイズ:失敗時のメッセージを自由に設計できる

下記の図は、標準マッチャーとカスタムマッチャーの構造的な違いを示しています。

mermaidflowchart LR
  standard["標準マッチャー<br/>toBe, toEqual など"] -->|複数組み合わせ| test1["テストケース A"]
  standard -->|複数組み合わせ| test2["テストケース B"]

  custom["カスタムマッチャー<br/>toBeValidUser"] -->|1回の呼び出し| test3["テストケース A"]
  custom -->|1回の呼び出し| test4["テストケース B"]

  custom -.->|内部で複数チェック| logic["ドメインロジック<br/>ID・名前・メール検証"]

標準マッチャーを複数組み合わせる方式では、各テストケースで同じようなコードを繰り返す必要があります。一方、カスタムマッチャーを使えば、検証ロジックを一箇所にまとめ、シンプルな呼び出しで再利用できるのです。

課題

冗長なテストコードの問題点

標準マッチャーだけでテストを書くと、以下のような問題が発生します。

可読性の低下

複数のアサーションを並べると、何を検証しているのか理解しにくくなります。

typescript// 配列が昇順にソートされているか検証
test('配列がソートされている', () => {
  const arr = [1, 2, 3, 4, 5];

  // 全要素をループで比較
  for (let i = 0; i < arr.length - 1; i++) {
    expect(arr[i]).toBeLessThanOrEqual(arr[i + 1]);
  }
});

このコードは動作しますが、「ソート済みか」という意図が即座に伝わりません。

コードの重複

同じような検証ロジックを複数のテストで書くことになり、保守コストが増大します。

typescript// テスト A
test('ユーザー A の検証', () => {
  expect(userA.id).toBeGreaterThan(0);
  expect(userA.name).toBeTruthy();
  expect(userA.email).toMatch(/@/);
});

// テスト B(同じロジックを繰り返す)
test('ユーザー B の検証', () => {
  expect(userB.id).toBeGreaterThan(0);
  expect(userB.name).toBeTruthy();
  expect(userB.email).toMatch(/@/);
});

検証ルールが変わったとき、すべてのテストケースを修正する必要があります。

エラーメッセージの不明瞭さ

標準マッチャーのエラーメッセージは汎用的で、失敗の本質的な原因がわかりにくいことがあります。

typescript// 失敗時のメッセージ例
// Expected: true
// Received: false

// これでは「なぜ false だったのか」がわからない

下記の図は、冗長なテストコードの問題構造を示しています。

mermaidflowchart TD
  test_start["テスト開始"] --> check1["条件 1 チェック"]
  check1 --> check2["条件 2 チェック"]
  check2 --> check3["条件 3 チェック"]
  check3 --> check4["条件 4 チェック"]
  check4 --> result["結果判定"]

  check1 -.->|失敗| error1["汎用エラー A"]
  check2 -.->|失敗| error2["汎用エラー B"]
  check3 -.->|失敗| error3["汎用エラー C"]
  check4 -.->|失敗| error4["汎用エラー D"]

  result --> pass["テスト成功"]

  style error1 fill:#ffcccc
  style error2 fill:#ffcccc
  style error3 fill:#ffcccc
  style error4 fill:#ffcccc

複数の標準マッチャーを連続で使うと、どのチェックで失敗したのかを追跡するのが手間になります。カスタムマッチャーなら、すべてのチェックを一つのロジックにまとめ、明確なエラーメッセージを返せるのです。

ドメイン固有の検証ニーズ

プロジェクトが大きくなるにつれて、ドメイン固有の検証要件が増えていきます。たとえば:

  • ビジネスルールに基づくバリデーション(価格が許容範囲内か、など)
  • データ構造の整合性チェック(API レスポンスのスキーマ検証)
  • 計算結果の妥当性(数値が特定の条件を満たすか)

これらを標準マッチャーだけで表現すると、テストコードが複雑化してしまいます。

解決策

expect.extend の基本構文

expect.extend を使えば、独自のマッチャーを定義できます。基本的な構文は以下の通りです。

マッチャー定義の構造

typescriptimport { expect } from 'vitest';

// カスタムマッチャーを定義
expect.extend({
  // マッチャー名(キャメルケース推奨)
  toBeEvenNumber(received: number) {
    // テストロジックを実装
    const pass = received % 2 === 0;

    // 結果を返す
    return {
      pass,
      message: () =>
        pass
          ? `期待値: 奇数, 実際: ${received}`
          : `期待値: 偶数, 実際: ${received}`,
    };
  },
});

このコードでは、toBeEvenNumber という偶数判定のカスタムマッチャーを定義しています。

マッチャーの使用例

定義したマッチャーは、標準マッチャーと同じように使えます。

typescripttest('偶数の検証', () => {
  expect(4).toBeEvenNumber(); // 成功
  expect(5).not.toBeEvenNumber(); // 成功(否定形)
});

カスタムマッチャーの戻り値

カスタムマッチャーは、以下のプロパティを持つオブジェクトを返す必要があります。

pass プロパティ

テストが成功したかどうかを示すブール値です。

typescript// pass: true → テスト成功
// pass: false → テスト失敗
const pass = received % 2 === 0;

message プロパティ

失敗時に表示されるエラーメッセージを返す関数です。not 修飾子の有無に応じて、適切なメッセージを返すようにします。

typescriptmessage: () => {
  // this.isNot で not 修飾子の有無を判定できる
  return this.isNot
    ? `Expected ${received} not to be even`
    : `Expected ${received} to be even`;
};

下記の図は、カスタムマッチャーの処理フローを示しています。

mermaidflowchart TD
  start["expect(value)"] --> matcher["カスタムマッチャー<br/>toBeEvenNumber()"]
  matcher --> logic["検証ロジック<br/>received % 2 === 0"]
  logic --> pass_check{pass?}

  pass_check -->|true| is_not{this.isNot?}
  pass_check -->|false| is_not

  is_not -->|true| fail["テスト失敗<br/>メッセージ表示"]
  is_not -->|false + pass=true| success["テスト成功"]
  is_not -->|false + pass=false| fail

  style success fill:#ccffcc
  style fail fill:#ffcccc

expect.extend で定義したマッチャーは、内部で検証ロジックを実行し、結果に応じて成功・失敗を判定します。not 修飾子が付いている場合は、判定を反転させる仕組みです。

ヘルパーユーティリティの活用

Vitest は、カスタムマッチャー内で使える便利なヘルパー関数を提供しています。

this.utils.matcherHint()

エラーメッセージのヒント部分を生成します。

typescriptmessage: () => {
  const hint = this.utils.matcherHint(
    'toBeEvenNumber',
    'received',
    ''
  );
  return `${hint}\n\nExpected: even number\nReceived: ${received}`;
};

this.utils.printReceived()this.utils.printExpected()

値を見やすく整形して出力します。

typescriptconst received = this.utils.printReceived(actualValue);
const expected = this.utils.printExpected(expectedValue);

this.equals(a, b)

深い等価性チェックを行います。オブジェクトや配列の比較に便利です。

typescriptconst isEqual = this.equals(actual, expected);

具体例

基本:引数なしのカスタムマッチャー

最もシンプルなカスタムマッチャーから始めましょう。引数を受け取らず、受け取った値だけをチェックします。

配列がソート済みか判定するマッチャー

typescriptimport { expect } from 'vitest';

// マッチャーを定義
expect.extend({
  toBeSorted(received: number[]) {
    // ソート済みか判定
    const isSorted = received.every(
      (val, i, arr) => i === 0 || arr[i - 1] <= val
    );

    return {
      pass: isSorted,
      message: () =>
        isSorted
          ? `Expected array not to be sorted`
          : `Expected array to be sorted, but received: [${received.join(
              ', '
            )}]`,
    };
  },
});

使用例

typescripttest('配列がソート済み', () => {
  expect([1, 2, 3, 4, 5]).toBeSorted(); // 成功
  expect([5, 4, 3, 2, 1]).not.toBeSorted(); // 成功
  expect([1, 3, 2, 4]).not.toBeSorted(); // 成功
});

このマッチャーを使えば、配列のソート状態を一目で判定できます。

中級:引数を受け取るカスタムマッチャー

引数を受け取ることで、より柔軟な検証が可能になります。

数値が範囲内か判定するマッチャー

typescriptexpect.extend({
  toBeWithinRange(
    received: number,
    min: number,
    max: number
  ) {
    const pass = received >= min && received <= max;

    return {
      pass,
      message: () => {
        const hint = this.utils.matcherHint(
          'toBeWithinRange'
        );
        return pass
          ? `${hint}\n\nExpected ${received} not to be within range [${min}, ${max}]`
          : `${hint}\n\nExpected ${received} to be within range [${min}, ${max}]`;
      },
    };
  },
});

使用例

typescripttest('範囲チェック', () => {
  expect(50).toBeWithinRange(0, 100); // 成功
  expect(150).not.toBeWithinRange(0, 100); // 成功
});

引数を複数受け取ることで、汎用的なマッチャーを作成できます。

中級:オブジェクトのスキーマ検証

複雑なオブジェクトの構造を検証するマッチャーです。

ユーザーデータの妥当性を判定

typescriptinterface User {
  id: number;
  name: string;
  email: string;
}

expect.extend({
  toBeValidUser(received: unknown) {
    // 型ガード
    const isObject =
      typeof received === 'object' && received !== null;
    if (!isObject) {
      return {
        pass: false,
        message: () =>
          `Expected an object, but received ${typeof received}`,
      };
    }

    const user = received as Partial<User>;

    // 各フィールドをチェック
    const hasValidId =
      typeof user.id === 'number' && user.id > 0;
    const hasValidName =
      typeof user.name === 'string' && user.name.length > 0;
    const hasValidEmail =
      typeof user.email === 'string' &&
      /@/.test(user.email);

    const pass =
      hasValidId && hasValidName && hasValidEmail;

    return {
      pass,
      message: () => {
        if (!hasValidId) return `Invalid id: ${user.id}`;
        if (!hasValidName)
          return `Invalid name: ${user.name}`;
        if (!hasValidEmail)
          return `Invalid email: ${user.email}`;
        return `Expected invalid user, but all fields are valid`;
      },
    };
  },
});

使用例

typescripttest('ユーザーデータの検証', () => {
  const validUser = {
    id: 1,
    name: 'Alice',
    email: 'alice@example.com',
  };
  expect(validUser).toBeValidUser(); // 成功

  const invalidUser = {
    id: -1,
    name: '',
    email: 'invalid',
  };
  expect(invalidUser).not.toBeValidUser(); // 成功
});

複雑なビジネスロジックも、カスタムマッチャーでカプセル化できます。

上級:非同期マッチャー

Promise や async/await を扱うマッチャーも作成できます。

指定時間内に Promise が解決するか判定

typescriptexpect.extend({
  async toResolveWithin(
    received: Promise<unknown>,
    ms: number
  ) {
    const startTime = Date.now();

    try {
      await received;
      const elapsed = Date.now() - startTime;
      const pass = elapsed <= ms;

      return {
        pass,
        message: () =>
          pass
            ? `Expected Promise not to resolve within ${ms}ms, but resolved in ${elapsed}ms`
            : `Expected Promise to resolve within ${ms}ms, but took ${elapsed}ms`,
      };
    } catch (error) {
      return {
        pass: false,
        message: () =>
          `Expected Promise to resolve, but it rejected with: ${error}`,
      };
    }
  },
});

使用例

typescripttest('Promise のタイムアウト', async () => {
  const fastPromise = Promise.resolve('fast');
  await expect(fastPromise).toResolveWithin(100); // 成功

  const slowPromise = new Promise((resolve) =>
    setTimeout(resolve, 200)
  );
  await expect(slowPromise).not.toResolveWithin(100); // 成功
});

非同期処理のテストも、カスタムマッチャーで読みやすくなります。

上級:TypeScript 型定義

TypeScript プロジェクトでは、カスタムマッチャーの型定義を追加する必要があります。

型定義ファイルの作成

プロジェクトのルートに vitest.d.ts または test​/​setup.ts のような型定義ファイルを作成します。

typescriptimport 'vitest';

// カスタムマッチャーの型を定義
interface CustomMatchers<R = unknown> {
  toBeEvenNumber(): R;
  toBeSorted(): R;
  toBeWithinRange(min: number, max: number): R;
  toBeValidUser(): R;
  toResolveWithin(ms: number): R;
}

// Vitest の型を拡張
declare module 'vitest' {
  interface Assertion<T = any> extends CustomMatchers<T> {}
  interface AsymmetricMatchersContaining
    extends CustomMatchers {}
}

型定義の説明

  • CustomMatchers インターフェース:独自マッチャーのシグネチャを定義
  • Assertion の拡張:expect() で使えるようにする
  • AsymmetricMatchersContaining の拡張:expect.objectContaining() などとの組み合わせをサポート

tsconfig.json の設定

型定義ファイルをコンパイラに認識させます。

json{
  "compilerOptions": {
    "types": ["vitest/globals"]
  },
  "include": ["src/**/*", "test/**/*", "vitest.d.ts"]
}

これで、TypeScript の補完とエラーチェックが効くようになります。

下記の図は、TypeScript 型定義の構造を示しています。

mermaidflowchart LR
  vitest["vitest パッケージ"] -->|型定義| assertion["Assertion&lt;T&gt;"]

  custom_def["vitest.d.ts<br/>CustomMatchers"] -->|拡張| assertion
  custom_def -->|拡張| asymmetric["Asymmetric<br/>Matchers<br/>Containing"]

  assertion --> expect_call["expect(value)<br/>型補完が効く"]
  asymmetric --> object_call["expect.objectContaining()<br/>型補完が効く"]

  expect_call --> custom_use["カスタムマッチャー<br/>toBeValidUser() など"]

型定義を正しく設定すれば、カスタムマッチャーも標準マッチャーと同じように IDE の補完が効きます。

実践:セットアップファイルでの一括登録

プロジェクト全体でカスタムマッチャーを使うには、セットアップファイルで一括登録します。

test​/​setup.ts の作成

typescriptimport { expect } from 'vitest';
import './matchers/toBeSorted';
import './matchers/toBeValidUser';
import './matchers/toResolveWithin';

// 他の初期化処理があればここに記述

個別マッチャーファイルの例

typescript// test/matchers/toBeSorted.ts
import { expect } from 'vitest';

expect.extend({
  toBeSorted(received: number[]) {
    const isSorted = received.every(
      (val, i, arr) => i === 0 || arr[i - 1] <= val
    );

    return {
      pass: isSorted,
      message: () =>
        isSorted
          ? `Expected array not to be sorted`
          : `Expected array to be sorted, but received: [${received.join(
              ', '
            )}]`,
    };
  },
});

Vitest 設定での読み込み

typescript// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    setupFiles: ['./test/setup.ts'],
  },
});

この構成により、すべてのテストファイルでカスタムマッチャーが自動的に利用可能になります。

まとめ

expect.extend を使えば、Vitest のマッチャーを自由に拡張できます。ドメイン固有の検証ロジックをカプセル化することで、テストコードの可読性と保守性が飛躍的に向上するでしょう。

本記事で解説した内容:

  • 基本構文expect.extend でカスタムマッチャーを定義する方法
  • 戻り値の構造passmessage プロパティの役割
  • ヘルパー関数this.utils で使える便利なユーティリティ
  • 実践例:引数なし・引数あり・オブジェクト検証・非同期マッチャー
  • TypeScript 対応:型定義ファイルの作成と設定方法
  • プロジェクト運用:セットアップファイルでの一括登録

カスタムマッチャーを活用すれば、テストコードがビジネスロジックを自然に表現できます。標準マッチャーでは表現しきれない複雑な検証も、わかりやすく記述できるようになるでしょう。

まずは小さなマッチャーから始めて、徐々にプロジェクト全体で活用していくことをおすすめします。テストの品質向上に、expect.extend はきっと役立つはずです。

関連リンク