先日当社のサービスである Synergy! の新機能として、シナリオ機能をリリースしました。この機能の開発では、フロントエンドとバックエンドの間のAPIの正しさを担保するために、テスト支援ツールであるPactを導入しました。
Pact 導入の先行例はいくつも見つけることができるのですが、フロントエンドに適用した例はあまり見つけられません。今回の記事では、Pact をフロントエンドに導入した一連の流れを紹介します。
k8sやCIを積極的に触っていた経験をいかして仕事をしています。
カレーが好きで、豆板醤や甜麺醤など中華系スパイスを使ったカレーを作っています🍛
Pactとは - 導入目的
Pactは、「コンシューマ駆動契約テスト(Consumer-Driven Contract testing)」というアプローチに則って、リクエストとレスポンスをテストするためのツールです。APIのリクエストとレスポンスが、使う側と提供する側で仕様の誤りなく実装されていることを確認します。
コンシューマ駆動契約テストの文脈で使われる用語について
聞き慣れない言葉が出てきたかもしれません。コンシューマが駆動する契約のテスト…?
「コンシューマ」がきっかけとして「契約」のテストをするということはなんとなくわかります。ではここでの「コンシューマ」と「契約」とは。
「コンシューマ」はAPIを使う側を指します。ですのでAPI提供側にももちろん名前がついていて、そちらを「プロバイダ」と呼びます。「契約」はコンシューマとプロバイダのあいだでAPIのリクエストとレスポンスをどのようなものにするかという約束です。ここから、本記事でもコンシューマ、プロバイダ、契約という名前を使って記事を進めていきます。
コンシューマ駆動契約テストとは
では改めて、コンシューマがきっかけとして行われる契約のテストとは。コンシューマがAPIのリクエストとレスポンスを契約としてまとめます。契約が記述されたものをプロバイダに渡せば、そこにはリクエストとレスポンスが書かれているから、APIがその内容に沿っているかということをプロバイダがテストできます。これがコンシューマ駆動契約テストです。
コンシューマとプロバイダの間で連携して使うものなので、第三者の提供するパブリックなAPIを使ってなにかアプリを作りたいというときに入れるテストではありません。プロバイダと協力して開発できる関係でなければなりません。
Pactとは?
Pactはコードを書くことでコンシューマ駆動契約テストを行うツールです。ここまで読んでいてコンシューマ駆動契約テストに興味を持っても、「契約ってどう書けばええねん、専用の言語を学ばなきゃあかんのか?」という状態かと思います。Pactを使って契約を得るためにはいつも使っている言語とテストライブラリがあれば問題ありません。Pactを使ったコンシューマ駆動契約テストの手順を追います。
- コンシューマがプロジェクトにPactを導入し、APIリクエストと期待するレスポンスをテストとして記述する
- コンシューマ側で1のテストを実行して、契約が書かれたファイル(Pactファイル)を取得する
- Pactファイルをプロバイダに渡し、プロバイダ側のテストに使ってもらう
Pactの導入さえ済めば、あとはテストを書いて走らせるだけで契約が書かれたPactファイルが得られます。次の章で、今回のプロジェクトで行った導入手順を振り返っていきます。
OpenAPIとの違いはなに?
PactはOpenAPIとよく対比されるようです。似てるようで、かたやAPIのテストツール、かたやAPIの仕様記述フォーマット。
たとえばプロバイダでAPIの返り値となるJSON内の一つのキー名を変更したとし、これがコンシューマであるフロントエンドへ伝わってないとします。
Pactが組み込まれているプロジェクトならば、CIでPactファイルを使ったAPIテストが行われるでしょう。このPactファイルはキー名が変更前のもので契約が定義されており変更後のレスポンスと食い違うため、テストに失敗し、コンシューマに変更が必要なことに気づきます。コンシューマが複数いる場合では、どこから提供されたPactファイルか見れば、契約の確認をすべきコンシューマを容易に特定できます。
OpenAPIが導入されている場合、変更がコンシューマに通知されずコンシューマもそれに気づかないと、リリースまで進んでしまい事故になります。 また、変更を通知するとして複数コンシューマがいる場合は、それぞれのコンシューマが変更に対してアンテナを張るか、プロバイダがコンシューマに対して変更内容を知らせ、変更をそのままリリースして問題ないかそれぞれに調べてもらわなければなりません。
今回導入したプロジェクトではOpenAPIを使って、APIを叩くTypeScriptコードを自動生成していました。この状況ではコンシューマ側が変更後のOpenAPIからコードを生成して、未変更のコンシューマ側コードで型の不一致からビルドエラーが起こりました。こういった仕組みでプロバイダ側のAPIの変更に気づくことはできるかもしれません。ですがそもそもプロバイダから変更を知らされなかったとしたらコンシューマがOpenAPIを更新する必要はありません。コンシューマ側のビルドエラーを頼りにプロバイダのAPI変更検知をするのはすぐれた方法とはいえません。
Pactが正しく入れられているなら、Pactがテストでコンシューマとプロバイダの齟齬が発生することを検知してくれます。もしAPIのコンシューマが複数いる場合には、どのコンシューマとの間でAPIの仕様調整をすればいいかを的確に判断する材料にもなってくれます。
フロントエンドにPactを導入して、Pactファイルを出力するまで
今回導入したプロジェクトではTypeScript、Angular、Jestを使用していました。もしそれらがJavaScript、React、Mochaに変わったとしてもPactは設定すべき内容を押さえていれば、問題なく使えます。
インストール
フロントエンドプロジェクトでPactのJavaScript版であるPact JSをインストールします。
npm i -D @pact-foundation/pact@latest
Jestの設定
既存のテストとPactのテストを同一設定で同時に実行したくないため、テスト設定のファイルを分けました。プロジェクトルート下にjest.config.jsがもともとあり、それをコピーしてjest.config.pact.jsを作成しました。
既存のテストファイルは*.spec.ts
でした。PactのテストファイルはPactのためのものだとわかるように*.pact.spec.ts
としました。
jest.config.jsを使って行うテストではPactのテストを無視してほしいので、下記の設定値を入れました。
testPathIgnorePatterns: ['pact.spec.ts'],
jest.config.pact.jsには逆にPactのテストのみ行ってもらうための設定を入れました。それに加え、サーバを使用するので適当なポートを決めてtestURLを入れる必要があります。
testMatch: ["**/?(*.)pact.spec.ts"], testURL: 'http://localhost:8910',
ポート番号にPactの語呂合わせである”8910”を入れることをひらめいたときが、このプロジェクトでの私の最高の瞬間でした🌝
Pactのテストを書く
たとえばブログ管理システムを開発していたとして、ブログの記事件数を取得できるAPIがあったとします。APIへのリクエストを送るにはパス、リクエストメソッド、Acceptリクエストヘッダなどを決める必要があります。これをひとまず下記のものとして進めます。
ブログの記事件数を返すAPI
- パス -
/api/count-entries
- リクエストメソッド - GET
- Acceptリクエストヘッダ -
application/json
- レスポンス - 成功すればステータスコード200で、JSONで
{"count": n}
このAPIにリクエストを送る関数をcountBlogEntries
という名前で作ることにします。が、今回は先に契約をテストとして書いてしまいましょう。
記述内容は大きく分けて以下の3つです。
(1) Pactクラスを使ってプロバイダのモックを定義
(2) APIを叩くテストの事前処理、事後処理、最終処理を定義
(3) 契約の記述(プロバイダにどういうリクエストが来てどういうレスポンスを返すか定義)
(4) 実際にリクエストを出してみてレスポンスを検証
import { Pact } from '@pact-foundation/pact'; import * as path from 'path'; jest.setTimeout(300000); // (1) Pactクラスを使ってプロバイダのモックを定義 const provider = new Pact({ port: 8910, log: path.resolve(process.cwd(), 'pact', 'logs', 'mockserver-integration-operation.log'), dir: path.resolve(process.cwd(), 'pacts'), spec: 1, consumer: 'some frontend', provider: 'backend' }); describe('ブログのAPI', () => { // (2) APIを叩くテストの事前処理、事後処理、最終処理を定義 beforeAll(async () => { await provider.setup(); }); afterEach(async () => { await provider.verify(); }); afterAll(async () => { await provider.finalize(); }); describe('ブログ記事をカウント', async () => { const expectedResponse = { count: 10 }; // (3) 契約の記述 await provider.addInteraction({ state: 'ブログ記事が10件ある', uponReceiving: 'foobar', withRequest: { method: 'GET', path: '/api/count-entries', headers: { Accept: 'application/json' } }, willRespondWith: { status: 200, headers: { 'Content-Type': 'application/json' }, body: expectedResponse } }); // (4) 実際にリクエストを出してみてレスポンスを検証 it('ブログ記事数を取得する', async () => { const response = await countBlogEntries(); expect(response).toEqual(expectedResponse); }); }); });
(1) まずプロバイダのモックの定義です。Pactのテストを実行すると、APIサーバとして動作するモックサーバが立ち上がります。それをポートの何番で立ち上げ、ログをどうするか、Pactファイルの出力先をどこにするかといったことをPactクラスを使って設定します。使用するポートは先にJestの設定で決めていたので、ここではそれに従っています。
(2) 次にテストが切り替わる度に行われる事前、事後処理と、一連のテストの最終処理の定義です。
provider.setup()
でプロバイダのセットアップを行い、テストが実行されたあと、provider.verify()
を行うことで、プロバイダモックサーバが期待どおりにリクエストを受け取ったかを確認します。もしリクエストパスなどが誤っていればプロバイダモックサーバはリクエストを受け取っていないためエラーとなり、ここでテストを失敗させます。
provider.finalize()
はAPIが予期通り叩かれたことを確認して、最後に契約をまとめたPactファイルの出力を行います。
この事前、事後、最終処理は必須の処理なので、定型的に差し込んでしまいましょう。
(3) プロバイダにどういうリクエストが来てどういうレスポンスを返すかを定義します。
プロバイダモックサーバはメソッドaddInteraction
で契約を受け取って、その記述通りの設定でリクエストを待ち構え、記述どおりのレスポンスを返します。リクエストのパスが一文字でも間違えたり、Acceptリクエストヘッダが契約と違うものになっているなら、このテストは契約にそっていないことになるので失敗します。コンシューマ側で誤ったリクエストを送ってしまうコードを書くことを防いでくれます。
(4) 最後にAPIを叩いたときに期待する値を取得できるかテストします。プロバイダモックサーバから契約として記述した通りのレスポンスが得られているか確認します。
今回の例ではリクエストに必要な項目数を絞っていますが、パラメータやクッキーももちろん条件に加えることができます。
テストが書けたら要件が固まっているということで、関数countBlogEntries
を書くことも容易だと思います。この関数のコードをここに書くのは割愛します。
Pactファイルの出力(テスト実行)
テストとテスト対象のコードが用意できたら、Pactのテストを行います。プロジェクトルートに作っておいたテスト設定ファイルを使ってテストを始めます。
jest --config jest.config.pact.js
テストが成功すると、pacts/
の中にJSON形式でPactファイルが出力されます。
テストが失敗した場合は、テストログが出力されているのでそちらを見ましょう。APIリクエストが正しくなかった場合など、失敗原因を詳しく探ることができます。
出力されたPactファイルは、APIのプロバイダに渡します。渡し方としては直接渡すという方法もありますが、Pact Brokerというものを使ってバージョン管理していくという方法がベターです。
Pactファイルをデバッグのモックとして使う
Pactのうれしい点は、Pactファイルを使ってモックサーバを立ち上げられる点です。開発に使えるので、立ち上げ方を追っていきます。
サーバ起動前の設定(プロキシ)
同一サーバからHTMLもAPIモックデータも配信するならこの設定は不要ですが、Pact製モックサーバは独立して新たに立ち上げるものだからそうもいきません。プロキシを設定して、リクエストをPact製モックサーバに流れるようにしなければなりません。
Angularではこういった状況を見越してか、プロキシ機能が組み込まれていますのでそれを使います。
まずプロキシ設定をJSONで記述します。どこのパスへのリクエストを、どこのサーバに送りたいかを書きます。
{ "/path/to/api": { "target": "http://localhost:8911", "secure": false, "changeOrigin": true } }
まだPact製モックサーバを立ち上げていませんが、上記のように書いたのでポートは8911を使うことにします。
次にangular.json内のprojects.*.architect.configurations.mock.proxyConfig
に上に書いたJSONを当てます。
"projects": { "some-project": { ... "architect": { ... "serve": { ... "configurations": { "mock": { "browserTarget": "some-project:build:mock", "proxyConfig": "config/proxy/mock.json" } }
モックサーバの起動の仕方
モックサーバはDockerを使います。Dockerコンテナ起動コマンドに、事前に出力したPactファイルを所定のディレクトリにマウントするオプションが入っています。
docker run --name pact-mock -p 8911:8911 \ -v \"$(pwd)/pacts/:/app/pacts\" pactfoundation/pact-stub-server \ -p 8911 -d pacts
これで契約の内容をモックしてくれるサーバが立ち上がります。
モックを使ってデバッグを始める
モックサーバをAngularデバッグ開始時に同時に立ち上げ、モックを使ったデバッグを始めましょう。
docker run --name pact-mock -p 8911:8911 \ -v \"$(pwd)/pacts/:/app/pacts\" pactfoundation/pact-stub-server \ -p 8911 -d pacts && \ ng serve -c mock
まとめ
この記事でフロントエンドへのPactの導入の手順を追いました。
Pactを導入するため、コンシューマとプロバイダの間の決まり事を契約としてフロントエンドのテストに書きました。テストを実行することで契約が記述されたPactファイルが得られ、これをバックエンドのテストに使うことで、APIを使う側と提供する側の間で、使い方の食い違いが起こることを防げるようになります。
Pactファイルはさらにモックデータとしてデバッグに使うこともできるようになり、開発に役立てることもできました。
契約をテストとして書くコストは、モックサーバを作ってデータをそこに加えていくことと比べるとそれほど大きな差ではありません。そんなテストを書くことで、APIのプロバイダ側でテストに使えるファイルを出力でき、さらにはそれを使ってモックサーバを立てることもできて一石二鳥でした。
APIのコンシューマとプロバイダをつなぐテストを行うための仕組みを提供しつつ、フロントエンドの開発に使えるモックも作ってくれる。この記事が、そんな便利なPactの導入の一助になれば幸いです。