以前の記事で、Spring BootのソースコードからOpenAPI仕様のフォーマットのAPI定義書のYamlファイルを生成するようにしました。このYamlファイルを受け取ったフロントエンド・エンジニアはバックエンドの仕様を確認しながら開発を開始できるようになります。
しかしながら、バックエンドの開発が進んでも、フロントエンド開発チームが想定している仕様が遵守される保証はありません。実際、多くの現場ではバックエンド開発チームが断りなくAPI仕様を変更したものをデプロイする事がよくあります。その結果、アプリケーションが動作しなくなり、原因究明のためフロントエンドの開発がストップしてしまいます。
このような事態を防ぐにはどうしたらよいのでしょうか? まず、フロントエンド(コンシューマ)側が想定しているAPI仕様に基づいてAPIのテストスイートを作成します。そして、バックエンド・サーバ(プロバイダ)はこのテストに合格するか、バックエンド開発チームがAPIの仕様変更をフロントエンド開発チームの了承を得ない限り、デプロイを禁止するようにすればよいのです。
この考えに基づくテストの事を、コンシューマ駆動契約(Consumer Driven Contract: CDC)テストと呼びます。
本記事では、CDCテスト・ツールであるPactを使い、コンシューマをTypeScriptで書かれたReactアプリケーション、プロバイダ側をJavaで書かれたSpring Bootアプリケーションとした場合の、CDCテストの例を解説します。
目次
フロントエンド側でPactファイルを作成する
API仕様の確認
openapi: 3.0.1 info: title: タスクAPI仕様 description: タスクデータのAPI仕様です license: name: Apache License 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.txt version: 0.1.0 servers: - url: https://demo.example.com/v1 description: Production Server paths: /tasks/{id}: get: tags: - tasks summary: タスクを取得する。 description: IDを指定してタスクを取得します。 operationId: getTask parameters: - name: id in: path description: タスクのID required: true schema: type: string responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/TaskResponseDto' put: tags: - tasks summary: タスクを更新する description: タスクのリクエストされたプロパティを更新します。 operationId: replaceTask parameters: - name: id in: path description: タスクのID required: true schema: type: string requestBody: content: application/json: schema: $ref: '#/components/schemas/TaskRequestDto' required: true responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/TaskResponseDto' delete: tags: - tasks summary: タスクを削除する。 description: 指定されたIDのタスクを削除します。 operationId: deleteTask parameters: - name: id in: path description: タスクのID required: true schema: type: string responses: "200": description: OK patch: tags: - tasks summary: タスクを置換する description: タスクのすべてをリクエストされた値に置換します operationId: updateTask parameters: - name: id in: path description: タスクのID required: true schema: type: string requestBody: content: application/json: schema: $ref: '#/components/schemas/TaskRequestDto' required: true responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/TaskResponseDto' /tasks: get: tags: - tasks summary: タスクリストを取得する description: タスクのリストを取得します。 operationId: listTask responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/ListTaskResponseDto' post: tags: - tasks summary: タスクを作成をする description: タスクを作成します。 operationId: createTask requestBody: content: application/json: schema: $ref: '#/components/schemas/TaskRequestDto' required: true responses: "201": description: 作成されました。 content: application/json: schema: $ref: '#/components/schemas/TaskResponseDto' components: schemas: TaskResponseDto: required: - title type: object properties: id: type: string description: ID title: maxLength: 125 minLength: 1 type: string description: タスクの件名です description: maxLength: 4096 minLength: 0 type: string description: タスクの詳細な説明です。 description: タスクデータ TaskRequestDto: required: - title type: object properties: title: maxLength: 125 minLength: 1 type: string description: タスクの件名です description: maxLength: 4096 minLength: 0 type: string description: タスクの詳細な説明です。 description: タスクデータ ListTaskResponseDto: type: object properties: results: type: array items: $ref: '#/components/schemas/TaskResponseDto' description: タスクリストデータ
APIを利用するReactのプロジェクト(コンシューマ)を作成する
まず、Reactのプロジェクトを作成します。(下記コマンドを実行するために、Node.jsをダウンロードしてインストールしておいてください。)
npx create-react-app frontend --template typescript cd frontend
以降の作業では、frontend
ディレクトリで作業するものとします。
そして、HTTPクライアントとしてaxiosを使いたいので、axiosをインストールします。
npm i axios
APIを呼び出すコードを実装する
プロジェクトを作成したら次にUIを実装する事がほとんどだと思います。ですが、本記事でUI作成部分は省略し、いきなりバックエンドのAPIを呼び出すコードを実装する事にします。
まず、Taskのモデルクラスを作成します。
src/model/Task.ts
ファイルを作成し、以下のように記述します。
export type Task = { id: string; title: string; description: string; }
src/infrastructure/TaskApi.ts
ファイルを作成し、以下のように記述します。
import axios, { AxiosInstance } from "axios"; import { Task } from "../model/Task"; export class TaskAPI { private readonly client: AxiosInstance; constructor(url: string = "http://localhost:8080") { this.client = axios.create({ baseURL: url, headers: { "Content-Type": "application/json", }, }); } public async getTask(id: string): Promise<Task> { const response = await this.client.get(`/tasks/${id}`); return response.data; } public async listTasks(): Promise<Task[]> { const response = await this.client.get("/tasks"); return response.data; } public async addTask(task: Task): Promise<Task> { const response = await this.client.post("/tasks", task); return response.data; } public async updateTask(task: Task): Promise<Task> { const updateTask = { title: task.title, description: task.description }; const response = await this.client.put(`/tasks/${task.id}`, updateTask); return response.data; } public async deleteTask(task: Task): Promise<Task> { const response = await this.client.delete(`/tasks/${task.id}`); return response.data; } }
コンシューマのPactテストを作成、実行する
次に、Pactを使うためのJestアダプターのJest-Pactをプロジェクトにインストールします。
npm i jest-pact @pact-foundation/pact @types/jest jest ts-jest
jest.config.cjs
ファイルを作成し、以下のように記述します。
module.exports = { preset: "ts-jest", testEnvironment: "node", testMatch: ['**/*.test.(ts|js)', '**/*.it.(ts|js)', '**/*.pacttest.(ts|js)'], };
package.json
ファイルの"test"を"jest --config jest.config.cjs"と書き換えます。
{ "scripts": { "test": "jest --config jest.config.cjs" } }
src/api.pacttest.ts
ファイルを作成し、以下のように記述します。
import { MatchersV3 } from "@pact-foundation/pact"; import { pactWith } from "jest-pact/dist/v3"; import { TaskAPI } from "./infrastructure/TaskApi"; import { Task } from "./model/Task"; // pactのテストを記述します。本記事では、バックエンドとフロントエンドの関係は1対1ですが、 // consumer、providerの関係はn対nになる事があるのでconsumerとproviderの名前を設定して、 // チーム間でどの契約テスト用なのかを分かるようにします。 // dirはpactファイルの出力先ディレクトリです。 pactWith({ consumer: 'TaskConsumer', provider: 'TaskProvider', dir: '../pact' }, (interaction) => { // 1回のサーバとクライアントのやり取りを定義します。 interaction('タスクを作成する', ({provider, execute}) => { let postTask: Task = { title: "新しいタスク", description: "新しいタスクの説明" } let expectedTask: Task = { id: "100", ...postTask } ここで、コンシューマ側が期待するクライアント・サーバ間の契約を定義します。 beforeEach(() => { provider .uponReceiving('タスクを作成する') // やり取りの名前 .withRequest({ // クライアントが送信する予定のリクエスト method: 'POST', path: '/tasks', headers: { 'content-type': 'application/json' }, body: postTask }) .willRespondWith({ // クライアントが期待するレスポンス status: 201, headers: { 'content-type': 'application/json' }, body: MatchersV3.like(expectedTask) // MachersV3.likeでは型のみを検証します。 }); } ); // 実際に実行してみて、期待通りの契約を記述できたのかをクライアント側でも確認します。 execute('タスクを作成する', (mockserver) => { const client = new TaskAPI(mockserver.url); return client.addTask(postTask).then((task) => { expect(task).toEqual(expectedTask); }); }) }); interaction('タスクを取得する', ({provider, execute}) => { let expectedTask: Task = { id: "001", title: "1番目のタスク", description: "タスクの説明" } beforeEach(() => { provider .given('001のタスクが存在する') // クライアント側がリクエストする時に、サーバ側に期待する前提条件がある場合に記述します。 .uponReceiving('タスクを取得する') .withRequest({ method: 'GET', path: '/tasks/001', }) .willRespondWith({ status: 200, headers: { 'content-type': 'application/json' }, body: MatchersV3.like(expectedTask) }); } ); execute('タスクを取得する', (mockserver) => { const client = new TaskAPI(mockserver.url); return client.getTask("001").then((task) => { expect(task).toEqual(expectedTask); }); }) }); interaction('タスクを一覧取得する', ({provider, execute}) => { let expectedTasks: { results: Task[]} = { results: [ { id: "001", title: "1番目のタスク", description: "タスクの説明" }, { id: "002", title: "2番目のタスク", description: "タスクの説明" }, { id: "003", title: "3番目のタスク", description: "タスクの説明" }, ]}; beforeEach(() => { provider .uponReceiving('タスクを一覧取得する') .withRequest({ method: 'GET', path: '/tasks', }) .willRespondWith({ status: 200, headers: { 'content-type': 'application/json' }, body: MatchersV3.like(expectedTasks) }); }); execute('タスクを一覧取得する', (mockserver) => { const client = new TaskAPI(mockserver.url); return client.listTasks().then((tasks) => { expect(tasks).toEqual(expectedTasks); }); }) }); interaction('タスクを更新する', ({provider, execute}) => { let putTask: Task = { id: "001", title: "更新された1番目のタスク", description: "更新されたタスクの説明" } let expectedTask: Task = putTask; beforeEach(() => { provider .given('001のタスクが存在する') .uponReceiving('タスクを更新する') .withRequest({ method: 'PUT', path: '/tasks/001', headers: { 'content-type': 'application/json' }, body: { title: "更新された1番目のタスク", description: "更新されたタスクの説明"} }) .willRespondWith({ status: 200, headers: { 'content-type': 'application/json' }, body: MatchersV3.like(expectedTask) }); } ); execute('タスクを更新する', (mockserver) => { const client = new TaskAPI(mockserver.url); return client.updateTask(putTask).then((task) => { expect(task).toEqual(expectedTask); }); }) }); interaction('タスクを削除する', ({provider, execute}) => { let deleteTask: Task = { id: "001", title: "1番目のタスク", description: "タスクの説明" } beforeEach(() => { provider .given('001のタスクが存在する') .uponReceiving('タスクを削除する') .withRequest({ method: 'DELETE', path: '/tasks/001', headers: { 'content-type': 'application/json' }, }) .willRespondWith({ status: 200, }); } ); execute('タスクを削除する', (mockserver) => { const client = new TaskAPI(mockserver.url); return client.deleteTask(deleteTask); }) }); });
レスポンスのマッチングルールの詳細は、Pact JSのドキュメントを参照してください。
また、ここで注意すべきなのは、フロントエンド側は契約のテストを記述しているのであり、バックエンドの機能テストを記述するのではないという事です。つまり、POSTされたデータがデータベースに保存されているかや、エラーのメッセージの詳細の正確性をテストするものではありません。(詳細は『Contract Tests vs Functional Tests』を参照してください)
次に、テストを実行します。
$ npm test > frontend@0.1.0 test > jest --config jest.config.cjs RUNS src/api.test.ts # 中略 PASS src/api.test.ts6061Z WARN ThreadId(02) pact_models::pact: Note: Existing pact is an older specification version (V3), and will Pact between TaskConsumer and TaskProvider with 30000 ms timeout for Pact タスクを作成する ✓ タスクを作成する (150 ms) タスクを取得する ✓ タスクを取得する (13 ms) タスクを一覧取得する ✓ タスクを一覧取得する (7 ms) タスクを更新する ✓ タスクを更新する (11 ms) タスクを削除する ✓ タスクを削除する (9 ms) Test Suites: 1 passed, 1 total Tests: 5 passed, 5 total Snapshots: 0 total Time: 3.632 s Ran all test suites.
../pact/TaskConsumer-TaskProvider.json
に以下のようにPactファイルが生成されます。
{ "consumer": { "name": "TaskConsumer" }, "interactions": [ { "description": "タスクを一覧取得する", "request": { "method": "GET", "path": "/tasks" }, "response": { "body": { "results": [ { "description": "タスクの説明", "id": "001", "title": "1番目のタスク" }, { "description": "タスクの説明", "id": "002", "title": "2番目のタスク" }, { "description": "タスクの説明", "id": "003", "title": "3番目のタスク" } ] }, "headers": { "content-type": "application/json" }, "matchingRules": { "body": { "$": { "combine": "AND", "matchers": [ { "match": "type" } ] } }, "header": {} }, "status": 200 } }, { "description": "タスクを作成する", "request": { "body": { "description": "新しいタスクの説明", "title": "新しいタスク" }, "headers": { "Content-Type": "application/json", "content-type": "application/json" }, "method": "POST", "path": "/tasks" }, "response": { "body": { "description": "新しいタスクの説明", "id": "100", "title": "新しいタスク" }, "headers": { "content-type": "application/json" }, "matchingRules": { "body": { "$": { "combine": "AND", "matchers": [ { "match": "type" } ] } }, "header": {} }, "status": 201 } }, { "description": "タスクを削除する", "providerStates": [ { "name": "001のタスクが存在する" } ], "request": { "headers": { "content-type": "application/json" }, "method": "DELETE", "path": "/tasks/001" }, "response": { "status": 200 } }, { "description": "タスクを取得する", "providerStates": [ { "name": "001のタスクが存在する" } ], "request": { "method": "GET", "path": "/tasks/001" }, "response": { "body": { "description": "タスクの説明", "id": "001", "title": "1番目のタスク" }, "headers": { "content-type": "application/json" }, "matchingRules": { "body": { "$": { "combine": "AND", "matchers": [ { "match": "type" } ] } }, "header": {} }, "status": 200 } }, { "description": "タスクを更新する", "providerStates": [ { "name": "001のタスクが存在する" } ], "request": { "body": { "description": "更新されたタスクの説明", "title": "更新された1番目のタスク" }, "headers": { "Content-Type": "application/json", "content-type": "application/json" }, "method": "PUT", "path": "/tasks/001" }, "response": { "body": { "description": "更新されたタスクの説明", "id": "001", "title": "更新された1番目のタスク" }, "headers": { "content-type": "application/json" }, "matchingRules": { "body": { "$": { "combine": "AND", "matchers": [ { "match": "type" } ] } }, "header": {} }, "status": 200 } } ], "metadata": { "pact-js": { "version": "11.0.2" }, "pactRust": { "ffi": "0.4.0", "models": "1.0.4" }, "pactSpecification": { "version": "3.0.0" } }, "provider": { "name": "TaskProvider" } }
Pactファイルを使ってバックエンド(プロバイダ)を検証する
このPactファイルを使って、以前の記事で作成したバックエンド側の検証を実施します。
以前の記事のバックエンドのプロジェクトを以下のようなディレクトリ構成になるように移動します。
$ tree -L 1 . . ├── backend # 以前の記事で作成したバックエンド(Spring Boot)プロジェクト ├── frontend # 本記事で作成したフロントエンド(React)プロジェクト └── pact # Pactファイルの出力先
以降の作業はbackendディレクトリで作業するものとします。
まず、pom.xml
ファイルに以下を追加します。
<dependency> <groupId>au.com.dius.pact.provider</groupId> <artifactId>junit5spring</artifactId> <version>4.5.3</version> </dependency>
src/test/java/com/example/demo/DemoPactContractTest.java
ファイルを作成し、以下を記述します。
package com.example.demo; import au.com.dius.pact.provider.junit5.PactVerificationContext; import au.com.dius.pact.provider.junitsupport.Provider; import au.com.dius.pact.provider.junitsupport.State; import au.com.dius.pact.provider.junitsupport.loader.PactFolder; import au.com.dius.pact.provider.spring.junit5.PactVerificationSpringProvider; import com.example.demo.presentation.TaskController; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit.jupiter.SpringExtension; @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, properties = "server.port=8080") @Provider("TaskProvider") @PactFolder("../pact") public class DemoPactContractTest { @Autowired private TaskController taskController; @TestTemplate @ExtendWith(PactVerificationSpringProvider.class) public void pactVerificationTestTemplate(PactVerificationContext context) { context.verifyInteraction(); } @State("001のタスクが存在する") public void taskExists() { // 何もしない } }
この時点でテストを実施しても、契約テストをパスする状態になっています。
$./mvnw test -Dpact_do_not_track=true [INFO] Scanning for projects... # 中略 [INFO] ------------------------------------------------------- [INFO] T E S T S [INFO] ------------------------------------------------------- [INFO] Running com.example.demo.DemoPactContractTest 17:46:53.720 [main] DEBUG org.springframework.boot.test.context.SpringBootTestContextBootstrapper - Neither @ContextConfiguration nor @ContextHierarchy found for test class [DemoPactContractTest]: using SpringBootContextLoader # 中略 Verifying a pact between TaskConsumer and TaskProvider [Using File ../pact/TaskConsumer-TaskProvider.json] タスクを一覧取得する Completed initialization in 1 ms returns a response which has status code 200 (OK) has a matching body (OK) Verifying a pact between TaskConsumer and TaskProvider [Using File ../pact/TaskConsumer-TaskProvider.json] タスクを作成する returns a response which has status code 201 (OK) has a matching body (OK) Verifying a pact between TaskConsumer and TaskProvider [Using File ../pact/TaskConsumer-TaskProvider.json] Given 001のタスクが存在する タスクを削除する returns a response which has status code 200 (OK) has a matching body (OK) Verifying a pact between TaskConsumer and TaskProvider [Using File ../pact/TaskConsumer-TaskProvider.json] Given 001のタスクが存在する タスクを取得する returns a response which has status code 200 (OK) has a matching body (OK) Verifying a pact between TaskConsumer and TaskProvider [Using File ../pact/TaskConsumer-TaskProvider.json] Given 001のタスクが存在する タスクを更新する returns a response which has status code 200 (OK) has a matching body (OK) [INFO] [INFO] Results: [INFO] [INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 [INFO] [INFO] ------------------------------------------------------------------------ [INFO] BUILD SUCCESS [INFO] ------------------------------------------------------------------------ [INFO] Total time: 7.660 s [INFO] Finished at: 2023-04-15T17:46:59+09:00 [INFO] ------------------------------------------------------------------------
後は、契約テストにパスする状態を維持しながら、データベースへの保存処理等を実装していけば良い事になります。