morishitaです。
最近、Stripeを利用した決済処理の実装をしていました。
サーバーサイド(Rails)でStripe APIを利用しており、そのテストの実装にvcrを利用しました。
以前から使っていましたが、改めて便利だと思ったのでご紹介。
外部APIを叩くテストのツラミ
システム開発をしているとすでにある他のシステムを力を借りるということがあります。
他のシステムのAPIを利用するということですね。
あるいは、ひとつのシステムに必要なすべての機能を実装せず機能ごとにシステムを分割し、お互いにAPIで呼び合ってサービスを実現することもあります。
マイクロサービスアーキテクチャってやつですね。
システム間連携する手段としては古くはSOAP、最近ではgRPCなど様々な方法がありますが、現状最も使われているのはHTTPプロトコルのREST APIではないでしょうか。
そのようなシステムのテストを実装する際、いちいちAPIを叩くのは大変です。
なぜなら、テスト対象のシステムの他にAPIを提供するシステムの準備が必要になるからです。
それに他社サービスをテストのために大量にコールするようなことをしては迷惑になりますし、従量課金のAPIだったらコストも馬鹿になりません。
なのでモックすると思います。
Railsのテストでよく使われるライブラリにwebmockがあります。
単にAPIへのコールをモックし、その成否程度が必要なだけならいいのですが、レスポンスの内容により処理が変わるとなるとそれを実装しないといけません。
私はここ最近、決済サービスStripeを利用したRailsアプリケーションを開発していたと冒頭書きましたが、お客様からお金をいただくサービスなのでテストもしっかり実装しました。
StripeのAPIは物によっては約3KBのレスポンスを返してきます。
レスポンスボディだけそんなにデータを返してくる、加えて、レスポンスヘッダもあります。それらをテストケースで必要な分用意するのは大変です。
もちろんレスポンス全部を処理に利用するわけではないので使う部分だけをモック実装してもいいのですが、モックとはいえできるだけ本物に近づけたい。
そう考えると… webmock 辛い。
VCRという救い
vcrを使えば、そんな面倒なWeb APIのモック実装から解放されます。
vcrもwebmock同様Web APIをモックするためのライブラリですが、自分でモックを実装する必要がありません。
設定にもよりますが、モックデータが無ければ、実際にAPIをコールしそのリクエスト/レスポンスをYAMLファイルに保存してくれます。
2回目以降は実際にはAPIを叩かずに保存したYAMLファイルからレスポンスを作ってモックしてくれます。
レスポンスヘッダを含め、同じレスポンスを再現でき本物に限りなく近いモックを使ってテストできます。
VCRを使う
前置きが長くなりましたが、RSpecで使う前提でVCRの使い方を紹介します。
インストールは Gemfileのtestグループにでもgem 'vcr'
を追加してbundle install
すればOKです。
設定
spec_helpler.rbでVCR設定します。
当社のとあるプロダクトでは次のような設定で利用しています。
require 'vcr' VCR.configure do |c| c.cassette_library_dir = 'spec/vcr' # カセットを保存するルートディレクトリ c.hook_into :webmock # 利用するモックライブラリ(内部ではwebmockを利用しています) c.configure_rspec_metadata! c.allow_http_connections_when_no_cassette = true # VCRを使わない場所ではHTTP通信を許可する c.default_cassette_options = { record: :new_episodes, # カセットがなければAPIをコールしてそれを記録する match_requests_on: [:method, :path, :query, :body], # カセットを引き当てる条件 } c.before_record do |interaction| # カセット保存前の処理の設定 interaction.response.body.force_encoding 'UTF-8' interaction.response.body = JSON.pretty_generate(JSON.parse(interaction.response.body)) if interaction.response.body.present? end end
VCRでは保存するモックデータをカセット(cassette) と呼びます1。
どのような設定をしているかはコメントでだいたい記述しましたが、ポイントを説明します。
record: :new_episodes
はカセットがなければ実際にAPIを叩いてそれを記録します。あればAPIを叩かずカセットを再生します。
match_requests_on
はリクエストに対応するカセットを何によって判定するかを設定します。
上記の例では[:method, :path, :query, :body],
を指定しています。これはリクエストのメソッドとパス、
クエリそしてリクエストボディが一致するカセットを再生するという意味です。
c.before_record
のブロックではカセットを保存する前の処理を実装しています。
上記の例ではレスポンスボディはUTF-8
とみなし、しかもJSONとみなして見やすく出力するようにしています2。
記録されるカセットの例を示します。
これはStripeの Refund(払い戻し)APIの例です。
ひとつのAPIのリクエスト/レスポンスのみを含んでいますが、一連の処理で複数回のAPIコールがあれば、すべてひとつのファイルに記録されます。
--- http_interactions: - request: method: post uri: https://api.stripe.com/v1/refunds body: encoding: UTF-8 string: charge=ch_XXXXXXXXXXXXXXXXXXXXXXXX headers: User-Agent: - Stripe/v1 RubyBindings/4.9.1 Authorization: - Bearer rk_test_F7uEYj6rWVCmzjOl7tm0qo6n004oYskC75 - Bearer rk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX Content-Type: - application/x-www-form-urlencoded X-Stripe-Client-User-Agent: - '{"bindings_version":"4.9.1","lang":"ruby","lang_version":"2.6.2 p47 (2019-03-13)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux version 4.9.125-linuxkit (root@659b6d51c354) (gcc version 6.4.0 (Alpine 6.4.0) ) #1 SMP Fri Sep 7 08:20:28 UTC 2018","hostname":"XXXXXXXXXX"}' Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: - "*/*" Connection: - keep-alive Keep-Alive: - '30' response: status: code: 200 message: OK headers: Server: - nginx Date: - Sat, 30 Mar 2019 16:15:08 GMT Content-Type: - application/json Content-Length: - '495' Connection: - keep-alive Access-Control-Allow-Credentials: - 'true' Access-Control-Allow-Methods: - GET, POST, HEAD, OPTIONS, DELETE Access-Control-Allow-Origin: - "*" Access-Control-Expose-Headers: - Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required Access-Control-Max-Age: - '300' Cache-Control: - no-cache, no-store Original-Request: - req_XXXXXXXXXXXXXX Request-Id: - req_XXXXXXXXXXXXXX Stripe-Version: - '2018-09-24' Strict-Transport-Security: - max-age=31556926; includeSubDomains; preload body: encoding: UTF-8 string: |- { "id": "re_XXXXXXXXXXXXXXXXXXXXXXXX", "object": "refund", "amount": 1810, "balance_transaction": "txn_XXXXXXXXXXXXXXXXXXXXXXXX", "charge": "ch_XXXXXXXXXXXXXXXXXXXXXXXX", "created": 1553962508, "currency": "jpy", "reason": null, "receipt_number": null, "source_transfer_reversal": null, "status": "succeeded", "transfer_reversal": null } http_version: recorded_at: Sat, 30 Mar 2019 16:15:08 GMT recorded_with: VCR 4.0.0
テストケースでの利用
さて、上記の設定を行い、実際のテストケースでどのように使うかを見ていきます。
決済を取り消す処理を実装したコントローラのSpecです。
describe RefundController, type: :controller do describe 'POST #refund' do subject { post :refund, params: { id: 'XXXXXXXXXXXXXX' }, format: :json } context '正常に払い戻せる場合', vcr: { cassette_name: 'refund-success' } do it 'Refund (払い戻し)が作られる' do expect { subject }.to change { Refund.count }.by(1) expect(subject).to have_http_status(:success) end end end end
vcr: { cassette_name: 'refund-success' }
の部分以外はいたって普通のコントローラスペックかと思います。
これだけでHTTP通信をモックしたテストになっています。
vcr: { cassette_name: 'refund-success' }
は次のことを設定しています。
- このコンテキストVCRを有効にする
- カセットファイルはspec/vcr/refund-success.yaml
カセットファイル名は指定しなくてもコンテキスト名で作ってくれる機能あります。
ただ、昔VCRをアップデートしたら2バイト文字があるとうまく動かないトラブルに見舞われたことがあり、それ以来私は指定しています3。
spec/vcr/refund-success.yamlの中身は上記のカセットの例になります。
初めて実行したときには実際にStripeのAPIにアクセスし、カセットファイルに記録します。2回目以降はカセットが再生されます。
とても簡単ですね。webmockのようにモックを準備するコードは一切なしでOKです。
カセットファイルを作り直したいときには、カセットファイルを削除して再実行するだけです。
注意点
とっても楽にWeb APIをモックしたテストが実装できるのですが、利用には注意が必要です。
それはモックはモック。フェイクであって本物ではないということです。
これはVCR特有ではなくモックに共通した注意点です。
モックしたAPIの仕様の変更は常にチェックが必要です。当たり前の話ですが、モックは作ったときの仕様のままです。
APIの仕様が変更されればそれに追従する必要があります。
さもないとテストは通るけど本番適用すると動かないというトラブルに繋がります。
外部に公開されているAPIなら移行期間を設けると思うので、すぐに古い仕様のAPIが使えなくなることはないと思いますが、放置すると廃止される可能性もあるので注意が必要でしょう。
それ以上に注意が必要なのは非公開の社内APIだったりします。
移行期間など設けず、変更してしまうなんてことが起こりがちです。
それが利用者にとって破壊的な変更だとトラブルになります。
それぞれのプロダクトのチーム間で変更について共有し、注意しあえるコミュニケーションが重要です。
まとめ
VCRでWeb APIのモックを楽しよう!
最後に
アクトインディではエンジニアを募集しています。