実践 Pact:マイクロサービス時代のテストツール

技術部の taiki45 です。

以前「サービス分割時の複雑性に対処する: テスト戦略の話」という記事で、サービス間のインテグレーションテストにおける問題について紹介しました。現在のクックパッドではこの問題の解決のために Pact というツールを導入して運用しています。この記事では、その運用の知見を紹介できればと思います。

Pact

Pact は Consumer-Driven Contract testing (CDC testing) を実現するためのツールです。"Consumer"、"Provider" という見慣れない単語が出てきますが、この記事ではだいたい「Consumer = Web API クライアント」、「Provider = Web API サーバー」と対応ができます。この記事では具体的な Pact の利用例を通じて CDC testing がどういうものなのかについても紹介します。

必要になった背景

クックパッドでは、今までサービス間連携(他サービスの呼び出し結果を使うこと)の部分のテストには主に WebMock を使って書いていました。しかし、WebMock だと Provider が更新されても Consumer のスタブデータは更新されないので Provider 側の変更に Consumer のテストは追随できません。定期的にスタブデータを更新したり、RackVCR を使って Provider 側が変更されたら Consumer のスタブデータを更新することもできますが、それだとあくまで Consumer でのテストであるため、Provider が意図しない破壊的変更をリリースすることは防げません。それを防ぐには Provider のテストで Consumer に対する破壊的変更を検知できる必要があります。

そこで、Consumer の期待する振る舞いをプログラムから扱えるデータとして表現して、Provider に渡し、Provider 側でその「Consumer が期待する振る舞い」を満たしているか検証する手法で、Provider の変更がサービス間の連携を壊さないことを確かめることができます。この「Consumer が期待する振る舞い」を "Consumer-Driven Contract (CDC)" と呼んでいます。そして、この手法を実装に落とし込んだツールが Pact です。

Pact の仕組み

Pact では CDC は "pact file" と呼ばれるファイルへと JSON フォーマットで出力されて、Consumer の CI 時に生成されます。Consumer は CI 時に毎回 pact_broker と呼ばれるアプリケーションに pact file を "publish" し、pact_broker がバージョンや publish 日時といったメタデータと共に保管します。Provider は CI 時に pact_broker から自身が関連する Consumer が publish した pact file をチェックアウトして、新しい変更が Contract を壊していないか検証します。このテスト自体は pact file から RSpec の example を生成して実行することで実現されています。それぞれの example をパスさせるためには Provider のデータのセットアップが必要になることが多く、それは "Provider State" と呼ばれる機能で実現されています。Provider State は、Consumer が状態に一意な名前を付けて、Provider はその状態を再現するためのコールバックを登録できる仕組みです。

実際のフローは以下のようになります:

  1. Consumer プロジェクトがテスト内で Contract を記述する。Contract はリクエストと、そのリクエストに対応するレスポンスと期待する副作用が含まれる。
  2. Consumer のテスト実行時にモックサーバーを起動させる。Consumer は HTTP クライアントの向き先を起動したモックサーバーに向けてリクエストを発行する。モックサーバーは登録された Contract を元にレスポンスを返す。
  3. テスト成功後に Consumer は定義した Contract を JSON 形式でファイルに書き出し、pact_broker と呼ばれるサーバーにアップロードする。
  4. Provider は CI で pact_broker から関係する Consumer(s) がアップロードした Contract をダウンロードして、その Contract 通りに自身が振る舞うかテストを実行する。

work_flow_of_pact

https://github.com/realestate-com-au/pact/blob/v1.9.2/README.md より

実際のコードを見たほうがより理解しやすいと思うので、ここで示します。

次のような Consumer の期待が存在するケースを例にします:

「レシピが2つ存在している前提で、Consumer が Provider の /v1/recipes に対して GET リクエストを送信した時に、Provider は [recipe_a, recipe_b] となる構造や値のデータを Content-Type: application/json のようなヘッダー値とともにレスポンスする」

このケースでは Consumer 側のテストで以下のように Pact を使って HTTP リクエストをモックするとともに、Contract を記述できます:

RSpec.describe Recipe, pact: true do
  before do
    allow(described_class.client).to receive(:endpoint).and_return(provider_app.mock_service_base_url)
  end

  describe 'get_all' do
    let(:recipe_a) { { id: Pact.like(1), name: Pact.like('Curry') } }
    let(:recipe_b) { { id: Pact.like(2), name: Pact.like('Salada') } }

    before do
      provider_app.given('there are 2 recipes').
        upon_receiving('a request for recipes').
        with(method: :get, path: '/v1/recipes').
        will_respond_with(
          status: 200,
          headers: {
            'Content-Type' => Pact.term(generate: 'application/json', matcher: %r{application/json}),
          },
          body: [recipe_a, recipe_b]
        )
    end

    it 'returns recipes' do
      recipes = described_class.get_all
      expect(recipes.size).to eq(2)
      expect(recipes.first.name).to eq('Curry')
    end
  end
end

上記コードでは、まず、HTTP クライアントの API call 先をモックサーバーに向けています。その後、Pact のモックサーバーに対して Contract の登録をしています。テストケース内の described_class.get_all が評価されると、実際に HTTP リクエストがモックサーバーに送信され、セットアップしたレスポンスが返却されます。以上の流れでこのテストは成功します。

先ほどのテストを実行すると pact file が生成されます。pact file には Consumer/Provider の情報、Consumer が期待するリクエスト/レスポンスが含まれています:

{
  "consumer": {
    "name": "ConsumerApp"
  },
  "provider": {
    "name": "ProviderApp"
  },
  "interactions": [
    {
      "description": "a request for recipes",
      "provider_state": "there are 2 recipes",
      "request": {
        "method": "get",
        "path": "/v1/recipes",
        "query": "fields=media%5Bcustom%5D&image_size[recipe]=280"
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": {
            "json_class": "Pact::Term",
            "data": {
              "generate": "application/json",
              "matcher": {
                "json_class": "Regexp",
                "o": 0,
                "s": "application/json"
              }
            }
          }
        },
        "body": [
          {
            "id": {
              "json_class": "Pact::SomethingLike",
              "contents": 1
            },
            "name": {
              "json_class": "Pact::SomethingLike",
              "contents": "Curry"
            }
          },
          {
            "id": {
              "json_class": "Pact::SomethingLike",
              "contents": 2
            },
            "name": {
              "json_class": "Pact::SomethingLike",
              "contents": "Salada"
            }
          }
        ]
      }
    }
  ],
  "metadata": {
    "pactSpecificationVersion": "1.0.0"
  }
}

Provider 側では、この pact file を使って自身が振る舞うかテストを実行します。 Pact を使った CDC testing では、単体テストと同じように、Provider の検証テスト実行毎に環境を初期化します。そのため、Pact が "Provider States" という前提条件を記述する仕組みを提供していて、Provider の検証時にはその仕組みを使ってテスト時にデータのセットアップをします。上記の例では、there are 2 recipes という文字列が Consumer から指定され、Provider はその前提条件をセットアップするためのロジックを記述します:

Pact.provider_states_for 'ConsumerApp' do
  provider_state "there are 2 recipes" do
    set_up do
      %w[Curry Salada].each {|name| Recipe.create!(name: name) }
    end
  end
end

データのセットアップロジックが揃うと、後は Pact が提供する Rake タスクを使って Provider の振る舞いを検証するテストを実行します。テストが成功することを確認して一連のワークフローが完了します。

ここでは一部の Pact を使った CDC testing の一部のコード例を示しましたが、より詳細なコード例は Pact の README にあります。

Consumer のテストで WebMock などを使って HTTP リクエストを単にスタブするのに似ていて、Pact はさらにそこに Consumer が暗黙に期待していた内容をやりとりできるように pact file に落としこむ点が加わっています。

Pact というツールは、コアとなる機能を複数の言語実装から利用できるように、いくつかのコンポーネントに分割されています。ここでは主に Ruby 実装である pact gem を中心に、その周辺のコンポーネントと合わせて紹介します。

pact gem

https://github.com/realestate-com-au/pact

Ruby 用の Pact ライブラリです。各種設定機能や、Consumer のテストで Contract を定義するための DSL が実装されています。また、Contract を読み込んで、それをもとに RSpec のテストを自動で行うことによって Provider を検証する機能が実装されています。

pact file の生成やモックの登録や検証といった主要な機能は後述の pact-mock_service gem で実装されています。

pact-mock_service gem

https://github.com/bethesque/pact-mock_service

上述のように Pact のコアとなるような機能が実装されています。

  • Consumer が期待するリクエスト、レスポンスの組の登録を受け付ける
  • Web サーバーとして動作し、リクエストを受け付けて登録されたレスポンスを返却する
  • 登録されたリクエストが期待するパラメータで呼ばれたどうかを検証する
  • pact file を生成する

pact-mock_service はコマンドライン経由で起動できます。また、Ruby のプロセス内から実行することもできます。

pact-mock_service の役割

典型的なリクエスト/レスポンスを見てみると pact-mock_service の役割と Pact の仕組みについてわかりが得やすいのでここで試します。

まず、モックサーバーを起動します:

$ pact-mock-service service --pact-dir=. --port=3000

とりあえずヘルスチェックをします:

$ curl -H 'X-PACT-MOCK_SERVICE: 1' -v http://localhost:3000
* Rebuilt URL to: http://localhost:3000/
*   Trying ::1...
* Connected to localhost (::1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.43.0
> Accept: */*
> X-PACT-MOCK_SERVICE: 1
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Access-Control-Allow-Origin: *
< X-Pact-Mock-Service-Location: http://localhost:3000
< Server: WEBrick/1.3.1 (Ruby/2.3.0/2015-12-25) OpenSSL/1.0.2e
< Date: Tue, 21 Jun 2016 15:20:02 GMT
< Content-Length: 20
< Connection: Keep-Alive
<
* Connection #0 to host localhost left intact
Mock service running

Pact では Provider/Consumer 間で期待されるリクエスト/レスポンスの組を Interaction と呼びます。Pact の Contract は、Provider や Consumer に関する情報の他に、複数の Interaction から構成されています。

ここでその Interaction を1つ登録します:

$ curl -X POST -H 'X-PACT-MOCK_SERVICE: 1' -H 'Content-Type: application/json' \
  http://localhost:3000/interactions \
  -d '{"description": "a request for recipes", "request": {"method": "get", "path": "/recipes"}, "response": {"status": 200, "headers": {"Content-Type": "application/json"}, "body": "[]"} }'

Set interactions

登録した Interaction が呼び出されたかどうか検証します。ここではまだ呼び出してないのでエラーが返ります:

$ curl -H 'X-PACT-MOCK_SERVICE: 1' -v 'http://localhost:3000/interactions/verification?example_description=a%20request%20for%20recipes'
*   Trying ::1...
* Connected to localhost (::1) port 3000 (#0)
> GET /interactions/verification?example_description=a%20request%20for%20recipes HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.43.0
> Accept: */*
> X-PACT-MOCK_SERVICE: 1
>
< HTTP/1.1 500 Internal Server Error
< Content-Type: text/plain
< Access-Control-Allow-Origin: *
< X-Pact-Mock-Service-Location: http://localhost:3000
< Server: WEBrick/1.3.1 (Ruby/2.3.0/2015-12-25) OpenSSL/1.0.2e
< Date: Tue, 21 Jun 2016 15:36:26 GMT
< Content-Length: 144
< Connection: Keep-Alive
<
Actual interactions do not match expected interactions for mock MockService.

Missing requests:
    GET /recipes

* Connection #0 to host localhost left intact
See standard out/err for details.

登録した Interaction を呼び出します:

curl http://localhost:3000/recipes

[]

もう一度 Interaction が呼び出されたか検証します。すでに呼び出しているので今回は成功レスポンスになります:

curl -H 'X-PACT-MOCK_SERVICE: 1' -v 'http://localhost:3000/interactions/verification?example_description=a%20request%20for%20recipes'
*   Trying ::1...
* Connected to localhost (::1) port 3000 (#0)
> GET /interactions/verification?example_description=a%20request%20for%20recipes HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.43.0
> Accept: */*
> X-PACT-MOCK_SERVICE: 1
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Access-Control-Allow-Origin: *
< X-Pact-Mock-Service-Location: http://localhost:3000
< Server: WEBrick/1.3.1 (Ruby/2.3.0/2015-12-25) OpenSSL/1.0.2e
< Date: Tue, 21 Jun 2016 15:36:54 GMT
< Content-Length: 20
< Connection: Keep-Alive
<
* Connection #0 to host localhost left intact
Interactions matched

pact file の生成リクエストを送ります:

$ curl -X POST -H 'X-PACT-MOCK_SERVICE: 1' -H 'Content-Type: application/json' http://localhost:3000/pact -d '{"consumer": {"name": "A Consumer"}, "provider": {"name": "A Provider"}}'

{
  "consumer": {
    "name": "A Consumer"
  },
  "provider": {
    "name": "A Provider"
  },
  "interactions": [
    {
      "description": "a request for recipes",
      "request": {
        "method": "get",
        "path": "/recipes"
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json"
        },
        "body": "[]"
      }
    }
  ],
  "metadata": {
    "pactSpecificationVersion": "1.0.0"
  }
* Connection #0 to host localhost left intact
}

pact file が ${consumer_name}-${provider_name}.json という命名規則で書き出されています:

$ cat a_consumer-a_provider.json
{
  "consumer": {
    "name": "A Consumer"
  },
  "provider": {
    "name": "A Provider"
  },
  "interactions": [
    {
      "description": "a request for recipes",
      "request": {
        "method": "get",
        "path": "/recipes"
      },
      "response": {
        "status": 200,
        "headers": {
          "Content-Type": "application/json"
        },
        "body": "[]"
      }
    }
  ],
  "metadata": {
    "pactSpecificationVersion": "1.0.0"
  }
}

pact gem は Consumer のテスト実行時に DSL で登録された内容を元に、このようなリクエストをモックサーバーに対して実行することで Pact の機能を実現しています。

pact-mock_service gem は pact 以外からも利用できるように設計されているので、Ruby 以外の言語で Pact の Consumer を実装する時に pact-mock_service が利用できます。例として pact-consumer-swift という Swift 向けの Pact Consumer ライブラリがあります。 クックパッドでも pact-mock_service を利用した Swift 向けの Pact Consumer ライブラリを作成しました。そのうち公開されると思います。

また pact-mock_service 相当の機能を独自に実装しているような Pact ライブラリも存在します。例として pact-jvm があります。

pact_broker

https://github.com/bethesque/pact_broker

Consumer と Provider が pact file をやりとりするための仲介サーバーです。 Pact を使って CDC testing を実行するのに pact_broker は必須でありません。例えば Consumer と Provider を同じチームが開発している場合、手元での開発中は pact file をローカルのファイルシステムのどこかに置き、pact verification 時にそのファイルパスを指定する、ということもできます。しかし、複数の CI サーバーで各 Consumer/Provider の CI が実行されるような環境では、ストレージは独立していたほうが使い勝手が良く、かつ pact file のバージョンや更新日時や差分などのメタデータも合わせて管理できたほうが便利です。なので pact_broker という仲介サーバーが提供されています。

上述した機能の他に、各 Consumer/Provider 間の関係を可視化する機能が用意されていて、意外と便利です:

network_diagram

https://github.com/bethesque/pact_broker/blob/v1.9.2/README.md より

pact_broker は Rack アプリケーションとして実装され gem として提供されています。公式に Docker イメージも提供されています。

また Ruby 向けクライアントライブラリとして pact_broker-client が提供されています。

後述しますが、クックパッドでも pact_broker サーバーを用意して Provider/Consumer 間のやりとりに利用しています。

pact-specification

https://github.com/pact-foundation/pact-specification

Pact における Contract のデータ構造とマッチングの仕様に関するテストケース群が集まっているリポジトリです。 Ruby 実装の pact gem 以外に、pact-jvm など、他言語による実装がありますが、仕様に沿って実装されている限り Consumer と Provider の言語が異なっても互換性があるようになっています。

クックパッドでの Pact の運用

基本的には Pact が想定する使い方やワークフローを採用しています。Consumer の CI で pact file を生成し pact_broker へ publish、Provider の CI で pact verification を実行しています。

手元ではデフォルトで pact file を生成しない

開発者の手元でテストを実行する時には pact file を生成する必要がないので、pact file を生成しないようにしています。CI ジョブでテストを実行する時、もしくは明示的に指定された時のみ生成するようにしています。

pact file を生成しないオプションを追加するパッチを送り、環境変数でそのオプションを制御しています。

pact verification の結果を見やすくする

規模が大きいアプリケーションでは、pact verification が失敗した時に出力が多く、Jenkins のコンソール画面だと内容が把握しにくい問題がありました。そこで JUnit format で検証結果を出力する gem を作り verification 失敗時に素早く内容を把握できるようにしています。

pact file のバージョニング

Pact では pact file を pact_broker にアップロードする際にバージョンをつけることができます。pact_broker では x.y.z のようなよくあるバージョニング以外に、独自にバージョニングロジックを定義することができます。 クックパッドでは頻繁にリリースを行うため、バージョンを表すのに単に git revision hash が良く使われています。git revision hash 単体ではバージョン番号として順序を表すのに不適当なのと、日時を得るまでに1ステップ必要になってしまうので、日時と git revision hash 値を連結した値を pact_broker におけるバージョンとして採用しています。

pact_broker の運用

公式に Docker イメージが公開されているのですが、上記のバージョニングロジックや、ヘルスチェックエンドポイントの追加など、いくつか変更する箇所があったのと、社内にすでに Web アプリケーション用のベースイメージが存在しているので、独自にイメージを作成してそれを利用しています。

pact_broker には内部からのみアクセスできるようにアクセス制御をかけています。データベースには RDS 上の PostgreSQL インスタンスを利用しています。また、CI 環境から publish する本番環境と、チュートリアルやテスト用途に利用できるサンドボックス環境を用意しています。

動的なポート確保

Pact のチュートリアルでは Consumer がモックサービスを設定する際に固定なポート番号を指定しています。ポート番号を手で管理するのは面倒なので、動的なポート確保機能を使うようにしています。

以前の pact-mock_service でも find_a_port gem を使用して動的に空いてるポートを確保する実装が存在していたのですが、社内にある RRRSpec を利用して分散テスト実行しているアプリケーションのテストで webkit-server が起動しないなどの不具合があったため、モックサーバーを起動する時に port 0 に bind する実装に修正した上で pact gem からも使えるようにしました。

Remote Facade パターンと pact-expectations

サービス間通信を利用するようなアプリケーションでは設計のガイドラインを設けていて、Remote Facade パターンを推奨しています。特にテストで Pact を使う際は Contract の数や種類を極力減らしたいので、様々な層のテストで個別に Pact を使って HTTP リクエストをスタブするのではなく、Remote Facade のテストでのみ Pact を使い、その他の層では Remote Facade をモックするようにしています。

Remote Facade パターンを単純に適用するだけだと、Pact を使わずにモックしている箇所でその Expectation が検証されない問題がありました。Pact で記述する Expectation をそのまま Remote Facade をモックする際にレスポンスとして利用することでその問題を解決し、人事部長氏 の手によって pact-expectations gem という実装が作られ、利用しています。

集約的ライブラリの作成

上記のようなバージョニングロジックや、Pact を利用するのに必要なヘルパーの初期生成や、Railtie を使った Zero-Configuration 化などをするために、集約的ライブラリを作成しています。主に社内向けの設定を出力するので、このライブラリは公開していません。

まとめ

Consumer-Driven Contract testing を実現する Pact というツールについてと、クックパッドにおける Pact の運用について紹介しました。

今回 Pact を導入するにあたり、遭遇した不具合を修正したり機能を追加しました。例えば、UTF-8 への対応や、Expectation 内の query をネストできるようにすることや、pact verification の際に pact file の取得をリトライするようにするなど。みなさんが Pact を導入する際に、この記事及び Pact への貢献がお役に立てると幸いです。

クックパッドでは自由に OSS に貢献することのできる環境や文化があります。共にワイワイ OSS に貢献していく仲間を募集してます。応募をお待ちしています。

http://recruit.cookpad.com/jobs/career_recruitment


One more thing: 8月の iOS Developers Conference Japan で、トークが採択されれば Pact の話をするようなので、興味のある方は是非。

https://iosdc.jp/2016/c/node/62

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/