かずおの開発ブログ(主にRuby)

日々の開発のことを色々書きます。

VCRを使って開発中にあほみたいにリクエストを飛ばさないようにする

RubyでWebサイトからスクレイピングしまくってデータを取得しまるくるようなプログラムを書いていると開発のテスト中にリクエストをあほみたいに飛ばしてしまうときがあります。

そうするとそのサーバーに迷惑がかかってしまいますし、あんまりいい気持ちのするものでもありません。そこで今回はVCRというgemを使ってこの問題を解決してみたいと思います。

VCRとは

vcr/vcr · GitHub

VCRとはテスト中に過去に投げたリクエストをカセット(cassete)に記録しておいて、その後再度同じリクエストが投げれられた場合はサーバーではなく、記録しておいたカセットからリクエストの結果を取り出すというものです。

これによって、上記のようにサーバーに迷惑がかかることもなくなりますし、テストのスピードも格段に早くなります。

簡単な使い方

とりあえずどんな感じになるのか、上記githubに載っているsampleをrspecバージョンで書き換えて簡単なサンプルを試してみたいと思います。

まず必要なgemがインストールされていない場合はしておきます。

gem install vcr rspec webmock

そして

spec --init

を実行 するとspecファイルが生成されます

create   .rspec
create   spec/spec_helper.rb

続いて、sample_spec.rbを生成します。

touch sample_spec.rb

まずはspec_helperの末尾にVCRの設定を書き込んでいきます。

# spec_helper.rb
VCR.configure do |config|
  config.cassette_library_dir = "vcr/vcr_cassettes"
  config.hook_into :webmock
end

config.cassette_library_dirで記録したリクエストのymlファイルを保存しておくディレクトリを設定して、config.hook_intoでHTTPリクエストをどのようにhookするかを設定しています。

その他の設定できる項目については公式サイトに記載がありますので興味のある方は見てみてください。 Configuration - Vcr - VCR - Relish

続いてsample_spec.rbに実行するテストを書き込んでいきます

# sample_spec.rb
require_relative './spec_helper.rb'
require 'vcr'

RSpec.describe 'sample' do
  it 'sample' do
    VCR.use_cassette("get_google") do
      100.times do
        response = Net::HTTP.get_response(URI('https://www.google.com'))
      end
    end
  end
end

googleに100回getアクセスするというプログラムです。google先生なら100回くらい大丈夫でしょう。

ポイントはVCR.use_cassette(カセット名) do endの部分です。こちらでどのカセットを使うのかを指定しています。

今はまだget_googleという名前のカセットはありませんが、1度このプログラムを実行することで、カセットが作成されます。

では1度目のテストを実行してみましょう。

rspec sample.rb

すると以下のような結果が出力されました。

Finished in 5.16 seconds (files took 0.66782 seconds to load)
1 example, 0 failures

100回getをするのに5.16秒かかりました。

ここでvcr/vcr_cassetes/ディレクトリを見てみるとget_google.ymlができているのがわかります。

中身を見てみると1回目のテストで実行されたリクエストが記録されているのがわかります。

ではもう一度テストを実行してみます

$ rspec sampler.rb

$ Finished in 0.24276 seconds (files took 0.47402 seconds to load)
1 example, 0 failures

!!!早い!!超早い!!!これは実際にリクエストを投げたわけではなくカセットに記録しておいたリクエストの結果を読み込んでいるからです。

VCRすごいですね!問題が見事に解決されました。

こんなところで簡単な使い方はOKかと思います。

よく出るエラーの対策

例えば先ほどのsample_spec.rbをこんな風に書き換えたとします。

# sample_spec.rb

require_relative './spec_helper.rb'
require 'vcr'

RSpec.describe 'sample' do
  it 'sample' do
    VCR.use_cassette("get_google") do
      100.times do
        response = Net::HTTP.get_response(URI('https://www.yahoo.co.jp')) #google→yahooに書き換え
      end
    end
  end
end

で同じテストを実行しようとするとこんなエラーが吐かれます

1) sample sample
     Failure/Error: response = Net::HTTP.get_response(URI('https://www.yahoo.co.jp'))

     VCR::Errors::UnhandledHTTPRequestError:

       ================================================================================
       An HTTP request has been made that VCR does not know how to handle:
         GET https://www.yahoo.co.jp/

       VCR is currently using the following cassette:
         - /Users/user_name/Desktop/GitHub/vcr_sample/spec/fixtures/vcr_cassettes/get_google.yml
         - :record => :once
         - :match_requests_on => [:method, :uri]

       Under the current configuration VCR can not find a suitable HTTP interaction
       to replay and is prevented from recording new requests. There are a few ways
       you can deal with this:

         * If you're surprised VCR is raising this error
           and want insight about how VCR attempted to handle the request,
           you can use the debug_logger configuration option to log more details [1].
         * You can use the :new_episodes record mode to allow VCR to
           record this new request to the existing cassette [2].
         * If you want VCR to ignore this request (and others like it), you can
           set an `ignore_request` callback [3].
         * The current record mode (:once) does not allow new requests to be recorded
           to a previously recorded cassette. You can delete the cassette file and re-run
           your tests to allow the cassette to be recorded with this request [4].
         * The cassette contains 100 HTTP interactions that have not been
           played back. If your request is non-deterministic, you may need to
           change your :match_requests_on cassette option to be more lenient
           or use a custom request matcher to allow it to match [5].

       [1] https://www.relishapp.com/vcr/vcr/v/3-0-0/docs/configuration/debug-logging
       [2] https://www.relishapp.com/vcr/vcr/v/3-0-0/docs/record-modes/new-episodes
       [3] https://www.relishapp.com/vcr/vcr/v/3-0-0/docs/configuration/ignore-request
       [4] https://www.relishapp.com/vcr/vcr/v/3-0-0/docs/record-modes/once
       [5] https://www.relishapp.com/vcr/vcr/v/3-0-0/docs/request-matching
       ================================================================================

ooops

An HTTP request has been made that VCR does not know how to handle:
GET https://www.yahoo.co.jp/

とあるようにそんなリクエストの返し方知らねーよバカって書いてます。

当たり前といえば当たり前で1回目に記録したgoogleに対するアクセス記録を使ってyahooにアクセスしようとしてるわけですから知らなくて当然です。

このことから、デフォルトでは保存されているアクセスはカセットから返して、保存されていないものは新たに記録するといったよしなな感じになっていないことが分かります。

でも結構親切に対策方法を書いてくれているので安心です。1つずつ見ていきましょう

1つ目

If you're surprised VCR is raising this error
and want insight about how VCR attempted to handle the request,
you can use the debug_logger configuration option to log more details [1].

「エラーの原因とかもうちょい詳しく知りたかったらログとか出してみたらいいと思うよ」って言ってます。

ログを取ってみましょう。spec_helper.rbにログファイルを生成する設定を記載します。

# spec_helper.rb
VCR.configure do |config|
  config.cassette_library_dir = "fixtures/vcr_cassettes"
  config.hook_into :webmock # or :fakeweb
  config.debug_logger = File.open("log","w")
end

これでテストを実行すると、logファイルが生成されるので中身を見てみます。

上略...
[Cassette: 'get_google'] Checking if [get https://www.yahoo.co.jp/] matches [get https://www.google.com/] using [:method, :uri]
[Cassette: 'get_google'] method (matched): current request [get https://www.yahoo.co.jp/] vs [get https://www.google.com/]
[Cassette: 'get_google'] uri (did not match): current request [get https://www.yahoo.co.jp/] vs [get https://www.google.com/]
[webmock] Identified request type (unhandled) for [get https://www.yahoo.co.jp/]

ひたすら100回分のgoogleに対するgetが読み込まれたのに(なのでほんとはdo loopの中にuse cassetteを書くべきでしたね。。) 最後にyahooに対するgetが呼ばれてそれは知らないとなってますね。 今回に関して言えば原因がわかっているのであまり有用な情報ではありませんが、原因がわからないときには有効な手法です。

続いて2つ目

You can use the :new_episodes record mode to allow VCR to
record this new request to the existing cassette [2].

「:new_episodesっていうオプションつけたらこの新しいリクエストをexisting cassetteに記録できるよ!」って言ってます。

これやりたかったことっぽいですね。やってみましょう

# sample_spec.rb
VCR.use_cassette("get_google", :record => :new_episodes) do #:record => :new_episodesオプションを追加
    100.times do
        response = Net::HTTP.get_response(URI('https://www.yahoo.co.jp'))
    end
end

これで実行すると先ほどのエラーは出ずに、get_google.ymlの末尾にyahooへのgetが記録されているのがファイルをみると確認できるかと思います。どんどん新しいものを同じカセットに記録していきたいならこの設定にしておけば良さそうです。

続いて

If you want VCR to ignore this request (and others like it), you can
set an `ignore_request` callback

「VCRに無視してほしかったらignore_requrestして無視してちょーだい」って言ってます。

やってみましょう。

まずsample_spec.rbを書き換えます

# sample_spec.rb
require_relative './spec_helper.rb'
require 'vcr'

RSpec.describe 'sample' do
  it 'sample' do
    VCR.use_cassette("get_google") do
      response = Net::HTTP.get_response(URI('https://github.com/')) #githubに書き換え
    end
  end
end

これをこのまま実行すると、上記のunhandledのエラーが出てしまいます。

そこでgithubへのリクエストは無視するという設定をspec_helper.rbに追加します

# spec_helper.rb
VCR.configure do |config|
  config.cassette_library_dir = "fixtures/vcr_cassettes"
  config.hook_into :webmock # or :fakeweb
  config.debug_logger = File.open("log","w")
  config.ignore_request do |request| #config.ignoreを追記
    request.uri == 'https://github.com/' #uriがgithubの場合は無視する
  end
end

これで再度テストを実行するとテストが通って、うまくgithubが無視されているのが分かります。

4つ目

The current record mode (:once) does not allow new requests to be recorded
to a previously recorded cassette. You can delete the cassette file and re-run
your tests to allow the cassette to be recorded with this request [4].

「今のrecord mode(once)だと新しいリクエストを記録できないっす。ファイル消してもう一回実行したらできます。」って言ってます。

ファイルを消してもう一回実行したらできるのは当たり前として、record modeが気になるところですね

こちらに詳しく書いていますが、ざっくりまとめると

  • :once

    • 過去のものを再生できる

    • 同名カセットファイルの記録は1度のみ

    • 同名のカセットファイルがある場合はエラーを吐く(←今回出ているエラーはこれですね)

  • :new_episodes

    • 過去のものを再生できる

    • 同名のカセットファイルがあれば追記する

  • :none

    • 過去のものを再生できる

    • 新たな記録は行わない

  • :all

    • 過去に記録されててもとにかく全部記録する

のような感じになっています。

1つ注意点としてallにすると過去のものを記録して再生できるというvcrの利点が生かされずに全てカセットに記録するということになってしまいます。

なので先ほどやったnew_episodesというオプションはこのrecord_modeを設定していたのです。

最後

The cassette contains 101 HTTP interactions that have not been
played back. If your request is non-deterministic, you may need to
change your :match_requests_on cassette option to be more lenient
or use a custom request matcher to allow it to match [5].

「カセットに101(Switching Protocol)が含まれてると再生できないよー。match_requests_onの条件もっとゆるくするかカスタムのrequest matcher定義してね。」って言ってます

要は記録したリクエストに101が含まれていると、同じリクエストを再生しても違うリクエストと判断されるからうまくリクエストmatcherを設定してくれってことだと思います。

軽く調べてみましたが、match_requests_onの条件をゆるくする方法についてはちょっと良く分かりませんでした。

また、request matchを自前で定義する方法については

describe "#fetch_info", vcr: {match_requests_on: [:method, VCR.request_matchers.uri_without_param(:timestamp)]} do
  # この場合timestampはmacherの条件がから除外される
end

こんな感じで書くといいみたいです。

最後だけちょっと曖昧になってしまいましたが、こんなところで終わりにしたいと思います。

参考:

morizyun.github.io

railsware.com

追記 VCRで記録したcasetteが間違ったレスポンスを返していることがありハマりました。もしリクエストのログとブラウザで実際アクセスしたリクエスト内容が合ってるにも関わらずレスポンスがおかしい時はcassetteの記録がおかしいことを疑ってみてもいいかもしれません。