🐫

vcr gem の外し方について

に公開

この記事では、外部 HTTP 通信に関するテストを便利にするライブラリである vcr を使用していたプロジェクトに対し、それに対する依存を回避するまでの手順をまとめたものです。

モチベーションなど

vcr という gem が存在します。
テスト実行時に実際の HTTP での外部との疎通を記録し、次回以降はその記録した情報を実行時に再生することで外部通信に依存するテストを安定化かつ高速化します。

https://github.com/vcr/vcr

これは、内部では webmock という、 HTTP リクエストのスタブや期待値を設定するライブラリを使っています。

https://github.com/bblimke/webmock


過去には MIT でしたが、(6.0.0 から) 最新版 では Hippocratic License というライセンスに変わっています。

https://firstdonoharm.dev/version/2/1/license/

Hippocratic License はライセンスの中で倫理的な制約を明示的に設けており、これが多くのオープンソースライセンスとは一線を画すポイントとなっています。
この、「誰でも自由に使えるわけではない」という特徴が評価・議論の対象となっています。
そこで、意図的に 5.x からバージョンを上げずにそのまま維持している形をとるという選択肢も意味ある選択だったりしています。

しかしながら、 Hippocratic License である 6.x がリリースされてからすでに 5年が経ちました。
ライセンスを受け入れるか、あるいは vcr 以外の選択はいつかやることになるはずです。

このポストは、漸近的・合理的な vcr の依存の外し方について記述しています。

戦略

まず、 vcr について注目する機能として大きく 2つあります。
ひとつは、初回リクエストの内容をカセットというファイルに記録するという機能。
そしてもうひとつはすでに存在するカセットを使用し、次回以降の HTTP リクエストを模倣するという機能です。

方針としては、前者の機能の代替はあきらめます
この、前者の機能については実際の HTTP リクエストを観測して自前で用意するのが良いと判断しました。

で、後者の HTTP リクエストを模倣するという点についてですが、素の WebMock を使います。
vcr も内部では WebMock を使っているので合理的な選択肢です。

また、 WebMock を使うということは素の stub_request を記述することになりますがここはある程度省力化できます。
なぜならば、そこにはすでに今まで vcr で運用されてきたカセットファイルが資産として存在するからです。
つまり、 vcr のカセットファイルの内容を解析して stub_request する仕組みがあればよいのです。

この方針を元に、次節では実際の移行ステップを見ていきます。

実際の流れ

vcr カセットを再利用するヘルパを定義

vcr カセットをそのまま使いつつ、 vcr gem そのものへの依存は回避するということを検討します。
そのために、テストで使う以下のようなヘルパを用意します。

spec/support/vcr_compatibility_helper.rb
# 以下の VcrCompatibilityHelper を include して使います
#
# RSpec.describe do
#   include VcrCompatibilityHelper
#
#   it do
#     # http request testcode...
#   end
# end

module VcrCompatibilityHelper
  def use_vcr_cassette(vcr_cassette)
    path = Pathname.new(File.join(file_fixture_path, "vcr", vcr_cassette))
    path = path.to_s.delete_suffix(".yml") + ".yml"
    raise "VCR cassette not found - #{path}" unless File.file?(path)

    vcr_cassette = VcrCassette.new(path)
    vcr_cassette.interactions.each do |interaction|
      response = {
        status: interaction.status,
        body: interaction.response_body,
        headers: interaction.response_headers
      }

      stub_request(interaction.method, interaction.uri).to_return(**response)
    end
  end
end

class VcrCassette
  class Interaction
    def initialize(data)
      @request = data[:request]
      @response = data[:response]
    end

    def method
      @request[:method].to_sym
    end

    def uri
      @request[:uri]
    end

    def status
      @response.dig(:status, :code)
    end

    def response_body
      @response.dig(:body, :string)
    end

    def response_headers
      @response[:headers].to_h
    end
  end

  def initialize(file)
    cassette = YAML.load_file(file).with_indifferent_access
    @interactions = Array.wrap(cassette[:http_interactions]).map { Interaction.new(it) }
  end

  attr_reader :interactions
end

このようなヘルパメソッドを用意しておき、これをテストで使えるように include しておきます。
そうすると、既存の vcr を使っている記述を少しの調整で脱 vcr できるようになります。

-  VCR.use_cassette "Foo/Bar/Baz"
+  use_vcr_cassette "Foo/Bar/Baz"

これで、テストファイル各所に存在する vcr への依存箇所を解決できます。

vcr 関連設定を WebMock の設定に置き換え

上記ヘルパを使い、全ての箇所の VCR.use_cassette を自前のヘルパメソッドに置き換えできたらあと少しです。
ここでは vcr に関する設定を、 WebMock のものに置き換えます。

  • ignored_hostsWebMock.disable_net_connect!allow option に
VCR.configure do
  config.ignore_hosts("127.0.0.1", "localhost", "external.example.com")
end
WebMock.disable_net_connect!(
  allow: ["127.0.0.1", "localhost", "external.example.com"]
)

Gemfile から vcr を外す (1回目)

vcr に関する設定がなくなれば、あとは Gemfile から除外してみます。
このとき Gemfilewebmock がなければ代わりに追加しておきます。

bundle remove vcr
bundle add webmock --group=test

ここまでで CI がしっかり成功すれば、ここがゴールです!
そうでない場合は、もう少し続きます。

vcr を外したら Unhandled な HTTP Request が顕在化するケース

vcr を使用しているとき、意図せぬ通信が生じたときには VCR::Errors::UnhandledHTTPRequestError という例外が生じます。
通常、この例外が生じることでテストの失敗、といいますか HTTP 通信でスタブを用意する箇所があることを把握することができるわけですが以下のようなコードだとそれがわかりません。

begin
  # なにかしらの HTTP リクエストを要する処理
  # ...
  # もしここで Unhandled な HTTP Request が生じたら、 VCR::Errors::UnhandledHTTPRequestError が発生する。
  # (そして rescue 節へ)

rescue => e
  # 起こったエラーをとりあえずログしておいて処理はそのまま続行する
  # 上記で、 VCR::Errors::UnhandledHTTPRequestError が生じた場合もここに到達する
  Rails.logger.error(e)
end

vcr を導入していた場合はこのようなケースは VCR::Errors::UnhandledHTTPRequestError 例外が発生したときに rescue にジャンプしてそのまま処理が続行することになります。
コード自体の治安の悪さは置いておくとしても、この状態から脱 vcr するとなぜテストが失敗するようになるのでしょうか?


vcr を外すと、上記のような Unhandled な HTTP Request が生じた場合に投げられる例外が変わります。
VCR::Errors::UnhandledHTTPRequestError でなく、 WebMock::NetConnectNotAllowedError が発生するようになります。

で、この WebMock::NetConnectNotAllowedError についてですが継承ツリーを確認するとそこには StandardError が入っておらず、 Exception の直下のクラス定義です。

WebMock::NetConnectNotAllowedError.superclass #=> Exception

# 一方、 vcr のエラーはというと、
VCR::Errors::UnhandledHTTPRequestError.superclass #=> VCR::Errors::Error
VCR::Errors::UnhandledHTTPRequestError.superclass.superclass #=> StandardError

つまり、 WebMock::NetConnectNotAllowedErrorStandardError ではないわけです。
一方、 ruby の rescue は、捕捉する例外を指定しない場合は StandardError がデフォルトであるため Unhandled な HTTP Request が存在した場合にこの隠れていたエラーが顕在化することになっていたのでした。


こういった箇所について、テストコード側で stub_request を補完したコミットは、 vcr の gem の使用有無に関係なく動作するはずです。
なので vcr を外したブランチではなく、そのコミットだけを cherry-pick して別のブランチからどんどん main ブランチに先出ししてマージしましょう。

Gemfile から vcr を外す (N回目)

Unhandled なら HTTP Request に対して stub_request を補完していき、それらがすべて解決したら、いよいよ Gemfile から vcr を外しただけの状態でプルリクエストにできるはずです。

bundle remove vcr
bundle add webmock --group=test

これで CI の成功を観測したら、完全に vcr への依存が外れたことになります。
おつかれさまでした。

まとめ

  • Rails アプリケーションから vcr gem への依存を段階的に回避する方法を紹介しました
  • vcr カセットの資産は、カスタムヘルパによって WebMock 経由で再活用できます
  • ライセンスの懸念がある gem を自然な形で段階的に置き換える設計としても応用可能です
  • 特に注意すべき点は、Unhandled な HTTP リクエストに対する例外型の違いです

Discussion