Vitest Matchers 技術チートシート:`expect.extend` で表現力を拡張する定石
Vitest のテストコードを書いていて、「このアサーションをもっとわかりやすく表現できないかな」と感じたことはありませんか。標準のマッチャーだけでは、ドメイン固有のロジックを検証するときに冗長になってしまいます。
expect.extend を使えば、独自のカスタムマッチャーを定義できます。テストの意図が明確になり、再利用性も向上するでしょう。本記事では、expect.extend の基礎から TypeScript 対応、実践的なパターンまでを網羅的に解説します。
カスタムマッチャー早見表
基本構文パターン
| # | パターン | 用途 | コード例 |
|---|---|---|---|
| 1 | 基本マッチャー | 単純な真偽値判定 | toBeDivisibleBy(divisor) |
| 2 | 引数付きマッチャー | パラメータを受け取る検証 | toBeWithinRange(min, max) |
| 3 | 非同期マッチャー | Promise や async/await を扱う | toResolveWithin(ms) |
| 4 | not 対応マッチャー | 否定形の検証をサポート | isNot フラグで制御 |
| 5 | 複合マッチャー | 複数条件を組み合わせる | toMatchSchema(schema) |
戻り値プロパティ一覧
| # | プロパティ | 型 | 必須 | 説明 |
|---|---|---|---|---|
| 1 | pass | boolean | ✓ | テスト成功なら true、失敗なら false |
| 2 | message | () => string | ✓ | 失敗時のエラーメッセージを返す関数 |
| 3 | actual | unknown | - | 実際の値(デバッグ用) |
| 4 | expected | unknown | - | 期待値(デバッグ用) |
利用可能なヘルパー一覧
| # | ヘルパー | 用途 | 使用例 |
|---|---|---|---|
| 1 | this.isNot | not 修飾子が付いているか判定 | if (this.isNot) { ... } |
| 2 | this.promise | Promise マッチャーか判定 | if (this.promise === 'resolves') |
| 3 | this.equals(a, b) | 深い等価性チェック | this.equals(actual, expected) |
| 4 | this.utils.matcherHint() | エラーメッセージのヒント生成 | matcherHint('toBeDivisibleBy') |
| 5 | this.utils.printReceived() | 実際の値を整形して出力 | printReceived(actual) |
| 6 | this.utils.printExpected() | 期待値を整形して出力 | printExpected(expected) |
TypeScript 型定義テンプレート
| # | 定義箇所 | コード例 |
|---|---|---|
| 1 | マッチャーインターフェース | interface CustomMatchers<R = unknown> |
| 2 | expect の型拡張 | expect: CustomMatchers |
| 3 | Assertion の型拡張 | Assertion: CustomMatchers |
| 4 | AsymmetricMatchersContaining | AsymmetricMatchersContaining: CustomMatchers |
背景
Vitest 標準マッチャーの限界
Vitest は Jest 互換のマッチャーを豊富に提供していますが、標準マッチャーだけでは以下のような課題が生じます。
プロジェクト固有のドメインロジックを検証するとき、toBe、toEqual、toBeTruthy などの汎用マッチャーを組み合わせても、テストの意図が読み取りにくくなってしまいます。たとえば、「このオブジェクトが有効なユーザーデータか」を検証する場合、複数のプロパティチェックを並べる必要があるでしょう。
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<T>"]
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でカスタムマッチャーを定義する方法 - 戻り値の構造:
passとmessageプロパティの役割 - ヘルパー関数:
this.utilsで使える便利なユーティリティ - 実践例:引数なし・引数あり・オブジェクト検証・非同期マッチャー
- TypeScript 対応:型定義ファイルの作成と設定方法
- プロジェクト運用:セットアップファイルでの一括登録
カスタムマッチャーを活用すれば、テストコードがビジネスロジックを自然に表現できます。標準マッチャーでは表現しきれない複雑な検証も、わかりやすく記述できるようになるでしょう。
まずは小さなマッチャーから始めて、徐々にプロジェクト全体で活用していくことをおすすめします。テストの品質向上に、expect.extend はきっと役立つはずです。
関連リンク
articleVitest Matchers 技術チートシート:`expect.extend` で表現力を拡張する定石
articleVitest モノレポ技術セットアップ:pnpm / Nx / Turborepo で超高速化する手順
articleVitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?
articleVitest ESM/CJS 混在で `Cannot use import statement outside a module` が出る技術対処集
articleVitest モジュールモック技術の基礎と応用:`vi.mock` / `vi.spyOn` を極める
articleVitest フレーク検知技術の運用:`--retry` / シード固定 / ランダム順序で堅牢化
articleZod 合成パターン早見表:`object/array/tuple/record/map/set/intersection` 実例集
articleバックアップ戦略の決定版:WordPress の世代管理/災害復旧の型
articleYarn 運用ベストプラクティス:lockfile 厳格化・frozen-lockfile・Bot 更新方針
articleWebSocket のペイロード比較:JSON・MessagePack・Protobuf の速度とコスト検証
articleWeb Components イベント設計チート:`CustomEvent`/`composed`/`bubbles` 実例集
articleWebRTC SDP 用語チートシート:m=・a=・bundle・rtcp-mux を 10 分で総復習
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来