Chain RSpec Matchers for Improved Test Readability

Josh Clayton

I’ve recently journeyed back into the world of Rails development after an extended stay in more functional languages. One aspect of Ruby that always delights me is its focus on readability. With this focus, along with the ability to fold certain structures, I set out to improve a wordy test.

The original test looked like this:

select_multiple_from "Which of these apply to you?", [
  "I read the New York Times every day",
  "I read the Washington Post every day",
]

click_on "Submit"

expect(page).to have_css("dd ul li", count: 2)
expect(page).to have_css("dd ul li", text: "I read the New York Times every day")
expect(page).to have_css("dd ul li", text: "I read the Washington Post every day")

The select_multiple_from handles the multi-select:

def select_multiple_from(from, options)
  options.each do |option|
    select option, from: from
  end
end

The three assertions felt a bit redundant, but necessary: I wanted to ensure form submission captured only the items chosen (by verifying length of items in the list) and that they showed up correctly.

When I write any Ruby, most especially when I’m writing acceptance tests, I favor focusing on readability and working at a single level of abstraction; I came up with:

expect(page).to have_multiple_choice_responses(
  "I read the New York Times every day",
  "I read the Washington Post every day"
)

This encodes the notion that these two items, and only these two items, should show up in the list. While there are multiple assertions we’re covering here, it’s possible to use a feature in many of RSpec’s matchers to chain assertions: and. It was introduced in the RSpec::Matchers::Composable module in RSpec 3 that many of the Capybara matchers, including have_css, have available.

As a first pass, we could write this matcher as such:

def have_multiple_choice_responses(item1, item2)
  have_css("dd ul li", count: 2).
    and(have_css("dd ul li", text: item1)).
    and(have_css("dd ul li", text: item2))
end

There are a few problems here, the most notable that it’s tightly coupled with the number of items. Let’s make this more flexible with Ruby’s Enumerable#inject:

def have_multiple_choice_responses(*args)
  count_matcher = have_css("dd ul li", count: args.length)

  args.inject(count_matcher) do |matcher, text|
    matcher.and(have_css("dd ul li", text: text))
  end
end

Enumerable#inject is called on our array of items we’re expecting, with a starting matcher (count_matcher), and for each iteration, extending that matcher with and to layer in an additional assertion. This allows the matcher to grow and have explicit expectations around the number of items present, as well as their content. This doesn’t assert order, but would be possible using each.with_index(1):

def have_multiple_choice_responses(*args)
  count_matcher = have_css("dd ul li", count: args.length)

  args.each.with_index(1).inject(count_matcher) do |matcher, (text, offset)|
    matcher.and(have_css("dd ul li:nth-of-type(#{offset})", text: text))
  end
end

(Note that the nth-of-type pseudo-selector starts at an index of 1 instead of 0)

With this refactoring, we’ve achieved what I’d consider a readable matcher which hits all the assertions necessary, and hides some of the specific CSS selectors behind a well-named method. Success!