RSpec composable matchers

While working on the Stride integration for GitLab, I found myself with the need of matching some complex and repetitive JSONs like the one from below.

{
  "type": "paragraph",
  "content": [
    {
      "type": "text",
      "text": "John Doe16 pushed new tag "
    },
    {
      "type": "text",
      "text": "test",
      "marks": [
        {
          "type": "link",
          "attrs": {
            "href": "http://localhost/namespace8/project8/commits/test"
          }
        }
      ]
    },
    {
      "type": "text",
      "text": " to "
    },
    {
      "type": "text",
      "text": "JohnDoe15/project8",
      "marks": [
        {
          "type": "link",
          "attrs": {
            "href": "http://localhost/namespace8/project8"
          }
        }
      ]
    }
  ]
}

Writing a matcher for this JSON got pretty hairy and not very readable, but RSpec has composable matchers that can be combined to express the exact details of the expectations. Let’s see how a spec will look with these matchers.

context 'description' do
  subject { push_message.call.dig('content', 0, 'content', 0) }

  it 'generates a remove message' do
    ref = Gitlab::Git.ref_name(sample_data[:ref])

    is_expected.to be_a_stride_paragraph_with(
      a_stride_text("#{user.name} removed branch "),
      a_stride_text(ref),
      a_stride_text(" from "),
      a_stride_link(stride_service.project_name, stride_service.project_url)
    )
  end
end

And all we need now is to define those matchers. Only one small problem with this strategy: there isn’t any obivious documentation on how to do it. After browsing the code, we find our way in some comments from the matchers file.

Include {RSpec::Matchers::Composable} in your custom matcher to make it support

being composed (matchers defined using the DSL have this included automatically).

So it’s just a regular matcher that includes RSpec::Matchers::Composable. And here they are.

RSpec::Matchers.define :be_a_stride_paragraph_with do |*nodes|
  include RSpec::Matchers::Composable

  match do |actual|
    expect(actual).to match(
    	hash_including(
    		'type' => 'paragraph',
    		'content' => a_collection_including(*nodes)
    	)
    )
  end
end

RSpec::Matchers.define :a_stride_text do |text, others = {}|
  include RSpec::Matchers::Composable

  data = { 'type' => 'text', 'text' => text }.merge(others)
  match do |actual|
    expect(actual).to match(hash_including(data))
  end
end

RSpec::Matchers.define :a_stride_link do |name, url|
  include RSpec::Matchers::Composable

  match do |actual|
    expect(actual).to match(
    	a_stride_text(name, 'marks' => a_stride_link_mark(url))
    )
  end
end

RSpec::Matchers.define :a_stride_link_mark do |url|
  include RSpec::Matchers::Composable

  match do |actual|
    expect(actual).to match(
    	a_collection_containing_exactly(
	      hash_including('type' => 'link',
	                     'attrs' => hash_including('href' => url)
	                    )
    ))
  end
end

Categories: ,

Updated: