チーム開発の視点が変わる アジャイル開発の新常識 第15回 諦めてませんか?大規模システムのテスト自動化
*本コラムは、技術評論社「Software Design」2022年2月号に寄稿したコラムを掲載しています。
はじめに
連載第2回 (本誌2021年1月号)では、大規模なアジャイルプロジェクトにおけるコミュニケーション面・技術面の課題に対し、マイクロサービスを用いてシステムを複数のサービスとチームに分割するという方策を取り上げました。
一方で、システムの分割によりシステムを構成するコンポーネント数が増えていくと、複雑性が増していき、新たな課題が生まれてきます。今回はそのような課題と解決策について触れていきたいと思います。
リグレッションテストの重要性
システムの規模にかかわらず、テストを行うことは品質を高めるうえで重要です。その中でも複雑化したシステムにおいては、とくにリグレッションテスト(回帰テスト)が重要となります。
リグレッションテストとは、新たに機能を追加もしくは既存機能を改修した際、その機能と協調して動作する部分が、これまでどおり動作するかを確認するテストになります。
システムが複雑化・大規模化していくと、リクエストの処理に関わる要素は指数関数的に増加していきます。要素が増えるということは、それだけ障害発生の確率が上がりますし、障害が連鎖していき大規模な障害につながる可能性があるため、リグレッションテストが重要となります。
テスト自動化のコスト
アジャイルのように短い期間で頻繁にリリースを行うような開発スタイルでは、テストの自動化は必須であるといえます注1。
昨今のCIツールやテストツールの発展によりテスト自動化をやりやすくなってきてはいますが、すべてのテスト工程に対して自動化を行うのはやはりまだ難しいです。
連載第7回 でも触れていますが、Mike Cohnが提唱したテストピラミッド(図1)注2があります。ピラミッドの最下段にある単体テスト(UnitTests)は低コストで作成できるためテストケース数を増やすべきで、ピラミッドの最上段になるUI Tests(後述のe2e Test注3に相当)は作成・維持・実行で大きく人的・時間的なコストがかかるためテストケース数を減らすべき、というものです。
図1 Mike Cohn提唱のテストピラミッド
複雑化したシステムでのリグレッションテストにおいて、どのテストの工程が重要になるのかを考えてみます。テスト工程の定義に関してはプロジェクトによってさまざまだと思いますが、筆者が過去に参加したマイクロサービス開発プロジェクトでは下記のような定義をしていました(図2)。
- ・単体試験(Unit Test):1つのサービスの1つのクラスの1つのメソッドの機能を確認するテスト
- ・統合試験(Integration Test):1つのサービスの複数クラスと、そのサービスが利用するデータベース(DB)の一連の処理を確認するテスト
- ・統合試験(Integration Test):1つのサービスの複数クラスと、そのサービスが利用するデータベース(DB)の一連の処理を確認するテスト
図2 テスト工程のイメージ図
ここで、リグレッションテストがどの区分に当てはまるか考えてみます。リグレッションテストの定義でも述べたとおり、変更を加えた機能を参照するという特性上、別のアプリケーションとの連携を確認する結合試験に該当することが多くなります。結合試験はテストピラミッドの最上位に位置するため、非常にコストがかかります。
結合試験はなぜ大変なのか?
具体的に、結合試験ではどのような部分でコストがかかるのでしょうか?
まず前提として、複雑なシステムでも正しく設計・実装されていることが必要です。
たとえば複数のサービスで1つの処理を行うマイクロサービスの場合であれば、それぞれのサービスを独立してデプロイできる設計になっている必要があります。
仮にマイクロサービスにサービスA、B、Cがあり、AがBに強く依存するような設計になっており、Aのデプロイ前にBが動作していることが必須だとします。この場合、AとCだけに関わるようなテストをする場合でもBを常に起動させておく必要があるといった問題が発生します。
このように大規模化したシステムに対応するアーキテクチャでも正しく設計されていないと、結合試験どころか単体試験ですら大変になる可能性があります。また、正しく設計されていたとしても、下記のような理由から結合試験の難易度は高いと言えます。
本当に期待する動作をしているかの確認が難しい
多くのプログラミング言語でのテストライブラリでは、詳細な挙動(あるステップ時の変数の確認など)まで追跡して検証できますが、同じコードファイル、もしくは同じプロセス・サービス内しか追跡できないものがほとんどです。
そのため、複数サービスをまたぐようなテストの場合は、内部挙動を追えず最後のレスポンスでの確認しかできないブラックボックスなテストになりがちです。ブラックボックステストでは経由したすべてのサービスで期待したとおりの動作をしているかの確認や、テストに失敗した場合にサービスで問題が発生したかの確認が難しくなります。またサービス間の連携にメッセージキューなどを挟んだ非同期通信を行っている場合はさらに追従が難しくなります。
複数アプリケーションを動作させる検証環境の用意が必要
結合試験の特性上、複数アプリケーションをつなげて試験する環境を用意する必要があります。この環境構築のためにアプリケーションのデプロイ・設定・テスト用のデータを準備するといった作業が必要になり、手動テスト・CIによる自動テストどちらにしても実行にコストがかかるようになります。
このようなコストの問題に対する一般的なアプローチとしては、それぞれのサービスから外部サービスへアクセスする部分のダミーを用意し、サービス単体での単体試験を行いつつ、結合試験に近いことをする方法がよく利用されます。
外部サービスを呼び出す部分をMockに置き換える
前述のようなダミーはMockと呼ばれています。Mockは固定の決まった値を返却するもので、これを利用してテストを行います。
Mockには大きく分けて、プログラムコード内でライブラリなどを用いて完結する方法と、プログラムコード外にMockを用意する方法の2つがあります。前者が依存性注入注4を利用して「外部サービスを呼び出すクライアントをMockクライアントに置き換える」のに対して、後者は「Mock用のサーバを用意してテスト時の接続先をそのMockサーバに向ける」ような実装になります。
Mockの課題
しかしどちらの方法をとったとしても、気づかないうちにMockの挙動と実際の挙動に差異が生じる可能性があります。
一般的に、Mockを利用したテストを作っていく場合は、
- 1.API提供者がAPIを作成し、仕様をAPI利用者に向けて公開する
- 2.API利用者は仕様を基にMockを作成し、Mockを利用したテストを作成する
という手順で進めていきますが、ここでのAPI仕様はあくまで口約束に近いものですので、API利用者に通知されない間に提供者側が仕様を変更し、「Mockを利用したテストは通るのに、実環境で実際のサービスとつなげるとバグが発生する」といった問題が起こり得ます(図3)。
図3 Mockの問題点
この問題の対応策として、プロジェクト内でルールを決め、API提供者は仕様変更時に関連者に事前通知を行うといった方法が考えられます。しかしこの方法では、API提供者側で利用しているサービスリストといった情報を管理する手間が発生します。また人的な管理であれば通知ミスなどは容易に発生し得ます。もしくは、「新たに変更を加える際はversionでAPIエンドポイントを分ける」といったしくみにすれば解決できるかもしれませんが、こちらも複数バージョンを同時に運用させないといけない手間が生まれます。
Consumer-Driven Contracts testing(CDC)
このようなMockでの課題を解決する方法として、Consumer-Driven Contracts testing(CDC)というアプローチがあります。前述のとおり、Mockの場合APIに変更があった際に影響を受けるのは常にAPI利用者側になります。API提供者側が仕様を変更してシステム全体で不具合が発生しても、API利用者側が改修しないといけない場合が多いでしょう。
CDCではこのようなAPI変更に対する責任をAPI利用者(Consumer)側からAPI提供者(Provider)側に移すことができます注5。
CDCでは、Consumer側が「〇〇というパラメータでリクエストを送ったら□□というレスポンスがほしい」という情報を契約(Contract)としてProvider側に渡します。Provider側はこの契約どおりの動きができるかをテストします。契約に記載されていないAPIや、契約を破るような変更を加える場合はテストが通らなくなってしまいます。この場合は、Consumer側とコミュニケーションをとって仕様変更の調整をしなくてはなりません(図4)。
図4 CDCを利用したAPIの破壊的変更への対応
このようにAPIの破壊的な変更をProvider側のテストで機械的に弾けるため、先ほどMockの課題で挙げたような、API利用者が知らぬ間に仕様が変わるといったことを「プロジェクトのルール」でなく「ツールを利用したしくみ」として弾けるのが大きな利点です。
PACTによるCDCの実装
CDCはあくまで概念で、実際に利用する場合は何かしらライブラリを使うのが一般的です。
今回はPACTというライブラリを利用したCDCの実装を解説します。
サンプルプロジェクトの紹介
PACTを導入するためのベースとなるプロジェクトとして、簡易的なECサイトのバックエンドを用意してみました注6。
支払いのインターフェースとなるpaymentサービス、商品の金額・在庫数を管理するproductサービス、ユーザーの残高を管理するuserサービスを用意してみました。
PACTがマルチ言語に対応できるため、せっかくですのでpayment はGo言語で、product、userはNode.jsで用意してみました。
サンプルのためのシンプルな支払いフローを図5に提示します注7。今回はpaymentをAPI利用者側、productをAPI提供者側とし、paymentから呼び出すproductの情報を得るためのGET/product/:productIDのAPIに対してPACTを利用して契約を結んでいこうと思います。
GET/product/:productIDはproductIDをパラメーターにリクエストを送り、リスト1のような結果を取得するAPIになります。
図5 支払いフローのシーケンス図
リスト1 GET/product/:productID APIのレスポンス
PACTのCLIをインストール
次項以降のステップでPACTを含めたテストケースの実行にはPACTのCLIがインストールされている必要があります。macOSの場合は次のコマンドでインストールが可能です注8。
Consumer側でPACTの契約書作成を行う
最初にConsumer側となるpaymentアプリケーションでPACTを利用して契約書を作成します。Goプロジェクト配下で次のように実行し、pact-goをインストールします。
次にpact-goを利用したテストコードを作成します。
今回はproductとの契約を行っていきます。GET/product/:productIDのAPI呼び出しを行うコード注9に対して、pact-goを利用したテストコードを作成し、テストを実行することで契約書を作成できます。リスト2にテストコードの一部を掲載します注10。このテストコードを次のように実行します。
テストを行い無事パスすると、リスト2①のPactDirで記載しておいたディレクトリに契約書のJSONファイル(リスト3)が生成されます注11。
リスト2 契約書を作成するためのテストコード
リスト3 契約書の一部
Provider側で契約書の検証を行う
作成した契約書をもとにProvider側でproductサービスの検証を行っていきます。今回はNode.jsのシンプルなWebフレームワークであるExpress.jsでWebサーバを作成しています。リスト4にGET /product/:productIDのAPI定義部を一部掲載します注12。
リスト4 契約対象となるProviderのAPI
次にこのAPIに対して、payment側からもらった契約書を利用してテストを行っていきます。
Provider側の検証方法としてはいくつか方法があるのですが、今回はシンプルにWebアプリを立ち上げたうえで、PACTやmocha、chaiといったテストライブラリから契約書ファイルを読み込み、公開されているAPIに対して検証する方法で進めます。
まず、テスト用のライブラリをインストールします。
次にテストコードを作成します。このテストコードの一部をリスト5に記載します注13。
リスト5 Provider側のAPI検証テストコード
PACTで検証を行う際は、契約書に記載されたリクエストを受けた際に期待されるレスポンスを返せるように返却用のデータを準備してあげる必要があります。PACTのテストライブラリではstateHandlersというオプションで対応するproviderStateの検証を行う前に実行する処理を決められます。
リスト5では、②で実際のコードからテストデータ作成用の関数をインポートしておき、⑦、⑧から呼び出して、期待されるレスポンス用のデータを作成しています。その後に実際にリクエストをAPIを呼び出して検証を行います。
テストは下記のコマンドで実行できます。
契約書に違反していないレスポンスを定義しているため、このテストは無事通ります。
契約を破る変更は許容されない
ここで、ほかのAPI利用者から機能変更要求が発生したとします。たとえばグローバル対応をするため「通貨コードを追加し、値段も整数でなく小数点を含むものに変更しよう」という要求が発生した場合を考えてみます。
リスト6のように、返却するダミーデータの定義を変えてみます。すると、再度Provider側のテストを実施するとリスト7のように失敗するようになります。priceはIntegerの整数値を期待しているのに対し、小数値が返ってきたことでエラーとなりました。
リスト6 APIのレスポンス内容を変更
リスト7 PACTのテストエラーメッセージ
より実践的なPACTに関して
今回のサンプルではREST APIでの例を紹介しましたが、メッセージキューを挟むような非同期なやりとりでの契約もPACTはサポートしています。
また今回の例ではシンプルな方法として、契約書をConsumer側からProvider側にファイルを直接渡しましたが、このやり方では契約書ファイルの受け渡しやバージョン管理に手間がかかります。実運用を行う場合は契約書を単一のレジストリに登録でき、契約状態の視覚的な確認も行えるpact-broker注14というOSSの利用も検討したほうがいいでしょう。
CDCですべてのバグを未然に防げるわけではない
今回はPACTを利用したCDCのアプローチで、API利用者側のAPI仕様変更に伴う負担を軽減できることを紹介しました。
しかし、これはあくまでAPI変更に対する責任をProvider側に押しやっただけで、API利用者側がまったく手を付けずに仕様変更に対応できるわけではありません。ほかのAPI利用者の要望でAPIに破壊的な変更が入る場合は、API利用者もその変更に追従する必要があります。
PACTではAPI提供者側のテストで破壊的変更を検知できるしくみを提供してくれるため、破壊的な仕様変更があった際にステークホルダ全体を巻き込んだコミュニケーションを行うことになります。これは、アジャイルソフトウェア開発宣言注15で言及されている「個人との対話」を促していると言えます。
◆ ◆ ◆
本稿では、複雑化したシステムにおいて複数のサービスにまたがる機能を確認する結合試験の課題を解決する方法としてCDCという概念を紹介しました。
一方でCDCはMockアプローチの延長であるため、あくまで実際の通信を模した机上の定義といえます。そのため、実際にサービスにつなげると問題が見つかる可能性もあり得ます。これを見つけるためには、図1のテストピラミッドが示すようにコストはかかりますが、検証環境を用意して連結するサービスを立ち上げ、テストデータを流してみて各サービスのログやDBを確認するといった手動によるアプローチが必要になります。すべてのバグを未然に防げるわけではないことは留意しておきましょう。
※図は技術評論社の許諾を得て掲載しています。
- 注1)連載第7回(本誌2021年7月号)を参照。
- 注2)Mike Cohn, Succeeding with Agile : SoftwareDevelopment Using Scrum, Addison-Wesley Pub. Co.,2009
- 注3)e2eの定義はまちまちだとは思いますが、ここではAPIを外部に公開するとしたときに、自分たちのシステムで利用するすべてのソフトウェアを実際に動かし、APIのリクエスト受付からレスポンスの返却までを確認する試験とします。
- 注4)オブジェクト間の依存関係を直接コードに書かず、コンストラクタや設定ファイルを用いてオブジェクトの外部から依存性を注入することを指します。Mockを用いる場合は、コードに変更を加えることなく、外部サービス呼び出し部をMock呼び出しに変更する目的で利用されます。
- 注5)CDCではAPI利用者をConsumer、API提供者をProviderと呼びます。
- 注6)https://github.com/akasetil/pact-sample
- 注7)実際には2層コミットなどもっと複雑な処理が必要ですが、今回はデモ用ということでシンプルにしています。
- 注8)他環境の場合はPACTのドキュメントを参考にしてインストールしてみてください。 https://docs.pact.io/
- 注9)コードの全文は注6のGitHubリポジトリ内「pact-sample/payment/service/payment.go」にあります。
- 注10)コードの全文は注6のGitHubリポジトリ内「pact-sample/payment/service/payment_test.go」にあります。
- 注11)コードの全文は注6のGitHubリポジトリ内「pact-sample/pactfile/payment-product.json」にあります。
- 注12)コードの全文は注6のGitHubリポジトリ内「pact-sample/product/src/」配下にあります。
- 注13)コードの全文は注6のGitHubリポジトリ内「pactsample/product/tests/provider.spec.js」にあります。
- 注14)https://github.com/pact-foundation/pact_broker
- 注15)https://agilemanifesto.org/iso/ja/manifesto.html