パイプラインの最前線に単体テストを配置する理由

金曜の午後にコード変更をプッシュしたと想像してください。ビルドは通り、デプロイも成功し、あなたは帰宅します。土曜の朝、電話がアラートで光ります。割引計算が顧客の注文に負の値を適用しているのです。ロジックはレビューでは正しく見えました。しかし、クーポンコードとセール価格が組み合わさると合計がマイナスになるというエッジケースを誰も見つけられませんでした。

これこそが、単体テストが存在する理由のバグです。単体テストが洗練されているからではなく、高速で、早期に、そして分離された状態で実行されるからです。単体テストは、あらゆるデリバリーパイプラインにおける最初の防御線です。

単体テストが実際に確認するもの

単体テストは、ある振る舞いが開始されるエントリポイントから、その意味のある一つの振る舞いを検証します。バックエンドサービスでは、そのエントリポイントはRESTエンドポイントやユースケースかもしれません。リクエストは、コントローラ、サービス、ドメインロジック、リポジトリ境界といった実際の内部レイヤーを通過することが許されます。テストは、あるメソッドが別のメソッドを呼び出すことを証明しようとするのではありません。システムが意味のある入力に対して正しく応答することを証明しようとしています。

重量と宛先に基づいて配送料を計算する関数がある場合、単体テストは以下を確認できます。

  • 標準重量は標準料金を返す
  • 重量ゼロは料金ゼロを返す
  • 負の重量はエラーまたはゼロを返す
  • 最大重量は上限値を返す

単体テストが証明しようとすべきでないのは、実際のデータベースがその料金を正しく保存するか、実際の決済ゲートウェイがそれを受け入れるか、隣接するサービスが実際に利用可能かどうかです。これらは他のテストタイプの関心事です。

単体テストの価値は狭いですが深いものです。周囲のシステムが制御されている場合に、特定の振る舞いがまだ機能するという確信を与えてくれます。後でコードを変更するとき、単体テストがパスすれば、すでに検証済みの振る舞いを壊していないことがわかります。

分離の原則

単体テストを高速かつ信頼性高くするには、周囲の世界を制御する必要があります。内部アプリケーションレイヤーは通常通り動作させるべきです。外部の隣接要素がテストの結果を左右してはいけません。これは通常、実際の本番データベース接続、ライブのサードパーティHTTPコール、他の実行中のサービスへの依存がないことを意味します。振る舞いがデータを必要とする場合、テストは制御されたデータ、インメモリ/ローカルのテストデータベース、またはシステム境界でのフェイク、モック、スタブを使用できます。

これが、単体テストを「メソッドごとに一つのテスト」や「クラスごとに一つのテスト」と定義すべきでない理由です。その定義は機械的すぎて、チームを実装の詳細のテストに押しやりがちです。単体テストは、関連するエントリポイントからの振る舞いテストであり、外部の世界が十分に制御されているため、障害がテスト対象の振る舞いにまで遡れるものとして理解する方が適切です。

モバイルコードは有用な例です。一部の振る舞いはモバイルランタイム内でのみ意味を持ちます。その場合、エミュレータやシミュレータを使用しても、テストが自動的に間違っているわけではありません。問題は、テストが依然として一つの振る舞いに焦点を当て、その周囲の依存関係を制御しているかどうかです。そうであれば、パイプライン内で単体テストの目的を果たすことができます。

この分離こそが単体テストを高速にします。典型的なバックエンドサービスの適切に書かれた単体テストスイートは、数分ではなく数秒で完了します。コンテナを起動したりテストデータベースに接続したりする統合テストと比較してください。それらには数分かかります。

速度が重要なのは、高速なテストほど頻繁に実行されるからです。開発者はコードをプッシュする前にローカルで実行します。CIパイプラインはビルド直後に実行します。何かが壊れた場合、30分かかる完全なテストスイートを待つことなく、数秒または数分以内にわかります。

パイプラインにおける単体テストの位置づけ

単体テストは、コードがコンパイルまたはビルドされた直後、パイプラインの最も初期の段階に属します。論理は単純です。アプリケーションが公開する基本的な振る舞いがすでに壊れているなら、実際の隣接システムに依存する低速なテストを実行する意味はありません。

典型的なパイプラインステージの順序は次のようになります。

以下は、CI設定ファイルでの最初のステップの実用的な例です。

# .gitlab-ci.yml または類似のCI設定
stages:
  - build
  - test
  - deploy

build:
  stage: build
  script:
    - npm install
    - npm run build

test-unit:
  stage: test
  script:
    - npm test -- --coverage
  only:
    - merge_requests
    - main
  1. コードのビルドまたはコンパイル
  2. 単体テストの実行
  3. 静的解析またはリンターの実行
  4. コンテナイメージまたはアーティファクトのビルド
  5. 統合テストの実行
  6. ステージング環境へのデプロイ
  7. エンドツーエンドまたは受け入れテストの実行
  8. 本番環境へのデプロイ

ステップ2で単体テストが失敗した場合、パイプラインは停止します。コンテナはビルドされず、ステージング環境は占有されず、根本的なロジックが間違っているために結局失敗する統合テストを待つ時間も無駄になりません。

これが高速フィードバックループです。バグを早期に発見するほど、修正コストは低くなります。単体テスト中に見つかったバグは数分で修正できます。本番環境で見つかったバグは、インシデント対応、ロールバック手順、顧客への連絡、場合によってはデータ修復が必要になります。

単体テストだけでは不十分な場合

単体テストには盲点があります。実際のシステムでコンポーネントが連携することを検証できないのです。チェックアウトの振る舞いが外部の決済APIに依存している場合、単体テストは、決済依存関係が成功、失敗、タイムアウト、または不正なデータを返したときのコードの振る舞いを確認できます。しかし、実際の決済APIがリクエスト形式を受け入れ、認証を処理し、期待される応答構造を返すかどうかは教えてくれません。

そのためには統合テストが必要です。しかし、単体テストもここで役割を果たします。コード構造が正しいこと、パラメータが正しい順序で渡されること、エラーハンドリングが期待通りに動作することを検証します。単に実際の統合チェックを置き換えることはできません。

もう一つの制限は、単体テストが設定の問題、環境の違い、インフラストラクチャの問題をキャッチできないことです。単体テストでは完全に機能する関数でも、本番データベースの照合順序が異なっていたり、必要な環境変数が欠けていたりすると、本番環境で失敗する可能性があります。これらの問題には異なるテストアプローチが必要です。

単体テストはどの程度で十分か

答えはリスクに依存します。関数が、誤りが金銭的損失、データ破損、または安全上の問題を引き起こす可能性のあるコアビジネスロジックを実装している場合、通常ケース、エッジケース、エラーケース、境界条件をカバーする徹底的な単体テストが必要です。

関数が単にデータを変換せずにある場所から別の場所に渡すだけの場合、パススルーが正しく機能することを確認する単一の単体テストで十分かもしれません。些細なコードに何時間もかけて網羅的なテストを書くのは、時間の有効活用ではありません。

実用的なアプローチはリスクベースのテストです。コードベースのどの部分が、もし失敗した場合に最も高いリスクをもたらすかを特定します。そこに単体テストの努力を集中させます。リスクの低いコードについては、明らかなミスをキャッチするのに十分なテストだけを書きます。

パイプラインにおける単体テストの実践的チェックリスト

  • 単体テストは、統合テストやエンドツーエンドテストの前に実行される
  • 単体テストはコードベース全体で数分以内に完了する
  • 単体テストは外部サービス、データベース、ネットワークアクセスを必要としない
  • 各単体テストは、メソッドやクラスではなく、関連するエントリポイントからの一つの意味のある振る舞いを検証する
  • テストは決定論的である:同じ入力は常に同じ結果を生成する
  • 失敗した単体テストはパイプラインを即座に停止する
  • 開発者はプッシュ前に同じテストをローカルで実行できる

具体的な要点

単体テストは、完璧なカバレッジ数を達成することではありません。最も一般的なクラスのバグを、最小の時間とインフラコストで可能な限り早期にキャッチすることです。パイプラインの最初に配置し、高速に保ち、分離し、最も重要なロジックに焦点を当ててください。他のすべてはその基盤の上に構築されます。