STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

WebMockでヘッダーにRSpecマッチャーは効かない

こんにちは。Webエンジニアをしているotariidaeです。

「ひとつのSTORES」としてプロダクトの統合を進めてきた結果、システム間通信の実装が増えています。多くのシステムをRuby on Railsで構築している STORES では、こうした機能をテストする際、外部リクエストの扱いとしてWebMockやRSpecのテストダブルが主な選択肢となります。STORES では可能な限り実際の挙動に近い形で検証したいというスタンスから、原則としてWebMockを採用しています。

STORES はWebMock多用企業です。

RSpecのマッチャーが効く

WebMockの .with ではRSpecのマッチャーが使えます。RSpecに慣れている人にとって、この暗黙の互換性は非常に便利です。

stub_request(:post, "www.example.com")
  .with(body: {
    id: anything, # RSpec::Mocks::ArgumentMatchersRSpec::Matchers由来
    tags: a_collection_containing_exactly("in_progress", "test") # RSpec::Matchers由来
  })

さて、例えば特定の形式のヘッダーをもつリクエストをスタブしたくなったとき、つい次のように書きたくなります。

stub_request(:post, "www.example.com")
  .with(headers: {
    Authorization: a_string_starting_with("Bearer ") # RSpec::Matchers由来
  })

しかし、これを実際に動かすと期待通りにマッチせずWebMock::NetConnectNotAllowedErrorで失敗してしまいます。

原因

RSpecマッチャーとの互換性はbodyに対してのみ提供され、headersには効きません。

WebMockの内部実装を見てみると、bodyとheadersでは対応するクラスが異なります。

@body_pattern = BodyPattern.new(options['body']) if options.has_key?('body')
@headers_pattern = HeadersPattern.new(options['headers']) if options.has_key?('headers')

出典:webmock/lib/webmock/request_pattern.rb

bodyにはWebMock::BodyPatternが、headersにはWebMock::HeadersPatternがそれぞれ対応しています。

WebMock::HeadersPatternの内部のノーマライズ処理において、渡された値は強制的に to_s で文字列へと変換される仕様になっています。

def self.normalize_headers(headers)
  return nil unless headers

  headers.each_with_object({}) do |(name, value), new_headers|
    new_headers[normalize_name(name)] =
      case value
      when Regexp then value
      when Array then (value.size == 1) ? value.first.to_s : value.map(&:to_s).sort
      else value.to_s
      end
  end
end

出典:webmock/lib/webmock/util/headers.rb

試してみると、たしかにマッチャーが文字列になっていることがわかります。

WebMock::HeadersPattern.new({
  Authorization: a_string_starting_with("Bearer ")
})
# => #<WebMock::HeadersPattern:0x0000000122f3c910
#     @pattern={"Authorization" => "#<RSpec::Matchers::BuiltIn::StartWith:0x0000000122fa9f10>"}>

だからスタブが一致せずにWebMock::NetConnectNotAllowedErrorになっていたというわけです。

この挙動はスタブに限らず have_requested().with などのアサーションでも同様です。

代替策

代わりに正規表現かブロックが使えます。

stub_request(:post, "www.example.com")
  .with(headers: {
    Authorization: /\ABearer .*/
  })
stub_request(:post, "www.example.com")
  .with { |req| req.headers["Authorization"].start_with?("Bearer ") }

おわりに

趣味で stub_request の整理をしていたときに気になったWebMockの挙動についてご紹介しました。

みなさんのWebMock盆栽業の参考になれば幸いです。