API CDC Testing

Page content

Consumer Driven Contract Testing

Pact - 5 minute guide ななめ読みメモ

ひたすら翻訳

映画見てて気が散るから、翻訳しながら理解するぞー。


5 minute guide

From zero to running Pact tests in 5 mins

この getting started guide はブラウザの中だけで完結して実行できる。これでキーとなるコンセプトを素早く理解してもらえるだろう。途中にその概念をデモするためのスニペットが登場するが、説明補助のための断片なのでそのまま実行することは出来無いことご容赦くださいまし。実行するべき、動作するコードも登場するが、それは以下のように REPL のサービスで埋め込まれている。単純に緑の play ボタンを押せば実行できて、下半分にアウトプットが出るようになってる。例は以下の通り:

An example scenario: Order API

ここでは Pact のテストを事例で紹介するために、 Order Web を consumer, Order API を provider として用意する。 Consumer 側のプロジェクトでは以下が必要になる:

  • model (Order class): Order API から受け取るデータを表現する
  • client (OrderApiClient): APIに対して http call をするために必要なクライアント

注意: pact (規約) を生成するには、実際の http リクエストを行うコードを書く必要があるが、隅から隅まで用意する必要はない。 UI などは不要だ。

Testing the Order Web (consumer) project

Scope of a Consumer Pact Test

理想的には、 Pact のテストはクライアントのクラスの “ユニットテスト” になるべきで、リクエスト生成とレスポンスの処理の正しさの検証に専念するべき。もし Pact を UI テストにまでも落ち込んでしまうと、テストが正しいかの検証に延々と時間をかけることになり作業量がが膨大になってしまう。その為忘れないでほしいのが、 pact はあくまで 通信の contract をテストすることが目的であり、 UI の動作やビジネスロジックを試すためのものではないということだ。

通常、 consumer のアプリケーションは いくつものサブコンポーネントからなる。 Web アプリケーションだったり、あるいは API を利用した別の API サーバだったり。以下の黄色い部分がそのアプリケーションにおける Pact のスコープをイメージ化したものだ:

Scope of a consumer Pact test

図中の Collaborator が他のシステムと通信する役割を担うコンポーネントで, 今回の例だと OrderApiClient がそれに当たる。これが Order Api システムとインタラクトする。この2要素が consumer test の範囲だ。

1. Start with your model

order.js のようなシンプルなモデルクラスをイメージして欲しい。 Order のアトリビュートはリモートサーバに元々あり、 http call で Order API から取得される必要がある:

order.js

class Order {
  constructor(id, items) {
    this.id = id
    this.items = items
  }

  total() {
    return this.items.reduce((acc, v) => {
      acc += v.quantity * v.value
      return acc
    }, 0)
  }

  toString() {
    return `Order ${this.id}, Total: ${this.total()}`
  }
}

SampleOrder.js

module.exports = [
  {
    id: 1,
    items: [
      {
        name: 'burger',
        quantity: 2,
        value: 20,
      },
      {
        name: 'coke',
        quantity: 2,
        value: 5,
      },
    ],
  },
]

2. Create an Order API client

外部連携を担うクライアントは以下のような感じだ。こいつは Order API へのリクエストを投げて、得られたレスポンスを Order モデルに合うように格納してくれる:

orderClient.js

const fetchOrders = () => {
  return request.get(`${API_ENDPOINT}/orders`).then(
    res => {
      return res.body.map((o) => {
        return new Order(o.id, o.items)
      })
    },
    err => {
      throw new Error(`Error from response: ${err.body}`)
    }
  )
}

3. Configure the mock Order API

Pact は mock を設定できるが、以下は localhost:1234 で Order API として稼働する設定だ。

mock.js

// Setup Pact
const provider = new Pact({
  port: 1234,
  log: path.resolve(process.cwd(), "logs", "pact.log"),
  dir: path.resolve(process.cwd(), "pacts"),
  consumer: "OrderWeb",
  provider: "OrderApi"
});

// Start the mock service!
await provider.setup()

4. Write a test

order.spec.js

describe('Pact with Order API', () => {
  describe('given there are orders', () => {
    describe('when a call to the API is made', () => {
      before(() => {
        return provider.addInteraction({
          state: 'there are orders',
          uponReceiving: 'a request for orders',
          withRequest: {
            path: '/orders',
            method: 'GET',
          },
          willRespondWith: {
            body: eachLike({
              id: 1,
              items: eachLike({
                name: 'burger',
                quantity: 2,
                value: 100,
              }),
            }),
            status: 200,
            headers: {
              'Content-Type': 'application/json; charset=utf-8',
            },
          },
        })
      })

      it('will receive the list of current orders', () => {
        return expect(fetchOrders()).to.eventually.have.deep.members([
          new Order(orderProperties.id, [itemProperties]),
        ])
      })
    })
  })
})

Green! (ホントかよ、断片じゃなくて動作するコードにしてよぉ)

Order API spec を通すと、 設定された pact ディレクトリ (./pacts がデフォルト) に pact ファイルが生成され、ログもまた設定されたログディレクトリ (./log がデフォルト) に保存される。これで pact ファイルには Order API にあなたが期待するレスポンスが記述されていることになる。実行になにか問題があればログを見るとよいだろう。

更に、実際には、他にも発生しうるステータスコードについても同じ様に用意してテストしたくなるだろう。例えば:

  • 404
  • 400 (バリデーションエラーがどう動くか、またその時のボディはどんな感じか)
  • 500 (レスポンスボディにどんなエラーメッセージが含まれているべきかを確認したり。 ※訳注: ただしどこまでやるべきかは賛否歩きがする※ provider に実際に 500 error を生成してもらうのは難しいことも多いだろうし、やり方によっては provider 側が擬似的に 500 を生成できるように仕組みを入れてくれることもあるかもしれない. あるいはこの手のテストは pact で扱わないように決める場合もあるだろう)
  • 401/403 はもし認証があれば
Run the consumer Tests!

もう十分能書きをたれたので、コードを実際に動かして consumer test をしよう。

Sharing the Contracts to the Provider Team

consumer test を実行すると pact file (contract) が成果物として生成され、次はそれを provider に提供しよう。そうやって consumer としての利用方法を満たしながら Order API の開発、メンテナンスをしてもらおう。 pacts を共有する方法は複数ある が、おすすめの方法は Pact Broker を使う方法だ。これはワークフローの自動化を協力にアシストしてくれる。

ちなみに他の手段はこんな感じのが紹介されている:

  1. Consumer CI build commits pact to Provider codebase
  2. Publish pacts as CI build artefacts
  3. Use Github/Bitbucket URL
  4. Publish pacts to Amazon S3

Pact Broker は自分でホストすることが前提の Open Source だが、 pactflow.io によってマネージドも用意されている。 developer plan に sign-up すれば無料で利用できるらしい。

発行された Pact は https://test.pact.dius.com.au/pacts/provider/GettingStartedOrderApi/consumer/GettingStartedOrderWeb/latest で確認できる。 username/password は 元記事を参照

Testing the Order API (provider) project

Scope of a Provider Pact Test

provider 側では、 Pact はすべてのインタラクション (大体の場合 http リクエスト) を provider のサービスに対して再実行することになる。いろいろあるあるうち、以下が主な選択肢になるはずだ:

  • MVC における Controller あるいは図における Adapter 部分を呼び出す
  • データベースをリアルなものを使うか mock するか
  • http server を mock するか

一般的に、結合テストとしては自身のシステムは全て本物を使って外部システムを mock することで、コストを抑えつつ試験をすることができる。 consumer 側と同じ様に provider 側の Pact によるカバレッジを図示すると、図の黄色い部分になる:

1. Create the Order API

以下は Express JS を使ったシンプルな API の作り方:

const express = require('express')
const cors = require('cors')
const bodyParser = require('body-parser')
const server = express()

server.use(cors())
server.use(bodyParser.json())
server.use(bodyParser.urlencoded({ extended: true }))
server.use((_, res, next) => {
  res.header('Content-Type', 'application/json; charset=utf-8')
  next()
})

// "In memory" data store
let dataStore = require('./data/orders.js')

server.get('/orders', (_, res) => {
  res.json(dataStore)
})

module.exports = {
  server,
  dataStore,
}
2. Run provider verification tests

実施するべき “provider verification” タスクは以下の通り

  1. contract ファイルがどこに有るか, Order API はどこで動いているか (L3-13)
  2. API を起動 (L16-18)
  3. provider verification タスクを実行 (L22)
// Verify that the provider meets all consumer expectations
describe('Pact Verification', () => {
  const port = 1234
  const opts = {
    provider: providerName,
    providerBaseUrl: `http://localhost:${port}`,
    pactBrokerUrl: 'https://test.pact.dius.com.au/',
    pactBrokerUsername: 'dXfltyFMgNOFZAxr8io9wJ37iUpY42M',
    pactBrokerPassword: 'O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1',
    publishVerificationResult: true,
    tags: ['prod'],
    providerVersion: '1.0.' + process.env.HOSTNAME,
  }

  before(async () => {
    server.listen(port, () => {
      console.log(`Provider service listening on http://localhost:${port}`)
    })
  })

  it('should validate the expectations of Order Web', () => {
    return new Verifier().verifyProvider(opts)
  })
})

使ってみる

翻訳した “5 minutes …” の途中で REPL なるオンライン Javascript IDE でのデモがでてくるが、その ソースリポジトリ をダウンロードしてきて実験すると良い。 気になるのが、今回の文章でも登場している OrderAPI 等のファイルがあるが、すぐ動くようにセットアップされていない。代わりに GET /dogs/ のレスポンスをテストする API が ready になっている。 Order API が気にはなるが、 dogs も簡単に実行できるし、とにかくこれで雰囲気が掴めそうである。

  • 利用方法: npm i, node index.js で consumer test が実行される。
    • consumer test の結果、 ./pacts/dogconsumer-dogprovider.json という名前の contract file が生成されるようになっている。
  • index.js でコメントアウトされている providerTest() を実行すれば provider test もできる。
  • 同じく publishPacts() を実行すると、今度は Pact Broker というものなのか、 DIUS (test.pact.dius.com.au) がホストしてくれている web に色々出力される。レポーティング充実していて素晴らしい。よくわからんけど。

consumerTest() を深堀り

  • index.js では ./consumer/consumer.spec.js から呼び出している
  • consumer.spec.js では実際にテスト内容が定義されている。

OpenAPI との融合 - Swagger Mock Validator

npm module である Swagger Mock Validator を使えば、

swagger-mock-validator path/to/swagger.yml path/to/pact.json

のコマンドで Open API Spec との整合性も確認できる! ( Protecting your API development workflows with Swagger/OpenAPI & Pact.io で発見。いい記事だこれ)

pact.io 公式 にも記述あった。

If you are using OpenAPI, consider using Swagger Mock Validator, a plugin developed at Atlassian that aims to unify these worlds.

実際に dogs API で試してみた

テスト結果から想像して OAS ファイルを書いてみた。

oas_dogs.yaml

openapi: 3.0.0
info:
  version: 0.1.0
  title: Dog API
tags:
  - name: dogs
    description: example apis
paths:
  /dogs:
    get:
      tags:
        - dogs
      summary: example api.
      description: example api.
      operationId: getDogs
      responses:
        '200':
          description: OK
          content:
            application/json;charset=utf-8:
              schema:
                $ref: '#/components/schemas/Dogs'
servers:
  - url: 'http://localhost'
components:
  schemas:
    Dogs:
      description: dogs
      type: array
      items:
        description: dog object
        type: object
        properties:
          dog:
            description: dog id
            type: integer
            example: 1
          name:
            description: dog name
            type: string
            example: 'John'

これに対して以下の通りに swagger-mock-validator を実行すると、 error, warning ともに発生せず、である:

$ swagger-mock-validator oas_dogs.yaml pacts/dogconsumer-dogprovider.json
0 error(s)
0 warning(s)

ここで失敗のお試しのために、レスポンスのうちの dog プロパティが 数字ではなく文字列という風に schema を書き換える:

# ...
components:
  schemas:
    Dogs:
      description: dogs
      type: array
      items:
        description: dog object
        type: object
        properties:
          dog:
            description: dog id
            type: string
            example: 'aaa'  # ここを修正した
          name:
            description: dog name
            type: string
            example: 'John'

そしてバリデーションすると、 Response body is incompatible with the response body schema in the spec file: should be string と見事にエラー(つまり Pact contract と OAS の不整合)を指摘される:

$ swagger-mock-validator oas_dogs.yaml pacts/dogconsumer-dogprovider.json
Mock file "pacts/dogconsumer-dogprovider.json" is not compatible with spec file "oas_dogs.yaml"
1 error(s)
	response.body.incompatible: 1
0 warning(s)
{
  warnings: [],
  errors: [
    {
      code: 'response.body.incompatible',
      message: 'Response body is incompatible with the response ' +
        'body schema in the spec file: should be string',
      mockDetails: {
        interactionDescription: 'a request for all dogs',
        interactionState: 'i have a list of dogs',
        location: '[root].interactions[0].response.body[0].dog',
        mockFile: 'pacts/dogconsumer-dogprovider.json',
        value: 1
      },
      source: 'spec-mock-validation',
      specDetails: {
        location: '[root].paths./dogs.get.responses.200.content.application/json;charset=utf-8.schema.items.properties.dog.type',
        pathMethod: 'get',
        pathName: '/dogs',
        specFile: 'oas_dogs.yaml',
        value: 'string'
      },
      type: 'error'
    }
  ]
}

Error: Mock file "pacts/dogconsumer-dogprovider.json" is not compatible with spec file "oas_dogs.yaml"
    at Object.<anonymous> (/Users/george/.ndenv/versions/v12.4.0/lib/node_modules/swagger-mock-validator/dist/cli.js:84:36)
    at Generator.next (<anonymous>)
    at fulfilled (/Users/george/.ndenv/versions/v12.4.0/lib/node_modules/swagger-mock-validator/dist/cli.js:5:58)

たしかにこれなら pact で test を行った結果として得られた contract と、 API 仕様との差分を常に確認することができる。

これってどういうことなんだろう?

その効用とは?どんなところに有効なんだ?

  • consumer-driven
    1. consumer が API に期待する動作を pact で定義し、それに合うようにクライアントを実装する -> test パスしつつ contract file を生成する
    2. provider が API spec 執筆中に、執筆した OAS ファイルと contract file との整合性を swagger-mock-vlaidator を使って確認する
      • 使い方は以下のような感じ
      # ローカルのファイルでもいいよ
      swagger-mock-validator /path/to/swagger.json /path/to/pact.json
      # ファイルは http アクセスできるところでもいいよ
      swagger-mock-validator https://api.com/swagger.json https://pact-broker.com/pact.json
      
    3. provider 側のシステムを pact contract file をもとに検証する