コンシューマ駆動契約テスト用ツールのPactを使う

以前の記事で、Spring BootのソースコードからOpenAPI仕様のフォーマットのAPI定義書のYamlファイルを生成するようにしました。このYamlファイルを受け取ったフロントエンド・エンジニアはバックエンドの仕様を確認しながら開発を開始できるようになります。

しかしながら、バックエンドの開発が進んでも、フロントエンド開発チームが想定している仕様が遵守される保証はありません。実際、多くの現場ではバックエンド開発チームが断りなくAPI仕様を変更したものをデプロイする事がよくあります。その結果、アプリケーションが動作しなくなり、原因究明のためフロントエンドの開発がストップしてしまいます。

このような事態を防ぐにはどうしたらよいのでしょうか? まず、フロントエンド(コンシューマ)側が想定しているAPI仕様に基づいてAPIのテストスイートを作成します。そして、バックエンド・サーバ(プロバイダ)はこのテストに合格するか、バックエンド開発チームがAPIの仕様変更をフロントエンド開発チームの了承を得ない限り、デプロイを禁止するようにすればよいのです。

この考えに基づくテストの事を、コンシューマ駆動契約(Consumer Driven Contract: CDC)テストと呼びます。

本記事では、CDCテスト・ツールであるPactを使い、コンシューマをTypeScriptで書かれたReactアプリケーション、プロバイダ側をJavaで書かれたSpring Bootアプリケーションとした場合の、CDCテストの例を解説します。

目次

フロントエンド側でPactファイルを作成する

API仕様の確認

以前の記事では、以下の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] ------------------------------------------------------------------------

後は、契約テストにパスする状態を維持しながら、データベースへの保存処理等を実装していけば良い事になります。