At LaunchWare, we've been looking for the best way to locate an element on a page in an Rspec request spec. Sometimes there's just no getting around asserting that one piece of content appears before another, like when testing sort options. We've tried a few things:
The exposition
Imagine there are two comments on a post. We want to assert that the most recent comment appears first.
We have the following markup:
<div>
<div class="comment">
<p>Here is a newer comment!</p>
</div>
<div class="comment">
<p>Here is a comment!</p>
</div>
</div>
The most obvious way
The most obvious way to assert that the newer commment appears first is:
let!(:old_comment) { Factory(:comment) }
let!(:new_comment) { Factory(:comment) }
within "div.comment:nth-child(1)" do
page.should have_content new_comment.text
end
within "div.comment:nth-child(2)" do
page.should have_content old_comment.text
end
Or some variant of this. page.find("div.comment:nth-child(1)")
works too.
But this approach is brittle. What if we go all HTML5 on this template and change the comment divs to article tags like so:
<article>
<article class="comment">
<p>Here is a newer comment!</p>
</article>
<article class="comment">
<p>Here is a comment!</p>
</article>
</article>
Then our test is broken.
Or worse, if we add another comment into the mix as part of another test:
let!(:old_comment) { Factory(:comment) }
let!(:new_comment) { Factory(:comment) }
let!(:some_other_comment) { Factory(:comment) }
Our markup becomes:
<article>
<article class="comment">
<p>Here is a comment!</p>
</article>
<article class="comment">
<p>Here is a newer comment!</p>
</article>
<article class="comment">
<p>Here is a comment!</p>
</article>
</article>
Not nth-child 1 and 2 anymore...
But hey, we don't really care which children they are, or what tags they are. We just want to know if the text of the new comment is on the page and placed somewhere before the old comment! Let's test that. So, here you have it:
The less brittle (and therefore better) way
We know thse things:
- With capybara, you can grab the contents of the whole page as a string by calling
page.body
. - And with a Ruby string, you can grab the position (index) of a certain substring by calling
index(substring)
. - And with Rspec you can assert that one index/position is less than/before another using
index1.should_be < index2
.
So put that all together, and we replace the brittle approach above with this:
let!(:old_comment) { Factory(:comment) }
let!(:new_comment) { Factory(:comment) }
page.body.index(new_comment.text).should < page.body.index(old_comment.text)
Awesome!
But there's one more thing:
The one more thing (best) way
It would be annoying to type that over and over. What if you or the guys at LaunchWare figure out a better way to do this in the future? You wouldn't want to have to find-and-replace your whole code base. And we wouldn't either.
Try this Rspec custom matcher definition on for show:
Rspec::Matchers.define :appear_before do |later_content|
match do |earlier_content|
page.body.index(earlier_content) < page.body.index(later_content)
end
end
Once this is in play, you can now make this magical assertion:
let!(:old_comment) { Factory(:comment) }
let!(:new_comment) { Factory(:comment) }
new_comment.text.should appear_before(old_comment.text)
Definitely try this at home:
How to add this to your project
I've released this as a gem! so all you have to do is add
gem 'orderly'
to your gemfile. Then you can assert
this.appears_before(that)
And you're in business. #win
Follow me on Twitter at jmondo for <= 140 char gems