Shin x Blog

PHPをメインにWebシステムを開発してます。Webシステム開発チームの技術サポートも行っています。

WebAPI の自動テスト戦略

テストコードを実装する際に単体テストで書くか、統合テストで書くか迷う場面はないでしょうか。本エントリでは、私なりのテスト戦略についての考えをまとめました。

概要

  • 単体テストと統合テストの特徴
  • テスト戦略
  • 統合テストの懸念への対応

NotebookLM による音声概要を作成しました。よくまとまっているのでこちらもどうぞ。 https://notebooklm.google.com/notebook/1ae5c4a6-3924-46da-9d82-2c6b04782545/audio

対象アプリケーション

Web アプリケーションにおけるサーバサイドアプリケーション、特に UI ではなく JSON のような汎用データをレスポンスとして返すようないわゆる WebAPI を対象とします。

WebAPI は、永続化レイヤとしてデータベースを利用することを想定していますが、他のデータストアやバックエンド API を利用するケースも同様に扱えるかと思います。

ここでは、アプリケーション構成は下記のような一般的なレイヤードアーキテクチャとなっているとします。

本エントリにおける単体テストと統合テスト

単体テストや統合テストが具体的にどのようなものを指すかはコンテクストによるので、本エントリでは下記のように定義します。

なお、本エントリでは E2E などの他のテストは扱いません。

単体テスト(ユニットテスト)

単体テストは、一般的な定義と同様に単一のユニット(コンポーネント)を検証します。ここでいうユニットとは、オブジェクトやメソッド、関数を指します。

テストコードは、xUnit のようなテスティングフレームワークを利用し、アプリケーションと同じプログラミング言語で実装します。

テスト対象が別のコンポーネントを利用する場合もありますが、その場合にテストダブルで代用するか否かは問いません。ただし、データベースのようなプロセス外の外部コンポーネントアクセスを行う場合はテストダブルに置き換えます。

統合テスト(結合テスト、API テスト、フィーチャテスト)

統合テストは、WebAPI 全体の検証を行います。具体的には、API に対して HTTP リクエストを擬似的に送信し、API 実行結果として HTTP レスポンスを検証します。データベースの値を変更する API では想定された値が格納されているかを検証します。

テストコードは、単体テストと同様に xUnit のようなテスティングフレームワークで実装します。いわゆる API テストでは、Postman のような外部ツールを利用するケースもありますが、ここではテストコードの柔軟性やツール間のスイッチングコストを考慮して xUnit を利用します。

テスト実行において、認証ミドルウェアなどはテストダブルに置き換えたり無効化するケースもありますが、基本的にはデータベースを含めて本物のコンポーネントを利用します。(もちろん特に外部コンポーネントの場合は、本物を利用するのが難しい or 手間とメリットが合わないケースもあると思うので、その際は代用するケースもあります。)

具体的なコード例は下記のようになります。これは Laravel を利用した統合テスト例です。Act で /tasks に POST リクエストを送信し、Assert では HTTP レスポンスと tasks テーブルのレコードを検証しています。

<?php
// snip
    #[Test]
    public function タスクが追加される(): void
    {
        // Arrange
        // テストに必要なレコードを追加
        $userId = User::create([
            'name' => 'ユーザ1',
            'email' => 'a@example.com',
            'password' => 'password',
        ])->id;

        // Act
        // API 実行
        $response = $this->postJson('/tasks', [
            'title' => 'タスク1',
            'description' => '説明1',
            'due_date' => '2024-12-31',
            'is_completed' => false,
            'assigned_user_id' => $userId,
        ]);

        // Assert
        // レスポンス検証
        $response->assertStatus(201);
        $id = $response->json('id');
        $this->assertIsInt($id);

        // データベースレコード検証
        $this->assertDatabaseHas('tasks', [
            'id' => $id,
            'title' => 'タスク1',
            'description' => '説明1',
            'assigned_user_id' => $userId,
            'due_date' => '2024-12-31',
            'is_completed' => false,
        ]);
    }

単体テストと統合テストの特徴

単体テストと統合テストの特徴は下記のようになります。どちらもテスト実装によって変化していく性質ではあるのですが、おおよその傾向を示しています。それぞれ特徴が異なるので、必要に応じて組み合わせてテストしようというのが本エントリの趣旨です。

単体テスト 統合テスト
テスト対象(関心事) コンポーネント WebAPI(コンポーネント結合)
検証範囲(スコープ) 狭い 広い
テストコードの実装 容易(※1) やや手間がかかる
テストコードの複雑さ シンプル(※1) 複雑(※2)
実行速度 速い 遅い(単体テストに比べると)
並列実行 容易 工夫が必要
エラー箇所の特定 容易(※1) やや手間がかかる

※1: テストダブルの利用によって変化する箇所です。テストダブルを利用すると、実装の容易さや複雑さ、偽陽性と偽陰性への耐性が悪化する可能性があります。

※2: 統合テストはどの API のテストでも HTTP リクエスト送信でテストを実行するという同じ構成になるので理解しやすいという面もあります。

テスト方針

テストコードを書く際にベースとなる考え方は以下です。

  • 同じ効果ならテストは少ない方が良い。
  • 自動テストで幅広い範囲を検証できる方が良い(手動検証の範囲を減らしたい)。
  • 実装容易性や可読性、保守性、実行時間も考慮してバランスを取りたい。

テスト戦略

単体テストと統合テストの戦略を考えてみましょう。

ここでは、単体テストのみ、統合テストのみ、両者を組み合わせるパターンを示します。単体テストのみ、統合テストのみというのは極端なパターンに見えますが、現場では見られるパターンでもあります。

単体テストのみ

単体テストは実装しやすく、実行速度も速い上に並列化も容易なので開発を後押しするテストとしてとても有効です。またテストピラミッドが浸透したことで単体テストに力を入れる向きもあります。

一方、検証範囲が狭いので、API をテストする場合は多数のコンポーネントに対する単体テストを実装する必要があります。また、データベースのような外部コンポーネントはテストダブルに置き換えるので実際の挙動を検証できません。

コントローラやユースケースのように別コンポーネントの実行をコーディネートすることが役割のコンポーネントでは、テストダブルをセットアップする手間とテストするメリットが合わずにテスト実装がスキップされるケースもあります。

単体テストのみの場合、実装が容易、実行速度が速いなどのメリットはありますが、テスト範囲が狭くなる(別のテストでカバーしないといけない範囲が広くなる)懸念があります。

統合テストのみ

統合テストは検証範囲が API 全体と広く、データベースも利用するので実際の動作に近い挙動を検証できます。これによりコンポーネントの結合による問題をチェックできます。1 つのテストで幅広い範囲を検証できるのが強みです。

一方、主にデータベース関連でテスト実装に手間がかかり、テスト実装が複雑になりがちです。また、実行時間は単体テストより遅くなります。

ロジックの検証を全て統合テストで行う場合、ロジックの網羅性を検証するために統合テストを書くのはコストに合わないことになる上(1ms 未満で検証できるものを 100ms かけて検証する)、テスト入力(HTTP リクエスト)とロジックの距離が遠いのでテストケースを作る手間が上がったり、理解が難しくなるケースがあります。

結合テストのみの場合、テスト範囲は広く取れるメリットはありますが、高価(テスト実装、実行、理解、保守等)なテストを多数実装することになる懸念があります。

単体テストと統合テストを組み合わせる

上述したように、単体テスト、統合テストは特性が異なるので、組み合わせることで効果的なテストを実装できます。では、どのように組み合わせるのが良いのでしょうか。

テスト方針から考えると、1 つのテストで広い範囲(API 全体)を検証できる統合テストが有効です。また統合テストはデータベースを利用するので本来の挙動に近い検証ができるのも強みです。一方、統合テストは単体テストよりも高価なので統合テストのみで全てを検証するのはコストパフォーマンスがあまり良くありません。

そこで、統合テストで API 全体をカバーしておき、ドメインモデルなどのコンポーネントロジックは単体テストで網羅するというパターンが良いと考えています。

コンポーネント別のテストガイドライン

単体テストと統合テストを組み合わせる場合、テスト対象によってどちらで検証するかを選択することになるのでチームで共有できるガイドラインを用意しておくと良いです。

コンポーネント別のテスト方法をまとめたのが下表です。

検証テスト
コントローラ 統合テスト
リクエストバリデーション 単体テスト
ユースケース 統合テスト
ドメインモデル 単体テスト
インフラストラクチャ 統合テスト
データベース 統合テスト

コントローラやユースケースは、通常複雑なロジックは持たずに他のコンポーネントの実行をコーディネートするのが仕事なので統合テストでカバーします。これらは他のコンポーネントに依存することになるので、単体テストにするとモックなどのテストダブルが多数必要になり、労力の割には効果が薄いという面もあります。(テストダブルに置き換えることによって偽陰性、偽陽性が誘発される懸念もあります)

リクエストバリデーションやドメインモデルは、統合テストではコンポーネントが呼ばれていることのみ確認し、コンポーネントロジックの検証は単体テストでカバーします。統合テストでこうしたコンポーネントロジックを検証することも可能ですが、高価な統合テストを使うよりも安価な単体テストで十分に検証した方がお得です。

インフラストラクチャは、データベースアクセスが仕事なので統合テストで検証します。

ただし、これはガイドラインであり、場合によってはコントローラやユースケースに複雑なロジックが組み込まれているようなケースもあります。このような場合はロジックを別コンポーネントに切り出してこのコンポーネントの単体テストを書くのが望ましいですが、統合テストでロジックを検証したり、ユースケースの単体テストを書くケースもあり得ます。

統合テストによるテスト

統合テストではすべての API に対して一つ以上のテストを書きます。テストパターンとしては下記のようなものがあります。少なくとも最も広い範囲を検証できる最長のハッピーパスは実装しておきます。

  • 正常系
    • 最長のハッピーパス
    • 最長のハッピーパス以外で、特に実行コンポーネントが変化したりデータベースアクセス処理が異なるなどケアしたいケース(ex. 保存テーブルが異なる等)
  • 異常系
    • エラーレスポンスを返すケース

これ以外も不安に感じる箇所のテストや不具合が生じた箇所のテストなどは任意に追加していきます。ただし、上述したように統合テストではコンポーネントロジックの網羅性は検証しません。例えばバリデーションの場合、異常値であればバリデーションエラーになるテストは記述しますが、個々のバリデーションルールは検証しません。当該コンポーネントが API の中で実行されているかを検証するのみです。バリデーションルールのテストは必要であれば単体テストで検証します。

実際のところ、すべての API についてテストを書くのが難しい場合もあります。そのような場合は下記のように優先順位をつけて、優先度の高いものから実装していく、もしくは優先度の高いもののみ実装すると良いでしょう。

  • ドメインの重要度が高い API
  • 上記以外の更新を伴う API
  • 上記以外の参照系 API

テストピラミッドとテストダイヤモンド

テスト戦略における単体テスト、統合テスト、そして E2E テストのバランスを示すモデルとして、テストピラミッドやテストダイヤモンドがあります。

どちらも E2E テストよりも単体テストや統合テストを重視している点では共通しているのですが、テストピラミッドでは単体テストを、テストダイヤモンドでは統合テストを重視しています。

個人的にはこの両者の違いはそれほど意識する必要は無いと考えていて、上述したような統合テストをベースに単体テストを追加していくアプローチを取ればどちらのモデルになるかは単体テストの数次第になるためです。

つまり、アプリケーション初期実装時にあるような単純なデータベースの CRUD アプリケーションの場合は統合テストがメインとなり、単体テストは少数になるのでテストダイヤモンドのような構成になります。そこから開発が進んで複雑なドメインロジックが増えれば単体テストの割合が増えてくるのでテストピラミッドの形に近づいていくというわけです。

このようにテストの構成はアプリケーションの性質によって変化していくので、テスト方針が定まっていればテストピラミッドかテストダイヤモンドかは選択する必要はなく、単にその状態を示すモデルに過ぎないと考えられます。

統合テストの懸念

テストコードを日常的に実装している方で単体テストを書くのに懸念を持つ方は少ないと思うので問題無いのですが、統合テストを書くのに懸念があるという意見がしばしば見られます。それぞれについて見ておきます。

統合テストが無い

単体テストはテスティングフレームワークがあればすぐに書くことができますが、統合テストに関しては実行する基盤が必要となります。これは HTTP リクエストの送受信を擬似的に行う処理やテスト用データベース環境、データのセットアップ、検証といったものです。

こうしたものは Web アプリケーションフレームワークに用意されていればこれを利用すれば良いです。一方、基盤が無い場合は自分たちで用意する必要があります。こうした基盤を構築するにはある程度の工数が必要となりますが、一度作ってしまえば後は各テストで流用できるので統合テストを実装するのであれば用意すると良いでしょう。

統合テストの基盤をどのように構築すれば良いか、どのような機能があると良いのかは Laravel のフィーチャテストが良くできているのでこれを参考にすると良いです。

laravel.com

共有データセットが辛い

過去に統合テストの実装経験があっても、その辛さを知っているがゆえに統合テストを避けるという意見もあります。私自身も過去に複雑でメンテナンスしづらいテストを書いた経験があるので辛さも分かります。

良く知られている辛みとしては、テストに利用するデータベースデータに関するものです。特に複数のテスト間で同じデータセットを共有する場合はテスト間の依存によって簡単にメンテナンスが難しい状態になります。例えば、あるテストのためにデータを変えると他のテストが落ちる(偽陽性)といったことが発生し、テストを変更しづらい状態になります。

これに対してはテストごとに独立したデータセットを用意し、それをデータベースに投入することでテスト間で依存関係の発生しないようにすることがポイントです。つまり、テストに必要な環境はそのテストで用意するというテスト実装の基本を行うというわけです。

統合テスト実装は複雑になりやすいので工夫が必要になります。本エントリでは触れないですが、機会があればこちらも書いてみたいと思います。

実行時間が遅くなりそう

統合テストを導入することで、CI が遅くなりそうという懸念もあります。これのポイントは遅く「なりそう」です。

単体テストに比べて遅くなるのは当然なのですが、それが 100ms 遅くなるのか、1s 遅くなるか、1m 遅くなるかは大きな違いです。遅くなったとしても CI でのテスト実行が許容範囲内であれば問題無いともいえます。

つまり、これは実際に一つ統合テストを実装してみて計測すれば良いということです。例えば、統合テスト 1 テストケースの実行が 100ms かかるとして、1000 テストケースでは 100s かかることになります。これにテストセットアップの時間を追加して、合計 300s かかるとします。あとはこの結果を元に統合テストを重要な API に絞る、全ての API に対して書くなどを判断すれば良いわけです。

統合テストが増えることによる CI 実行時間の増加が許容範囲を超えるような場合でも、並列化したり(これも一手間必要ではありますが)、統合テストを全て実行するタイミングを限定するなど方法は考えられます。

イメージで統合テストは遅いから使えないというのは勿体無いので実際の CI 環境で計測して検討してみてください。

まとめ

単体テストと統合テストについて見てきました。

やはり現場では慣れた手法、すでに実装されたテスト手法を利用することが多いので、どちらかに偏ったテスト傾向が見受けられます。テストは目的さえ果たしていれば良いので、どちらかのテストのみで問題が無いのであればそれでも良いかもしれません。

ただ、テストに課題感があるのであれば、単体テストと統合テストの特徴を理解した上で、コンポーネントやレイヤ、機能に対してどのようなテストで検証するのが良いかというのを見直してみると良いでしょう。

参照