アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

VCR 設定 Tips

morishitaです。

外部 API 等へのアクセスする処理のテストを実装する際、毎回 API を叩くわけにもいかないのでモックすることになると思います。

そんなときに便利な Gem が VCR です。 次のエントリで以前紹介しました。

tech.actindi.net

VCR を簡単に説明すると、次の機能を持ったライブラリです。

  • テスト中に発生する HTTP 通信を YAML ファイルに記録する。
  • 次の実行から API を叩かずに記録した情報をレスポンスとして返す。

詳しくは上記のエントリで紹介していますので、「VCR ってなに?」だった方は先にそちらを読んでから本エントリを読むほうがわかりやすいかと思います。

基本の設定

RSpec でテストを実装する場合、 spec_helper.rb 等に次のような設定を追加します。

VCR.configure do |config|
  config.cassette_library_dir = 'spec/vcr' # ①
  config.hook_into :webmock # ②
  config.configure_rspec_metadata! # ③
  config.allow_http_connections_when_no_cassette = true # ④
  config.default_cassette_options = {
    record: :new_episodes, # ⑤
    match_requests_on: %i[method path query body_as_json], # ⑥
  }
end

①は VCR が通信を記録する YAML ファイル(カセット)を保存するルートディレクトリを指定しています。
②は VCR の内部で利用するモックライブラリを指定しています。指定したものは別途インストールが必要です。
③は RSpec の describevcr: キーで個別に VCR の設定を上書きできるようにするための設定です。まあ、おまじないと思って書いておけばいいです。
①②③が RSpec との組み合わせで利用する場合の最低限必要な設定です。

④は VCR を使用しないテスト場合に通信を許可するかどうかで true なので許可しています。
⑤はカセットがなければ実際に通信して記録するという設定。
⑥はリクエストの一意性をどのリクエストの要素で判定するかを設定しています。API の仕様に合わせて調整します。

基本の設定は以上ですが、以下知っておくと便利な設定を紹介します。

リクエスト/レスポンスを見やすく整形する

モダンな API では application/json なレスポンスを返すものがほとんどでしょう。
VCR はなにも設定しなければ API のレスポンスをそのまま保存します。
殆どの API はサイズを減らすため改行やスペース、インデントが省かれているので見やすい JSON 形式ではありません。
改行やインデントを入れて見やすい形式で保存するには次のようなコードを設定に追加します。

  config.before_record do |interaction|
    interaction.response.body.force_encoding 'UTF-8'
    response_content_type = interaction.response.headers['Content-Type'].first
    if response_content_type.include?('application/json')
      interaction.response.body = JSON.pretty_generate(JSON.parse(interaction.response.body))
    end
  end

config.before_record にブロックを渡すとそれを保存前に実行してくれます。
ブロック変数にリクエスト等のデータが渡されるので保存前のデータをここで弄ってやるわけです。
レスポンスボディは interaction.response.body に格納されています。
上記コードではそれを 'Content-Type''application/json' の場合にのみ JSON.pretty_generate で見やすく整形しています。

一方、リクエストでは GET だとリクエストボディはありません。POST の場合でも Content-Typeapplication/x-www-form-urlencoded などが多く、 JSON であることは比較的少ないと思います。
リクエストボディでも JSON を送信するようなものは例えば GraphQL API です。そういう API ではやはりリクエストボディも整形したくなるでしょう。
これも同様の設定を実装すれば見やすい形に整形できます。

VCR.configure do |config|
  config.before_record do |interaction|
    interaction.request.body.force_encoding 'UTF-8'
    request_content_type = interaction.request.headers['Content-Type'].first
    if request_content_type == 'application/json' && !interaction.request.body.empty?
      interaction.request.body = JSON.pretty_generate(JSON.parse(interaction.request.body))
    end
  end
end

レスポンスのときとの違いは interaction.request.body を整形しているところだけですね。

秘密情報をマスクする

アクセストークンで認証する API の場合、リクエストでトークンを送ることになるわけですが、VCR の出力にはリクエストヘッダ等も含まれており、それも記録されてしまいます。
そんなコードをリポジトリに入れるわけにはいかないですね。

そういった秘密情報は次の設定を追加することでマスクできます。

VCR.configure do |config|
  if ENV['ACCESS_TOKEN']
    masked_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    config.filter_sensitive_data(masked_token) { ENV['ACCESS_TOKEN'] }
  end
end

config.filter_sensitive_data はブロックの戻り値の文字列を引数に与えた文字列で置換してくれます。上記例では環境変数 ENV['ACCESS_TOKEN'] に秘密情報が格納されているとして、 それに一致する文字列を masked_token の値で置換します。 プロダクションコード側でも環境変数などでコード外からトークンの情報を渡すように実装すると思うのでこれで秘密をリポジトリに入れることはなくなります。

API の仕様で秘密情報が格納されているヘッダなどの場所は決まっているはずです。なのでその場所をピンポイントで置換する実装も可能です。しかし、その場合 API のヘッダーや Cookie の仕様まで知っている必要があるでしょう。 長時間変わらないセッション ID や、計算で算出する動的なトークンが格納されている場合には、リクエスト・レスポンスの構造を調べてマスクする必要があるでしょう1

しかし、固定のトークンで認証する API は多く、それらを利用するプロダクションコードでも環境変数などで渡すことが多いと思います。ならばその環境変数の値をとにかく置換するようにしたほうが API の詳細を知らなくても良くて手っ取り早く、 API の仕様が変わっても影響されずに確実にマスクできるのでおすすめです。

参考:Filter sensitive data - Configuration - Vcr - VCR - Relish

デバッグログを出力する

設定とはいえ、リクエストやレスポンスをパースしていじるようなプログラムを含むので、うっかりするとバグを作り込んでしまいエラーが発生することもあります。エラーが発生すると記録されるはずのカセットの YAML ファイルが出力されないので「あれ?」となります。修正するにはなにが起こっているのか知る必要がありますが、何も出力されません。
その場合には次のように debug_logger を設定してやると VCR のログが出力されるようになります。

VCR.configure do |config|
  # 〜 略 〜
  config.debug_logger = File.open('vcr-debug.log', 'w')
  # 〜 略 〜
end

リクエストで送信する値やアクセス先のリソースの状態によりリクエスト・レスポンスの構造が変わるような API があります。構造が変わると言っても値がなければキーがなくなるなどですが、存在しないキーの値に操作しようとするとエラーになりますし、キーはあっても null 値でもエラーになります。
そのように存在したりしなかったりする API の部分を掴んでいじるようなコードを VCR の設定に実装すると、エラーになったりならなかったりしてハマりがちです。でも、なにが起こっているのかわかれば解決できるでしょう。

まとめ

このエントリで紹介した設定を全部含む設定全体は次のようになります。

VCR.configure do |config|
  config.debug_logger = File.open('vcr-debug.log', 'w') # for debugging
  config.cassette_library_dir = 'spec/vcr'
  config.hook_into :webmock
  config.configure_rspec_metadata!
  config.allow_http_connections_when_no_cassette = true
  config.default_cassette_options = {
    record: :new_episodes,
    match_requests_on: %i[method path query body_as_json],
  }

  if ENV['ACCESS_TOKEN']
    masked_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    config.filter_sensitive_data(masked_token) { ENV['ACCESS_TOKEN'] }
  end

  config.before_record do |interaction|
    interaction.request.body.force_encoding 'UTF-8'
    request_content_type = interaction.request.headers['Content-Type'].first
    if request_content_type == 'application/json' && !interaction.request.body.empty?
      interaction.request.body = JSON.pretty_generate(JSON.parse(interaction.request.body))
    end

    interaction.response.body.force_encoding 'UTF-8'
    response_content_type = interaction.response.headers['Content-Type'].first
    if response_content_type.include?('application/json')
      interaction.response.body = JSON.pretty_generate(JSON.parse(interaction.response.body))
    end
  end
end

最後に

アクトインディではエンジニアを募集しています。

actindi.net


  1. そのような動的なトークンはおそらく認証 API などで取得するか、固定の文字列(アカウント ID など)を種にして計算するものでは思われます。認証 API にアクセスする場合にはパスワード等を送信することになると思うので、それも秘密情報になりますし、取得したトークンの有効期限がながければリポジトリに入れるのは心配です。計算で求めるものは算出ロジックは公開されていると思うので計算例が多ければ種がバレる可能性もあると思われます。