手動アサーションはもうやめよう:VitestによるREST APIのスナップショットテスト

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

12,000ドルのリファクタリング

火曜日の午前2時14分、ナイトスタンドに置いていたスマホが振動し始めました。ダムが決壊したかのように、SentryのアラートがSlackチャンネルに溢れかえっていました。午後5時にマージされた「軽微な」ユーザープロフィールAPIのクリーンアップが原因で、深夜までにフロントエンドのダッシュボードがユーザーの40%で表示されなくなっていたのです。犯人は?ある開発者がリンタールールに従って、一つのフィールド名をuser_idからuserIdに変更したことでした。

本当のショックは、CI/CDパイプラインが全く警告を発しなかったことです。すべてのテストがパスしていました。なぜなら、私たちはres.status === 200であることと、レスポンスボディがオブジェクトであることを確認していただけで、破壊的変更(ブレーキングチェンジ)を完全に見逃していたからです。50個のフィールドを持つJSONオブジェクトに対して手動でアサーションを書くのは気が遠くなるような作業であるため、私たちは近道を選んでしまいました。その夜、その近道によって4時間のダウンタイムと、多くの不満を抱えた顧客という代償を払うことになったのです。

スナップショットテストはこの状況を一変させます。テストするフィールドを一つずつ選ぶ代わりに、APIレスポンス全体を基準点(リファレンス)としてキャプチャします。将来、一文字でも変更があればテストは失敗します。指が痛くなるまでexpect(body.name).toBe(...)を書き続ける必要はもうありません。

5分以内でセットアップ

手順を進めるには、標準的なNode.js環境が必要です。今回は、Jestよりも大幅に高速なVitestと、HTTPトラフィックをシミュレートするためのSupertestを使用します。

1. 必要なパッケージのインストール

npm install vitest supertest express --save-dev

2. モックエンドポイントの作成

app.jsファイルを作成しましょう。これは、ネストされたユーザーデータを返す典型的な本番環境のエンドポイントをシミュレートします。

// app.js
import express from 'express';
const app = express();

app.get('/api/user/:id', (req, res) => {
  res.status(200).json({
    id: req.params.id,
    username: 'johndoe',
    email: '[email protected]',
    role: 'admin',
    metadata: {
      lastLogin: '2023-10-01T10:00:00Z',
      preferences: { theme: 'dark', notifications: true }
    }
  });
});

export default app;

3. 最初のスナップショットの作成

次に、app.test.jsを作成します。すべてのキーを個別にチェックするのではなく、toMatchSnapshot()を使用して構造全体を固定します。

// app.test.js
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from './app';

describe('GET /api/user/:id', () => {
  it('保存されたユーザースキーマと一致すること', async () => {
    const response = await request(app).get('/api/user/123');
    
    expect(response.status).toBe(200);
    // この1行が数十行の手動チェックに取って代わります
    expect(response.body).toMatchSnapshot();
  });
});

npx vitestでテストを起動します。初回実行時、VitestはJSONレスポンスを含む__snapshots__フォルダを生成します。それ以降の実行では、実際のAPI出力がこの保存された「ゴールドスタンダード(正解データ)」と比較されます。

なぜスナップショットが手動アサーションより優れているのか

従来のアサーションはビジネスロジックの検証には適していますが、データ構造の検証においては非常に脆いです。APIが20個のフィールドを持つネストされたオブジェクトを返す場合、テストファイルは買い物リストのようになってしまいがちです。

expect(res.body.username).toBe('johndoe');
expect(res.body.role).toBe('admin');
expect(res.body.metadata.preferences.theme).toBe('dark');
// ...さらに30行ほど続く

このアプローチは危険です。新しいフィールドを追加しても、テストの更新を忘れがちです。フィールドを削除しても、そのフィールドを明示的にチェックしていなければテストをパスしてしまうかもしれません。スナップショットテストは、レスポンス全体を一つの不変の契約として扱います。

toMatchSnapshot()を実行すると、Vitestはディープイコール(深い階層までの等価性)チェックを行います。ソースコードでusernameuser_nameに変更すると、Vitestはエラーを投げ、ターミナルに色分けされた差分(diff)を表示します。これにより、マージ前に何が変更されたかを正確に確認せざるを得なくなります。

「動的なデータ」の扱い方

現実のAPIは静的ではありません。自動インクリメントされるID、ランダムなUUID、ISOタイムスタンプなどを返します。スナップショットが"lastLogin": "2023-10-01..."を期待しているのに、APIが今日の日付を返すと、テストは毎回失敗してしまいます。

これを解決するのがプロパティマッチャー(Property Matchers)です。これはVitestに対して「このフィールドが存在し、文字列であることを確認してほしいが、具体的な値は気にしないでくれ」と伝えます。

it('変動するタイムスタンプを無視する', async () => {
  const response = await request(app).get('/api/user/123');

  expect(response.body).toMatchSnapshot({
    metadata: {
      lastLogin: expect.any(String) // テストは特定の値を無視するようになります
    }
  });
});

これにより、完璧な妥協点が得られます。厳格な構造検証を行いながら、時間が経過するたびにテストが失敗するというストレスから解放されます。

意図的な変更があった場合は?

時には、実際にAPIを変更したい場合もあります。意図的にフィールド名を変更した場合は、テストを書き直す必要はありません。次のコマンドを実行するだけです。

npx vitest -u

-uフラグ(update)は、古いスナップショットを新しいデータで上書きします。不注意な変更が紛れ込んでいないか確認するため、コミット前にスナップショットファイルのGitの差分を必ずレビューすることをお勧めします。

現場で得た教訓

スナップショットテストは強力ですが、やりすぎには注意が必要です。数十のマイクロサービスを管理してきた経験から、メンテナンス性を保つためのルールをいくつか紹介します。

  • 「スナップショット疲れ」を避ける: {"status": "ok"}しか返さないようなヘルスチェックに対してスナップショットを使わないでください。単純なものには単純なアサーションを使い、スナップショットは複雑なデータ構造のために取っておきましょう。
  • サイズを小さく保つ: エンドポイントが500件のレコードを返す場合、配列全体をスナップショットにしないでください。最初の1件とページネーションのメタデータだけをスナップショットにします。巨大なスナップショットファイルは、プルリクエストでレビュー不可能です。
  • スナップショットをドキュメントとして扱う: 'GET /orders should return a detailed invoice'(GET /orders は詳細な請求書を返すべき)のように明確なテスト名を使用してください。これにより、スナップショットファイルがチームにとっての「生きたAPI仕様書」として機能します。

手動アサーションから脱却したことで、私のチームはメンテナンスに費やす時間を何百時間も節約できました。破壊的変更が発生した瞬間にそれを捉えることができ、テストはようやくデータの現実を反映するようになりました。もう、JSONフィールドが足りないせいで午前2時に起こされることはありません。

Share: