Consumer-Driven Contracts testing with Pact-js

こんにちは!フリージアの東山です。
今回はPact-jsでコンシューマー側のConsumer-Driven Contracts testingを実践しようと思います。

Consumer-Driven Contracts Testing (CDC testing) について、詳細な説明は割愛します。
分かりやすく纏めてくださっている記事がありますので、以下のリンク先を参照ください。

本記事では、CDC testingについて以下の理解があれば問題有りません。

  • CDC testingには、登場人物が2人いる
    • コンシューマー: WebAPIの利用者
    • プロバイダー: WebAPIの供給者
  • CDC testingは、テスト手法である
    • WebAPIの振る舞いについて、コンシューマーとプロバイダーの間で仕様の齟齬が生まれないことを目的とする
  • CDC testingは、以下の手順で行われる
    1. コンシューマーが、期待するWebAPIの振る舞いを定義する
    2. コンシューマーは、期待するWebAPIの振る舞いでコンシューマー側のプログラムが正常に動作することをテストする
    3. プロバイダーは、提供するWebAPIが期待するWebAPIの振る舞いを満たすことをテストする
    4. プロバイダーは、テスト完了をコンシューマーに通知し、期待するWebAPIの振る舞いを満たすWebAPIを提供する

CDC testingを実践するためのライブラリは幾つかありますが、前述のとおり、本記事ではPactを使います。
特に、コンシューマー側 (フロントエンド) に焦点を当て、Pact-js を用いたコンシューマー側でのCDC testingを実践します。

Pact-jsを用いたコンシューマー側でのCDC testingの全容は以下になります。
主に、上記のCDC testingの手順における、1 ~ 2の工程を実践します。

Pact-js
(出典: https://docs.pact.io/)

Pactとは?

Pactは、Ruby・Java、Golang・Javascriptなど様々な言語に対応した、CDC Testingのためのオープンソースライブラリです。
テストを書くためのライブラリ提供のほか、Pact BrokerPactflowといったCDC Testingのためのエコシステムを提供しています。

PactではWebAPIの振る舞いをJSONファイルで管理します。
JSONファイルで管理することで、コンシューマー側とプロバイダー側がそれぞれ異なる言語で書かれている場合でも、WebAPIの振る舞いを受け渡し可能という利点があります。
WebAPIの振る舞いはWebAPIのリクエスト・レスポンスの仕様を定義したもので、Pactではインタラクションと呼んでいます。

Pact-js
(出典: https://docs.pact.io/)

次章の実践Pact-jsでは、以下の方針で進めていきます。

  1. インタラクションの定義
  2. Pact-jsのインストール・設定
  3. インタラクションの単体テスト
  4. インタラクションを満たすスタブWebAPIサーバーの起動と動作確認

英語のドキュメントですが、より詳しくPactについて知りたい方はPact: how pact works を参照ください。

実践Pact-js

では、Pact-jsでコンシューマーサイドのCDC testingを実践してみましょう。

今回は、以下の要件を満たすWebAPIをコンシューマー側から要求します。

  • 授業情報を配列で返却する
  • 授業情報の配列の要素には、曜日・時限・授業名をプロパティにもつオブジェクトが含まれる

インタラクションの定義

まずは、授業情報を返却するWebAPIのインタラクションを定義します。

項目
API名 授業情報API
APIエンドポイント http://localhost:8080/v1/api/lectures/
メソッド GET
リクエストヘッダ Accept: application/json
リクエストパラメータ -
レスポンスヘッダ Content-Type: application/json
レスポンスボディ lectures: [ { dayOfTheWeek: '曜日', period: '時限', name: '授業名' }, ... ]

授業情報APIは、冪等かつ安全であることを期待するので、GETメソッドでのHTTPリクエストとします。
レスポンスの形式は「application/json」です。

上記以外にも、返却されるHTTPステータス毎の仕様など、WebAPIを定義する上で決めなければいけないことはたくさんありますが、本記事では上記に留めます。

Pact-jsのインストール・設定

いよいよ、Pact-jsを利用します。

任意の場所にプロジェクトフォルダを作成し、@pact-foundation/pactをyarnでインストールします。
また、単体テストにAxiosJestを使用するため、合わせてインストールします。

※ 必ずしもJestである必要はありません。
@pact-foundation/pact > sampleに他のテストライブラリを用いたサンプルがあります。

$ cd /path/to/project_folder
$ yarn init

# Pact-jsのインストール
$ yarn add -D @pact-foundation/pact

# Axiosのインストール
$ yarn add axios

# Jestのインストール
$ yarn add -D jest

# Jestの初期設定
$ yarn jest --init
...

The following questions will help Jest to create a suitable configuration for your project

✔ Would you like to use Jest when running "test" script in "package.json"? … yes
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Automatically clear mock calls and instances between every test? … yes

...

インストールが完了したら、次はPact-jsの設定を行います。
Pact-jsの設定ファイルおよびテストファイルを含めたフォルダ構成は、以下のようになります。

/
├ .pact
│  ├ pactSetup.js
│  └ pactTestWrapper.js
├ spec
│  └ pact
│     └ lectures.spec.js
├ package.json
└ yarn.lock

.pactは設定ファイルを配置するフォルダです。
pactSetup.jsはインタラクションを提供するプロバイダーのインスタンスの作成を、pactTestWrapper.jsは単体テストにおける共通処理 (setup, finalize) をインタセプタとして設定します。

// .pact/pactSetup.js

const path = require("path")
const Pact = require("@pact-foundation/pact").Pact

// グローバル変数の設定
global.port = 8090
global.provider = new Pact({
  port: global.port,
  // ログファイルのパス
  log: path.resolve(process.cwd(), "logs", "mockserver-integration.log"),
  // Contractファイルを出力するフォルダのパス
  dir: path.resolve(process.cwd(), "pacts"),
  spec: 2,
  pactfileWriteMode: "update",
  // コンシューマー名 (任意)
  consumer: "Consumer",
  // プロバイダー名 (任意)
  provider: "Provider",
})

// .pact/pactTestWrapper.js

beforeAll(done => {
  // 前処理の前にPact Providerを起動する
  provider.setup().then(() => done())
})

afterAll(done => {
  // 前処理の後にPact Providerを終了する
  provider.finalize().then(() => done())
})

以上で、Pact-jsの設定は完了です。

インタラクションの単体テスト

次は、インタラクションの単体テストの実装・実行を行います。

Pact-jsでは、定義したインタラクションについて、
Pactでモックプロバイダーを作成し、実際にHTTPリクエストを送り、返却されるHTTPレスポンスを検証する
といった単体テストを行います。

単体テストに成功すれば、プロバイダー側へ共有するためのJSONファイル (PactではContractファイルと呼ぶ) が作成されます。
このContractファイルは、次工程である「インタラクションを満たすスタブWebAPIサーバーの起動と動作確認」でも利用します。

では、単体テストを実装します。

// spec/pact/lectures.spec.js

const axios = require('axios')

describe("Lectures API", () => {
  const EXPECTED_BODY = [
    { dayOfTheWeek: 'sun', period: 1, name: '数学' },
    { dayOfTheWeek: 'mon', period: 2, name: '物理' },
    { dayOfTheWeek: 'tue', period: 3, name: '英語' }
  ]

  describe("GET /v1/api/lectures", () => {
    beforeEach(() => {
      // インタラクションをモックプロバイダーに登録する
      const interaction = {
        state: "Has three lectures",
        uponReceiving: "A request for lectures",
        withRequest: {
          method: "GET",
          path: "/v1/api/lectures",
          headers: {
            Accept: "application/json",
          },
        },
        willRespondWith: {
          status: 200,
          headers: {
            "Content-Type": "application/json",
          },
          body: EXPECTED_BODY,
        },
      }
      return provider.addInteraction(interaction)
    })

    // モックプロバイダーに対しHTTPリクエストを行い、レスポンスを検証する
    it("returns a sucessful body", done => {
      return axios
        .get(`http://localhost:${port}/v1/api/lectures`, { headers: { Accept: 'application/json' } })
        .then(response => {
          expect(response.headers["content-type"]).toEqual("application/json")
          expect(response.data).toEqual(EXPECTED_BODY)
          expect(response.status).toEqual(200)
          done()
        })
        .then(() => provider.verify())
    })
  })
})

axiosやjestに関する説明は割愛します。詳しくはgithub.com/axios/axiosjestjs.ioを参照ください。

ここでは、大きく分けて以下の2つを行っています。

  • インタラクションをモックプロバイダーに登録する
  • モックプロバイダーに対しHTTPリクエストを行い、レスポンスを検証する

前者は、provider.addInteraction(interaction)を含むbeforeEach()ブロック内で行っています。
addInteraction関数は非同期で実行されるため、beforeEach()やbeforeAll()のブロック内でコールすることを推奨します。
インタラクションは前述の通りリクエストとレスポンスの定義を含みます。また、それ以外にStateというPact Provider側がインタラクションを識別するための値も含まれます。

後者は、単純なHTTPリクエストのテストになりますので、詳細な説明は割愛します。
1点だけ通常のHTTPリクエストと異なるのは、provider.verify()をテスト完了時にコールしていることです。 provider.verify()をテスト完了時にコールすることで、プロバイダー側にインタラクションのテストが成功したことを通知しています。

以上が、単体テストファイルの実装になります。 最後に、以下のコマンドを実行し、テストが成功することを確認します。

# package.json -> scriptsに追加した方が良いです
$ yarn jest --testRegex \"/*(.spec.pact.js)\" --runInBand --setupFiles ./.pact/pactSetup.js --setupFilesAfterEnv ./.pact/pactTestWrapper.js

...

PASS  spec/pact/lectures.spec.pact.js
  Lectures API
    GET /v1/api/lectures
      ✓ returns a sucessful body (21ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total

...

インタラクションを満たすスタブWebAPIサーバーの起動と動作確認

前節で単体テストが成功しましたので、プロジェクトルート配下に「pacts/consumer-provider.json」という名前のContractファイルが作成されています。

$ ls /path/to/project_folder/pact

drwxr-xr-x   3 your_name  wheel   96  9 20 12:53 .
drwxr-xr-x  10 your_name  wheel  320  9 20 12:52 ..
-rw-r--r--   1 your_name  wheel  954  9 20 12:53 consumer-provider.json

@pact-foundation/pactパッケージに含まれる「pact-stub-service」にこのContractファイルを食わせて、WebAPIサーバを起動します。

# package.json -> scriptsに追加した方が良いです
$ $(find . -name pact-stub-service | grep -e 'bin/pact-stub-service$' | head -n 1) ./pacts/consumer-provider.json -o --port 8080

INFO: Loading interactions from ./pacts/consumer-provider.json
I, [2019-09-20T13:39:41.305426 #26200]  INFO -- : Registered expected interaction GET /v1/api/lectures
D, [2019-09-20T13:39:41.305592 #26200] DEBUG -- : {
  "description": "A request for lectures",
  "providerState": "Has three lectures",
  "request": {
    "method": "GET",
    "path": "/v1/api/lectures",
    "headers": {
      "Accept": "application/json"
    }
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "body": [
      {
        "dayOfTheWeek": "sun",
        "period": 1,
        "name": "数学"
      },
      {
        "dayOfTheWeek": "mon",
        "period": 2,
        "name": "物理"
      },
      {
        "dayOfTheWeek": "tue",
        "period": 3,
        "name": "英語"
      }
    ]
  }
}
INFO  WEBrick 1.3.1
INFO  ruby 2.2.2 (2015-04-13) [x86_64-darwin13]
INFO  WEBrick::HTTPServer#start: pid=26200 port=8080

最後に、curlでスタブWebAPIサーバが正常に動作していることを確認できれば完了です。

$ curl http://localhost:8080/v1/api/lectures -H 'Accept:application/json' 

[{"dayOfTheWeek":"sun","period":1,"name":"数学"},{"dayOfTheWeek":"mon","period":2,"name":"物理"},{"dayOfTheWeek":"tue","period":3,"name":"英語"}]

なお、ここまでのソースコードはfreegian/pact-js-tutorialに挙げていますので、興味がある方は参照ください。

最後に

今回はPact-jsでコンシューマー側のConsumer-Driven Contracts testingを実践しました。

今後の話として、プロバイダー側とContractファイルを共有する際は、前述したPact BrokerPactflowを使うことを推奨します。
ローカルで作成したContractファイルを、pact.publish()関数をコールするだけで、各クラウド環境にアップロードすることができます。
各Contractファイルの状態管理、バージョン管理も可能なので、ぜひ使ってみてください。

また、Pact-jsを現場で採用する際は、WebAPIの振る舞いを定義するファイルテストするファイルを分離することをお勧めします。
弊社では、WebAPIの振る舞いをテストするファイルを共通化しています。これにより、WebAPIの振る舞いを定義するファイルを作成するだけで、WebAPIの単体テストおよびContractファイルの作成が可能となっています。
現場で採用する際は、ぜひご一考ください。

本記事を見て、ご意見やご指摘等ございましたら、「[email protected]」まで是非ご連絡ください。