テストの現実:統合テストの罠からの脱脱
単純な計算ユーティリティのユニットテストは簡単です。しかし、実際の開発現場はもっと複雑です。私が携わるプロダクションコードの多くは、StripeのAPI、PostgreSQLデータベース、AWS S3バケットなどとやり取りします。もしテストスイートが実際の決済ゲートウェイにアクセスしようとすれば、401エラーやネットワーク遅延によって、いずれ失敗するでしょう。不安定なテスト(Flaky tests)は生産性を著しく低下させます。不安定なCIパイプライン一つのために、チームが何時間もの開発時間を失うのを私は見てきました。
モック(Mocking)とスタブ(Stubbing)は、外部の挙動をシミュレートすることでこの問題を解決します。これらの手法により、テストは確実(デターミニスティック)で非常に高速になります。現代的なESMベースのプロジェクトでは、VitestはJestに代わる明確な勝者です。Viteの変換パイプラインを活用しているため、実行時間を30〜50%削減できることも珍しくありません。特にテストスイートが数百ファイルに及ぶ場合、その軽快さを実感できるでしょう。
混乱を避けるために、ツールの定義を明確にしておきましょう:
- スタブ(Stubbing): 「あらかじめ用意された」レスポンスを返します。コードが
getExchangeRate()を呼び出すと、スタブは計算を行わずに単に1.2を返します。 - モック(Mocking): 挙動に焦点を当てます。単に偽の値を返すだけでなく、関数が正しいAPIキーで正確に2回呼び出されたか、といった検証を行います。
セットアップ:Vitest環境の準備
TypeScriptプロジェクトでのVitestのセットアップは2分もかかりません。すでにViteを使用している場合、統合はシームレスです。スタンドアロンのランナーとして使用する場合でも、最小限のボイラープレートで済みます。
開発体験を向上させるために、コアパッケージとUIダッシュボードをインストールしましょう:
npm install -D vitest @vitest/ui @vitest/coverage-v8
TypeScriptユーザーは、グローバルなテスト関数を使用する際に「モジュールが見つからない」というエラーに遭遇することがよくあります。設定でグローバルを有効にすることもできますが、私は明示的なインポートを推奨します。コードのナビゲーションが容易になり、VS CodeでのIntelliSenseのサポートも向上します。package.jsonに以下の重要なスクリプトを追加してください:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
実践的な実装:モックとスタブの活用
プロファイルを取得し、ローカルキャッシュを更新するuserService.tsを例に考えてみましょう。実際にHTTPリクエストを行わずに、ロジックをテストしたいとします。
1. 外部モジュールのモック
axiosやfetchを使用する場合、テストファイルの冒頭でモジュール全体をモック化する必要があります。これにより、テスト環境の周囲に「安全なバブル」が作成されます。
// userService.ts
import axios from 'axios';
export const getUser = async (id: number) => {
const response = await axios.get(`https://api.myapp.com/users/${id}`);
return response.data;
};
テストでは、vi.mock()を使用してネットワークコールを傍受します。vi.mocked()を使用することで、モック化されたメソッドに対して完全な型安全性が提供される点に注目してください:
// userService.test.ts
import { describe, it, expect, vi } from 'vitest';
import axios from 'axios';
import { getUser } from './userService';
vi.mock('axios');
describe('getUser', () => {
it('成功時にユーザーデータを返す', async () => {
const mockUser = { id: 42, name: 'Alex' };
// TypeScriptはaxios.getがモックであることを認識しています
vi.mocked(axios.get).mockResolvedValue({ data: mockUser });
const result = await getUser(42);
expect(result).toEqual(mockUser);
expect(axios.get).toHaveBeenCalledWith('https://api.myapp.com/users/42');
});
});
2. スパイによるメソッドの監視
常にモジュール全体を置き換える必要はありません。特定の関数を監視するだけで十分な場合もあります。例えば、ユーザーが「購入」をクリックしたときにアナリティクスイベントが発行されることを確認したい場合、vi.spyOn()が最適です。
it('チェックアウトイベントを追跡する', () => {
const spy = vi.spyOn(tracker, 'sendEvent');
processCheckout({ total: 99.99 });
expect(spy).toHaveBeenCalledWith('checkout_completed', { amount: 99.99 });
spy.mockRestore(); // 他のテストへの影響を避けるため、常にリストアします
});
3. 時間の制御
15分で期限切れになるパスワードリセットトークンのテストをしますか? 実際に15分待つ必要はありません。vi.useFakeTimers()を使用して時計を操作しましょう。これにより、瞬時に時間を進めることができます。
it('30分後にセッションを期限切れにする', () => {
vi.useFakeTimers();
const session = startSession();
// 31分進める
vi.advanceTimersByTime(31 * 60 * 1000);
expect(session.isValid()).toBe(false);
vi.useRealTimers();
});
テストの整合性を維持する
よくある落とし穴は「モックの漏洩(Leaky mock)」です。これは、あるテストのモックが次のテストの結果に影響を与えてしまう現象です。個別に実行するとパスするのに、グループで実行すると失敗するという、フラストレーションの溜まる状況を引き起こします。
クリーンアップの自動化
すべてを自動的にリセットするようにVitestを設定することをお勧めします。これにより、テストの独立性と予測可能性が保たれます。vitest.config.tsに以下の設定を追加してください:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
clearMocks: true, // 呼び出し履歴をクリア
mockReset: true, // 空の関数にリセット
restoreMocks: true, // オリジナルの実装を復元
},
});
スマートなアサーション
巨大なJSONレスポンスのすべてのキーをテストするのは避けましょう。APIが50個のフィールドを持つオブジェクトを返しても、必要なのがemailとidだけであれば、非対称マッチャー(asymmetric matchers)を使用します。これにより、特定のロジックに影響しないAPIの変更に対してテストが強固になります。
expect(apiResponse).toHaveBeenCalledWith(
expect.objectContaining({
email: '[email protected]',
id: expect.any(Number)
})
);
これらのパターンをマスターすることで、壊れやすいテストスイートを堅牢なセーフティネットへと変えることができます。複雑なロジックのリファクタリングや依存関係のアップグレードも、コアとなるビジネスルールが外部の変動から保護されているという確信を持って行えるようになるでしょう。

