🔍

APIテスト アーキテクチャ

2022/10/21に公開

テックリードとして2つのプロジェクトのテスト自動化を推進しています(本業)。

この記事では、プロジェクトでの経験を踏まえ、APIテストの位置づけ、システム上のスコープ、2つの方式について説明します。

この記事の目的

  • これから新規/既存WebシステムのAPIテストを自動化する人に向けて、APIテストの位置づけ・システム上のスコープ・2つの方式を紹介し、よりよいアーキテクチャを選択してもらうこと

想定読者

この記事は、テストピラミッドの考え方を採用し、以下のような方針でテスト自動化を進めることが前提です。

  • ユニットテスト、UIテスト、APIテストのように、粒度・観点の違うテストをバランスよく用いて、品質を効率的に担保する
  • ユニットテストなどのより小さい粒度のテストで品質の大部分をカバーし、APIテスト、E2Eテストなどのより大きい粒度のテストをできる限り削減する

上記の前提を読んでも理解できなかった方は、以下の記事を先に読んでいただけますと幸いです。

自動テスト戦略:テストピラミッド

0. APIテストの位置づけ

APIテストは、Webサービス単体の外部仕様のうち、システム間のインターフェースであるAPIを検証するためのものである。

テストピラミッドの考え方に従い、以下のことが重要である。

    1. APIテストは、UIテストやユニットテストと切り離して行う。
    1. APIテストをできるだけ削減する
    1. APIテストの粒度を誤らない。特に、内部仕様に踏み込まないようにする

1. APIテストのシステム上のスコープ

背景

一般に、Webサービスは複数のAPIサーバーやデータベースから構成される。

このとき、システムをどのように分割してAPIテストを行うかが重要である。

例えば、以下のようなシステム構成で、APIテストを行いたいとする。

このとき、システムをどのように分割してAPIテストを行うのか、意識的に決める必要がある。

例えば、各APIサーバーやデータベースをそれぞれ独立したAPIとみなし、個別にAPIテストを行うことも可能である。この場合、以下のようにシステムが分割され、APIテストが行われる。

もう一つの極端な方法は、全てを接続して1つのサービスとみなし、APIテストを行うことである。

結論

おすすめは、以下のようにAPIサーバーとデータベースを1つの(マイクロ)サービスと捉えてAPIテストすることである。つまり、別APIサーバーは含めないが、データベースは含める。

理由: APIサーバー間を接続しないのはなぜか

別APIサーバーを含めてしまった場合、APIサーバー間をまたいだ、より粒度の大きいテストとなってしまい、以下のようなデメリットがある。

  • パターンがAPIサーバーごとの振る舞いの掛け算となって、ケース数が膨大になり、開発コストが増加する
    • 例えば、A APIサーバーがECサイトで様々なタイプの商品が注文でき、B APIサーバーがクーポンやキャンペーンによる割引計算を管理するとする
    • 2つを結合してのAPIテストは、Aの注文APIの正常系を5ケース、BのキャンペーンAPIのテストが8ケースだとすると、この掛け合わせで20〜40ケース程度になってしまうだろう
  • リクエストがA APIサーバー / B APIサーバーの両方を経由するため、ケースあたりの実行速度も低下する

これは、テストピラミッドの、「粒度の大きいテストに頼らず、粒度の小さいテストを品質の拠り所にするという」という原則にも通ずる。

参考...自動テスト戦略:テストピラミッド

加えて、APIテスト特有の課題もある。

  • A APIサーバーとB APIサーバーを開発しているチームが異なる場合、APIテストの作成や管理はチームをまたがったコミュニケーションが必要となり、コストが高い
  • B APIサーバーがAPIである以上、B APIサーバーはA APIサーバーを介したときに正常に動作することだけでなく、単体でも正しく動作することを担保したい。すると、全体を結合してのAPIテストと別に、B APIサーバー単体のテストもしたくなってしまう

したがって、API間はそれぞれ別の(マイクロ)サービスと捉え、個別にAPIテストすべきである。

理由:データベースを接続するのはなぜか

一方で、APIサーバーとデータベースをまとめて1つの(マイクロ)サービスとみなし、その外部仕様をAPIテストすることは理にかなっている。

データベースは、ロジックを持たず、単独での品質を保証すべきAPIではない。そのため、APIサーバーとデータベースを結合してもケース数が増えることはない。

  • パターン数が掛け算にならない
    • データベースにはロジックがないため。
  • データベース単体での品質を保証する必要がない
    • データベースはA APIサーバー以外から使われることはないため。APIサーバーと結合して想定どおりに振る舞うなら、それで必要十分である。

もし仮に、データベースをスタブしてAPIサーバーだけをAPIテストすると、データベースを独立したサービスとみなし、データベースに対してもAPIテストを行う必要が出てきてしまう。これは、データベースが想定したクエリを受け取れるかといったテストになるだろう。

しかし、データベースはロジックを持たず、APIサーバーと密に結合している。そのため、APIサーバーとデータベースを個別にAPIテストしようとすると、以下のようになってしまう。

  • ちょっとしたAPIサーバーの仕様変更のたびに発行されるクエリは変わるかもしれない
  • そのため、APIサーバーで仕様変更が起きるたびに、APIサーバーとデータベース両方のAPIテストを修正する必要がある

一方のAPIサーバーとAPIサーバーの間は、それぞれが一定の責務を持ち、疎結合性を意識したAPI(例えばRESTやGraphQLのような)によって連携している。B APIサーバー内でメールの文面を変えたり、B APIサーバー用のデータベースへの読み書きを最適化しても、B APIサーバーの外部仕様に影響が出てAPIテストの後方互換性を破壊することは少ない。

これは、疎結合・高凝集なコンポーネントの間は分割してテストするべきだが、密結合なコンポーネント間は分割してテストするデメリットのほうが大きいことの例である。

以上のことから、APIサーバーとデータベースは1つの(マイクロ)サービスとみなし、まとめてAPIテストを行うべきである。

2. 2つのAPIテスト方式

背景

APIの振る舞いは、複数のレイヤから実現されており、以下図のようにモデル化できる。

クライアントからのHTTP等によるAPIリクエストは、

  • サーバーのハードウェア(1)に到達し、OS(2)に対して外部からの通信として伝わり、サーバープロセス(3〜5)に渡される。
  • サーバープロセスは、例えばNode.jsやJVMなどのランタイム環境(4)がアプリケーション(4〜5)を実行することで実現されている。
  • Webサーバーアプリケーションは、フレームワーク(4)を採用して作る場合が多く、その場合、フレームワークが私たちが書いたコード(5)を呼び出す。

APIテストの信頼度は、以下のように決まる。

  • APIテストでは外部仕様をテストしたいため、できるかぎり多くのレイヤをカバーできると、信頼度が高まる。
  • レイヤが高くなれば(右側であれば)高くなるほど、アプリケーションに近いためバグが発生しやすく柔軟であり、重要度が上がりテストもしやすい。
    • 例えば、ハードのレイヤをテストすることは難しい。本番と開発は全く同一のハードではありえないし、性能も大きく違うだろう。

一方で、APIテストにおいても当然、開発生産性が求められる。
レイヤは左にいけばいくほどテストがしづらいため、より多くのレイヤをカバーすれば、手間が増して開発生産性は低下する。

したがって、プロダクトの特性を踏まえて、このトレードオフを調整する必要がある。

今回は、2つのAPIテスト方式を説明する。
特に決まった名前はないように思われるが、便宜上この記事では以下のように命名する。

    1. サーバー起動方式
    1. テストコード方式

この記事における推奨は、より開発生産性が高いテストコード方式である。そのため、サーバー起動方式のデメリットや、テストコード方式のメリットを強調する形で説明する。

サーバー起動方式

プロダクション同様にサーバーを起動し、実際にHTTP等のリクエストを送り、レスポンスやサーバーに生成されたデータを検証する方式である。

この方式のメリットは、より確実にサーバーが起動した状態でAPIが正しく振る舞うことを検証できることである。

このテスト方式では、以下のようなレイヤをテストすることができる。ポイントだけ以下に述べる。

  • ハードのレイヤはテストできない
  • OSのレイヤはほとんどテストできない
    • OSのバージョンはUbuntuなど指定することで合わせられる場合もあるが、本番がサーバレスであったり独自OSであれば、合わせられない
    • OSのバージョンが仮に合わせられても、OSの設定を合わせられるのは難しい
      • コンテナベースのアプリケーションであれば可能
    • また、CI環境ローカルでWebサーバーに対してHTTPリクエストをすることになるため、OSが外部からリクエストを受け付けたときの挙動まではテストできない
  • ランタイム環境より下のレイヤは全てテストできる

この方法のメリットは、多くのレイヤをカバーできることである。

一方で、デメリットは開発生産性にあり、以下のような懸念 / 制約がある。

    1. CIを自動化するために開発コストがかかる
    • 適切な環境変数を設定してサーバーを起動したうえでテストを実行するのは、ひと手間かかる
      • 外部サービスに依存している場合、モックする必要があるため、スタブも併せて起動する必要がある
    1. データの投入に開発コストがかかる / 実行時間が長い
    • 例えば、APIが存在しないマスタデータの投入はどうするか
      • APIテスト用にデータが投入された状態のサーバーを起動することもできる。しかし、その場合テストケースごとのデータという形ではなくなるため、全てのテストケースに必要なデータを一元管理することになってしまい、保守性が低い
    • 例えば、購入データを5件履歴に表示するために、購入APIを5回呼ぶ必要が出てきてしまい、通信レイテンシで実行時間が長くなる
    1. XaaSやサーバレスなど、アーキテクチャの制約で実現できない場合がある
    • XaaSの制約でプレビュー環境へのリクエスト数制限があったりする
    • XaaSやサーバレスを活用している場合、サーバーの生成を自動化することが難しい / 完全にはできない場合がある

※この記事は後続のテストコード方式を推奨としているため、このセクションは開発生産性上のデメリットにフォーカスして解説している。

テストコード方式(本記事における推奨)

テストコード方式は、より手軽で、サーバー起動方式の懸念 / 制約をカバーできる方法である。

これは、ユニットテストと同じ方式で、jestやvitest、xUnitなどのテストフレームワークを活用する。

ユニットテストとの違いは、アサーションの対象、ソースコードに対する呼び出し方など、書きっぷりだけである。

この方式のメリットは、開発生産性である。次のように、サーバー起動方式の懸念点を大きく軽減することができる。

  • CIの自動化
    • テストコードなので、テストフレームワークのコマンドを叩くだけでよい
    • スタブもテストコードの仕組みで実現できる
  • データの投入
    • テストフレームワークの機能でAPI呼び出しをエミュレートすることで、通信レイテンシなしでAPIの処理を実行させることができる。
    • マスタデータも、テストケースごとにコードから直接データベースに投入できる。
    • テストケース間でのデータの独立性も、テストフレームワークの機能で簡単に実現できる。
  • アーキテクチャの制約を受けづらい
    • サーバレスの場合、CI環境ローカルで立ち上げることは難しくても、テストコードならライブラリの機能でAPIを呼び出せる場合等がある。
    • Salesforceのように、テストコードをサポートしているXaaSもある。

一方で、テストコード方式のデメリットは、サーバー起動方式のメリットの裏返しで、APIテストとしての信頼性にある。

テストコード方式でカバーできるレイヤは以下のとおり、サーバー起動方式より少ない。ポイントだけ以下に述べる。

  • テストコードによる実行になるため、ハードに加え、OSレイヤ / ランタイム環境レイヤもテストできない。
    • Webサーバープロセスを起動しないから、OSは関係がない。
    • ランタイム環境も異なるかもしれない。プロダクション環境では環境変数を設定したり、ランタイム環境にパラメータを渡しているだろう。このAPIテストでは、ランタイム環境はテストフレームワークのCLIを実行するだけである。
  • フレームワークレイヤは、上手くやればテストができる
    • 例えば、supertestのようなライブラリで、テストコード上でもHTTPリクエストが起きた場合の挙動をエミュレートできる。これにより、フレームワーク層を巻き込んでテストすることが可能である。

上記のようなデメリットがあるため、ミッションクリティカルなプロダクトであれば、より多くのレイヤをサーバー起動方式が望ましい場合もあるだろう。

しかし、逆に言えば、この方式はカバーしているレイヤこそ少ないが、最優先されるべきレイヤ2つはカバーできており、多くのプロダクトにとっては十分と考える。フレームワーク・コードの2レイヤさえカバーできていれば、アプリケーションの振る舞いの大部分はカバーでき、バグが発生するパターンが大きく絞り込まれるからである。

ランタイム環境レイヤがテストできていないことで起きうる問題は、「サーバー起動スクリプトのミス」等、ごく僅かなパターンだけだろう。また、ランタイム環境より下のレイヤは、そもそもアーキテクチャがサーバレスであったりした場合、サーバー起動方式を選択したとしてもカバーできない可能性もある。

むしろ、この方式の1番のリスクは、ヒューマンエラーにある。

テストフレームワークを使用しての記述となり、ユニットテストとの技術的方式の違いはないため、書き手次第でユニットテスト的なコードを書くこともできてしまう。

例えば、/users/{id}をテストするために、HTTPリクエストをエミュレートするのではなく、単にハンドラーを関数として呼び出した場合、このテストのAPIテストとしての信頼度は大きく低下する。

このハンドラーに/user/{id}という誤ったパスが割り当てられていたり、ミドルウェア(フィルター)等の機能で事前に意図しない処理が行われていても、検知できないからだ。

これは上から2つめのレイヤであるフレームワークレイヤを、テストに巻き込まない書き方ができてしまうということを意味する。

したがって、テストコード方式を選択する場合、チームとしてのAPIテストの書き方に関するガイドラインをしっかりと定めることが重要である。

まとめ

  • APIテストは、(マイクロ)サービスの外部仕様のうち、システム間のインターフェースであるAPIをテストするためのものである。
  • APIテストは、APIサーバーをデータベースをまとめて(マイクロ)サービスとして扱い、サービス単位で行うことがおすすめである。
    • データベースをスタブしてはならない。
    • 他のAPIサーバーを結合してテストしてはならない。
  • APIテストには、サーバー起動方式とテストコード方式の2つがある。それぞれ信頼性と開発生産性に強みがある。
    • このうちテストコード方式は、開発生産性が高く、多くのプロダクトにとって十分な信頼性を実現できると思われるが、書き手次第で信頼性の低いAPIテストにもなりうる。
    • そのため、チームとしてAPIテストの書き方に関するガイドラインを定めることが重要である。

追伸

よければTwitterもフォローお願いします!
@sumiren_t

Discussion