With the help of Robbie Clutton’s SimpleBDD gem, my pair and I had structured our client’s tests into Cucumber-style Given/When/Then tests that our client product manager could read and understand. However, we felt that our improved high-level structure came at the expense of repetitive low-level methods. For instance, to allow both Given "Mary is signed in"
and Given "Joe is signed in"
, we had to define both mary_is_signed_in
and joe_is_signed_in
. In any non-test situation, such egregious duplication would be textbook refactor material. In a test, however, being clear and obviously correct is valuable as tests don’t have tests. In addition, we couldn’t easily refactor to something like Given "User is signed in (Mary)"
without sacrificing readability by our PM.
My pair and I explored several ways to refactor tests that looked like the one below without changing the high-level steps. (All the code examples are up and working on GitHub.)
require 'spec_helper'
describe "Collaborator sharing documents" do
context "Joe and Mary have no documents" do
let!(:mary) { "Mary" } # Would really be FactoryGirl
let!(:joe) { "Joe" }
it "Recipient receives an email and sees doc in homepage list" do
Given "Mary is signed in"
And "Mary creates a document"
When "Mary shares the document with Joe"
Then "Joe receives an email"
When "Joe is signed in"
Then "Joe has one document"
end
end
end
First, we tried to use more generic names for our test variables. For instance, instead of “Mary” in the test above, we would have “User.” Then we wouldn’t have repeated sign in methods. We ran into problems with tests that had multiple users or documents.
In addition, with a complicated domain we found it helpful to have our users and test objects tell the same story throughout our tests so that we could spot mistakes intuitively. For instance, if Mary and Joe were friends in one test and weren’t in another, this would instinctively raise a red flag in our minds. Giving our users more generic names broke our ability to intuitively spot logic errors.
Second, we tried to use normal method calls instead of SimpleBDD. Because SimpleBDD is really just calling methods, you can intersperse actual method calls within your SimpleBDD calls like this:
sign_in(mary)
shares_the_document(mary, joe)
Then “Joe receives an email”
We found that only a few of our SimpleBDD calls remained as most of our methods were generic enough that they were transformed to normal method calls. Thus, we lost the Given/When/Then syntax that we had used SimpleBDD to obtain.
None of these solutions really struck our fancy, so we turned to metaprogramming and using method_missing to define some of our test cases. Because SimpleBDD relies on respond_to?
, first we made a simple module to make sure that our method_missing
and respond_to_missing?
methods were in sync. If you’re not sure what respond_to_missing?
is, thoughtbot has a good explanation. It’s a much better way of overriding the behavior of respond_to?
that I only learned of recently.
module MethodMissingHelper
def method_missing(name, *args, &passed_blk)
match = find_matching_proc(name.to_s)
if match
regex, method_blk = match
method_blk[name.to_s.match(regex)]
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
find_matching_proc(method_name.to_s) || super
end
private
def find_matching_proc method_name
method_missing_mapping.detect { |regex, _| method_name =~ regex }
end
def method_missing_mapping
raise NotImplementedError, "Override me with a hash of Regex => Proc."
end
end
Then we used this module in our specs:
include MethodMissingHelper
def method_missing_mapping
{
/^([a-z]+)_is_signed_in$/
=> proc { |match| login_as public_send(match[1]) },
/^(?:mary|joe)(.*)$/ => proc { |match| public_send("user" + match[1]) }
}
end
Originally, we thought that we would need to pass through the matched objects. For instance, if our method missing received mary_has_no_class
, it would have to call user_has_no_class
and pass Mary
as a parameter. However, because we were working with feature specs, we found that this was not generally necessary. We did need it for specs that tested email sending because capybara-email needs to be passed an email address.
Neither my pair nor are I are particular fans of method_missing, especially not matching method names against a giant stack of unreadable regular expressions – that seems to be the number one complaint of Cucumber users. We’ve maintained discipline in adding to our method_missing_mapping and only have 4 or 5 regexes in there which still DRYed up our tests significantly. In addition, I’ve been working on a fork of SimpleBDD to roll some of this functionality into the gem without using method_missing. Ruby’s metaprogramming doesn’t always stack well but it provides a great way to prototype functionality and test out ideas — we’re definitely keeping this one!