運用中のサービス間の堅牢性をあげるために、Pact導入を試みた話

この記事は、freee Developers Advent Calendarの18日目です。

はじめまして! freee株式会社のエンジニアの id:Maco_Tasu です。最近の趣味はゲーム実況をYoutubeでみることです。今日は私が所属しているチームで行ったHackweekという取り組みの中で、運用中のサービス間における堅牢性向上を試みた話について書きたいと思います。

Hackweekとは

Hackweekとは私が所属するチームで11月に行った取り組みの一つで、各エンジニアがそれぞれ課題に感じていることに対して各自のアプローチで一週間それだけに集中して行う取り組みです。

私が取り組んだこと

freeeにはマイクロサービスが存在します。いくつかのマイクロサービスでは通信を容易に行えるようにするための専用のクライアントgemがあります。クライアントのテストコードでは、マイクロサービスとの通信箇所の処理はwebmockでマイクロサービスから返ってくるであろう値を返すようにstubしています。このような方法のテストの場合、

  • マイクロサービス側のみ修正して、クライアント側でstubしている箇所の修正漏れなど起こっていても気づきにくい
  • マイクロサービス側のレスポンスの値を変更すると、マイクロサービス側のテスト、クライアント側のstubしている値の二箇所を修正する必要がある

以上のような事が起こりえます。ここで一番問題なのは、マイクロサービス側の修正を行った際に、クライアント側の修正漏れが起こり得ることです。現在は念入りな動作確認やQAさんのチェックを通しているので、そのタイミングで問題には気付けるのですが、ここもより早いタイミングで機械的に検知できるようにすることでサービス間の堅牢性につながるのではないと考えていました。そこで思いついたこととしては、クライアントがリクエストを正しく処理できることを正だとみて、それにマイクロサービスが期待するレスポンスを返せるかという部分をテストで検知できたらこの問題解消されるのでは?と考えていました。これを実現するためのいい感じのツールがないかと色々なテスト方法を検索していて調べたところ、Cookpadさんが執筆されていた実践 Pact:マイクロサービス時代のテストツールという記事をみつけて「これだ!」となりPactの導入を試みてみました。

Pact とは

Consumer-Driven Contract testing を実現するためのツールです。クライアント駆動でテストを作成し、クライアントが期待しているリクエストを実際にサーバーが返せるかの検証をすることができます。実際に使う際には、大きく以下のような手順を踏むことになるかと思います。

  1. consumer(クライアント) にPactのgemを導入して、rspecなどで作ったAPI呼び出し処理に対するテストケースを作成します
  2. 1を実行した際のリクエストとレスポンスがPact fileに記録され吐き出されます。
  3. privider(マイクロサービス) にPact fileのテストを実行するのに必要な初期データを作成します。
  4. 初期データが作成できたらprivider側でbundle exec rake pact:verifyを実行します
  5. この際consumerが期待するレスポンスをprividerが返せるか pact fileにそって検証されます

参照: https://github.com/pact-foundation/pact-ruby

こうやってみると意外にすぐできそうです。今回はこれを運用中のサービスに導入を試みました。

運用中のサービスへの導入

運用中のサービスに導入しようとした場合、クライアント側はwebmockを既に使っていたら少しの設定の追加とwebmockからの置き換えの対応が必要です。

クライアント側

1. まずPactがテスト時にリクエストが受け付けるためのmock serverが起動できるように設定を追加します

spec_helper以下のような設定を追加します

require 'pact/consumer/rspec'

Pact.service_consumer "client" do
  has_pact_with "microservice" do 
    mock_service :microservice do
      port 3001 # 適当なポート
    end
  end
end
2. 次にwebmockを使用している箇所はPactのmock serverを参照するように以下のように変更します

例えば以下のようなwebmockの設定があったら次のような感じで書き換えます。

before: mock_server

stub_request(method, "#{host}#{path}?#{string_query}").
      to_return(status: status, body: body.to_json)

after: pact

string_query = "#{params.to_query}"
description = "#{self.class.it.full_description}"
escape_path = URI.escape(path)
mock_service.given(description).
      upon_receiving(description).
      with(method: method, path: escape_path, query: string_query).
      will_respond_with(
        status: status,
        headers: {
          'Content-Type' => Pact.term(generate: 'application/json', matcher: %r{application/json}),
        },
        body: body
      )

webmockからPactに置き換えた場合でも、Pactがテスト中にmockしたAPIはコールされたかなど検証してくれます。 ここで記述されているupon_receiving(description)の箇所は、マイクロサービス側でテストを実行する時にクライアント側のどのテストと結びついているかを表す識別子的な役割を果たします。そのためここはユニークな値でなければいけないのですが、私の場合、"#{self.class.it.full_description}"を渡すここでとりあえずうまくいっています。

3. Pactを適用させたいテストケースに以下を追加
describe 'test', pact: true do
  ...
end

例のようにpact: trueの書いてあるテストケースのみPactが有効になり、テスト実行時にファイルに書き出されるようになります。この設定があるおかけで運用中のサービスのテストケースに少しずつテストケースを反映することができ、導入のハードルが下がりました。ここで気をつけないといけないのは、一つのテストファイルでwebmockを使いつつPactもつかいたいケースで、Pactを適用させたテストケース実行時にはwebmockを無効にしないとwebmock側で、「そんなリクエストはモックしてないのでしらないぞ!」って怒られてしまいます。そこで柔軟に対応できるように私はspec_helperは次のような設定に変更しました。

RSpec.configure do |config|
  require 'webmock/rspec'
  include WebMock::API

 config.before :suite  do
    WebMock.enable!
  end

 config.before :all, pact: true do
    WebMock.disable!
  end

 config.after :all, pact: true do
    WebMock.enable!
  end
end


def mock_request(method: :get, host: '127.0.0.1:9292', path: '/', params: {}, body: {}, status: 200)
  if self.class.metadata[:pact]
    # pactを使ったmock
  else
    # webmockを使ったmock
  end
end

以上のような設定を追加することで、一つのファイルでwebmockもPactも共存できるようになります。

マイクロサービス側

こちらはクライアント側で作成されたテストケースに対しての初期データ設定のみ対応が必要です。基本的にはControllerのテストが正しく書かれていたらそこで作成されているテストデータを、Pactから使われる箇所へ移植する作業になります。導入にあたる手順は以下になります。

1. クライアント側が生成したファイルをPactが読み取れるように設定を追加します

ruby Pact.service_provider 'microservice' do honours_pact_with 'client' do pact_uri 'jsonファイルが置かれている場所を指定' # 手元で動かすには相対パスでクライアントが吐き出したファイルを指定する感じです。 end end

2. ファイルに書かれたテストケースと対になるテストデータを追加します

基本的にはよくあるFactoryGirl(FactoryBot)、DatabaseCleanerを使ったテストデータの作成方法と同じです。

# FactoryGirlの設定
Pact.configure do | config |
  config.include FactoryGirl::Syntax::Methods
end

# 各テスト時にデータがクリアされるようにする
Pact.set_up do
  DatabaseCleaner.strategy = :transaction
  DatabaseCleaner.start
end

Pact.tear_down do
  DatabaseCleaner.clean
end


Pact.provider_states_for 'client' do
  provider_state 'クライアントで設定しているテストケース名' do # 先述した upon_receiving(description) の名前
    set_up do
       # テストデータセットアップ
    end

    tear_down do
       # セットアップデータ破棄
    end
  end

  # ... 以下テストが続く
end

以上で準備完了です。あとはマイクロサービス側で

bundle exec rake pact:verify

を実行するとクライアント側が吐き出したファイルに沿ってマイクロサービス側でリクエストを受けて、期待するレスポンスを返せるか検証されます。今回は手元で確認するところまでしか導入できてませんが、これをCIなりにのせて自動化していくのが次のスッテプかなというところです。

導入してみて感想

初見のツールだったので仕様の把握に少し時間はかかったものの、一度仕組みさえ整えてしまえばPactを意識しないで今まで通りテストがかくことができました。導入して他に気づいたことや感想は以下になります。

  • [気づき] 現状stubしている箇所で、requestまたはresponseも追従できないものがいくつか見つけることができた
  • [気づき] 現状追従できていない箇所があるものので問題になっていないのは、入念な動作確認やQAの皆様のチェックのおかげが大きいことに改めて気づけた
  • [感想] 実際にクライアント・マイクロサービス間のリクエストを機械的に検証できるのはすごく安心感ある
  • [感想] テストケースごとにPactを使うことができるので、徐々に導入していけて運用中のサービスからでも使いやすかった
  • [感想] Pactを使ったクライアント側のテストがしっかりしていたら、マイクロサービス側のControllerのテストで意味が重複してくるのでテスト自体なくてもいいのではないかと感じた

以上のような感想をいだきました。

最後に

いかがでしたでしょうか。今回はクライアント・マイクロサービス間の堅牢性向上のために、Hackweekを通して運用中のサービスにPactを導入する時に実際にどうやったかという点と、気をつけないといけない箇所について触れました。少しでも同様の問題でお悩みの方への参考になりましたら幸いです。

明日19日目は、freeeのエンジニアで多彩な趣味をお持ちなtakumiさんです!お楽しみに!