morishitaです。
外部 API 等へのアクセスする処理のテストを実装する際、毎回 API を叩くわけにもいかないのでモックすることになると思います。
そんなときに便利な Gem が VCR です。 次のエントリで以前紹介しました。
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 の describe
で vcr:
キーで個別に 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-Type
は application/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
最後に
アクトインディではエンジニアを募集しています。
-
そのような動的なトークンはおそらく認証 API などで取得するか、固定の文字列(アカウント ID など)を種にして計算するものでは思われます。認証 API にアクセスする場合にはパスワード等を送信することになると思うので、それも秘密情報になりますし、取得したトークンの有効期限がながければリポジトリに入れるのは心配です。計算で求めるものは算出ロジックは公開されていると思うので計算例が多ければ種がバレる可能性もあると思われます。↩