2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Ruby開発Advent Calendar 2022

Day 8

カスタムマッチャーをカジュアルに書こう

Posted at

カスタムマッチャー書いてますか?

以前こちらの記事で、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内でmatcherDSLで定義すると、そのブロック内にスコープを絞ることができます。

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にしていくところから始めるとやりやすいように思います。


もっとカジュアルにカスタムマッチャーを書いていくのはどうでしょう?

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?