If forced to pick only one technique for refactoring code, I’d go with Extract Method. And if I were made to choose similarly for refactoring specs, it’d be Extract Method’s descendant Extract Matcher.
Motivations
You’ve got one or more expectations verifying state, but its unclear from the level of abstraction what the intention of these expectations is. By extracting well-named matchers, you can clarify the intention and so make the spec easier to understand.
Another motivation for Extract Matcher is to reduce duplication of repeated expectations or groups of expectations.
Example
A feature spec contains an expectation for checking the flash message after a user logs in:
expect(page).to have_css ".notice", text: "You're logged in"
For a developer familiar with Capybara, RSpec, and CSS, this expectation is fine, its reasonably clear what its testing. It reveals the intention to "assert the user successfully logged in". If there’s any issue with it, it is that plainer language could demonstrate the intention without the noise of a CSS selector.
Here’s the first attempt at muting the noise by extracting a have_flash
matcher.
expect(page).to have_flash :notice, text: "You're logged in"
The have_flash
matcher can be written in the same spec below any examples using the RSpec DSL for custom matchers.
feature "..." do
scenario "..." do
...
expect(page).to have_flash :notice, text: "You're logged in"
...
end
matcher :have_flash do |level, options|
match_unless_raises do |page|
expect(page).to have_css ".#{level}", options
end
end
end
have_flash
is one level of abstraction higher than have_css
as its language is one step closer to the language a user might use when thinking about how to tell if they are logged-in, and one step farther away from the CSS language the developer and web browser use to produce the page.
Can we get any closer to the language a user viewing our web page would use to make the test clearer?
Here’s a couple alternatives for how the level of abstraction could be brought closer again to the user, and further away from the technical implementation of the page:
# Option 1:
expect(page).to have_notice "You are logged in!"
# Option 2:
expect(page).to have_logged_in_notice
In this case is the readability benefit of moving to higher and higher levels of abstraction worth the extra effort? The returns on readability are diminishing but still there. This is one for you to decide based on your own style and experience in the moment (or just toss a coin, there’s not much to choose between them).
I want to show you one more example of extracting a matcher to demonstrate how you can use it to group multiple expectations into a single, more intention-revealing expectation:
expect(page).to have_title "Login"
expect(page).to have_current_path "/login"
expect(page).to have_css "h1", "Enter details to login"
The one intention of the above 3 expectations is to assert that the user is on the login page. Lets extract this intention into a be_login_page
matcher:
feature "..." do
scenario "..." do
...
expect(page).to be_login_page
...
end
matcher :be_login_page
match_unless_raises do |page|
expect(page).to have_title "Login"
expect(page).to have_current_path "/login"
expect(page).to have_css "h1", "Enter details to login"
end
end
end
Reusing Matchers
In the above examples we’ve extracted matchers in to the same spec that they are used in.
What if we wanted to reuse those matchers throughout our specs?
To make a matcher reusable, move it to its own file in the spec/support/matchers/
directory and ensure its require
-d by spec/rails_helper.rb
(or spec/spec_helper.rb
) and config.include
it.
To make the be_login_page
matcher available to all feature specs:
# File: spec/support/matchers/be_login_page.rb
module BeLoginPage
extend RSpec::Matchers::DSL
matcher :be_login_page do
match_unless_raises do |page|
expect(page).to have_title "Login"
expect(page).to have_current_path "/login"
expect(page).to have_css "h1", "Enter details to login"
end
end
end
RSpec.configure do |config|
# Makes be_login_page matcher available to only feature specs.
# To make it available to all specs, drop the `type` option.
config.include BeLoginPage, type: :feature
end