カスタムマッチャー書いてますか?
以前こちらの記事で、shared_examples
ではなくカスタムマッチャーで書いた方が読みやすいRSpecになると思うと書きました。
では、カスタムマッチャーはどう書いたらいいでしょうか?
カスタムマッチャーの書き方
上の記事で例題として上げていた、以下のように書けるようにするbe_api_response_of
マッチャーを考えてみましょう。
it { expect(response).to be_api_response_of(401, "Unauthorized") }
context "when logged in" do
include_context "logged in"
context "when page is 1" do
let(:page) { 1 }
it { expect(response).to be_api_response_of(200, "Succeed").with_data(...) }
end
context "when page is 'a'" do
let(:page) { "a" }
it { expect(response).to be_api_response_of(400, "Bad Request").with_error("page must be an integer") }
end
end
DSLで書く
よく例として書かれているグローバルにDSLで定義する場合は、例えば以下のような感じでしょうか。
# spec/suport/matchers/be_api_response_of.rb のようなところに置いて rails_helper.rb で require されるようにする
# 以下のコメントアウトを外せばいいと思います
# Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }
RSpec::Matchers.define :be_api_response_of do |expected_status, expected_message|
chain :with_data do |expected_data|
@expected_data = expected_data
end
chain :with_error do |expected_error|
@expected_errors ||= [] << expected_error
end
match do |actual_response|
if actual_response.status != 200
@failure_message = "HTTP status: expected=200, actual=#{actual_response.status}"
return false
end
unless actual_response.content_type.starts_with?("application/json")
@failure_message = "Content Type: expected=application/json, actual=#{actual_response.content_type}"
return false
end
actual_body = JSON.parse(actual_response.body) rescue {}
if actual_body["status"] != expected_status
@failure_message = "status: expected=#{expected_status}, actual=#{actual_body["status"]}"
return false
end
if actual_body["message"] != expected_message
@failure_message = "message: expected=#{expected_message}, actual=#{actual_body["message"]}"
return false
end
if @expected_data && actual_body["data"] != @expected_data
@failure_message = "data: expected=#{@expected_data}, actual=#{actual_body["data"]}"
return false
end
if @expected_errors && actual_body["errors"].sort != @expected_errors.sort
@failure_message = "errors: expected=#{@expected_errors}, actual=#{actual_body["errors"]}"
return false
end
true
end
failure_message do
@failure_message
end
end
scoped DSLで書く
Example Group内でmatcher
DSLで定義すると、そのブロック内にスコープを絞ることができます。
RSpec.describe "users", type: :request do
matcher :be_api_response_of do |expected_status, expected_message|
# DSL版と内容は同じ
end
# test case...
end
matcher classで書く
以下のように matcher class で定義する事もできます。
# rails_helper.rbでincludeする
RSpec.configure do |config|
config.include MyApp::Rspec::Matchers
end
# spec/suport/matchers/be_api_response_of.rb のようなところに置いて rails_helper.rb で require されるようにする
module MyApp
module Rspec
module Matchers
def be_api_response_of(expected_status, expected_message)
BeApiResponseOf.new(expected_status, expected_message)
end
class BeApiResponseOf < RSpec::Matchers::BuiltIn::BaseMatcher
def initialize(expected_status, expected_message)
super()
@expected_status = expected_status
@expected_message = expected_message
end
def with_data(expected_data)
@expected_data = expected_data
self
end
def with_error(expected_error)
@expected_errors ||= [] << expected_error
self
end
def matches?(actual_response)
# DSL版のmatchとほどんど同じ
# expected_statusとexpected_messageが、@expected_statusと@expected_messageに変わるだけ
end
def failure_message
@failure_message
end
end
end
end
end
それぞれの特徴
- DSL
- グローバルに定義される
- コンテキストに依存したマッチャーが書けてしまう
- コードの再利用がしづらい
- 引数の数の違いに気づきづらい
- scoped DSL
- グローバルに影響しないので、雑に書きやすい
- 他はDSL版と同じ
- matcher class
- includeをどこでするかによって、グローバルに使えるようにもできるし、スコープを絞ることもできる
- 少し書くのが面倒
まとめ
理想は matcher class で定義するのがいいと思うのですが、いきなりそうしなくても他への影響がない scoped DSL でカジュアルに書いてしまって、そこから必要であればグローバルの DSL、matcher class にしていくのも手かなと考えています。
特にshared_examples
を使ってしまっていて読みづらいRSpecをリファクタリングする際に、scoped DSLにしていくところから始めるとやりやすいように思います。