[rspec-users] Ordering in view specs using have_tag and with_tag
Wincent Colaiuta
win at wincent.com
Thu May 24 09:25:26 EDT 2007
Ok, following up on my posts from a couple of days ago...
The question:
> When writing view specs is there any way to test not only for the
> presence of tags (have_tag) and nested tags (with_tag), but also test
> that they appear in a given order?
The workaround:
> One thing which may prove useful is that
> assert_select returns an array of matches, and also passes that array
> into any block (if called with a block). The array contains
> HTML::Node instances, and these nodes have a position attribute (the
> position of the node within the byte stream); so making comparisons
> of order externally is possible and could be down already right now
> with no changes (although not transparently; you'd have to explicitly
> compare element positions in your specs).
Here's an example spec showing one way this could be done. It's
pretty ugly though (and I expect the list server will make it even
uglier by hard-wrapping it) as it requires quite a bit of "book-
keeping" to be done:
describe '/users/index with 2 users' do
before do
users = (1..2).inject([]) do |list, index|
user = mock("user #{index}")
user.should_receive(:login_name).and_return("example login
name #{index}")
user.should_receive(:display_name).and_return("example display
name #{index}")
user.should_receive(:email_address).and_return("example email
address #{index}")
list << user
end
assigns[:users] = users
render 'users/index'
end
it 'should display the login names, displays names and email
address, in alternating odd and even rows' do
odd = even = nil
response.should have_tag('div.odd>div') do |match|
odd = match[0]
positions = []
with_tag('div.label', 'Login name:') { |match|
positions << match[0].position }
with_tag('div.info', 'example login name 1') { |match|
positions << match[0].position }
with_tag('div.label', 'Display name:') { |match|
positions << match[0].position }
with_tag('div.info', 'example display name 1') { |match|
positions << match[0].position }
with_tag('div.label', 'Email address:') { |match|
positions << match[0].position }
with_tag('div.info', 'example email address 1') { |match|
positions << match[0].position }
positions.should == positions.sort
end
response.should have_tag('div.even>div') do |match|
even = match[0]
positions = []
with_tag('div.label', 'Login name:') { |match|
positions << match[0].position }
with_tag('div.info', 'example login name 2') { |match|
positions << match[0].position }
with_tag('div.label', 'Display name:') { |match|
positions << match[0].position }
with_tag('div.info', 'example display name 2') { |match|
positions << match[0].position }
with_tag('div.label', 'Email address:') { |match|
positions << match[0].position }
with_tag('div.info', 'example email address 2') { |match|
positions << match[0].position }
positions.should == positions.sort
end
odd.position.should < even.position
end
end
Compare this to the version without the book-keeping:
describe '/users/index with 2 users' do
before do
users = (1..2).inject([]) do |list, index|
user = mock("user #{index}")
user.should_receive(:login_name).and_return("example login
name #{index}")
user.should_receive(:display_name).and_return("example display
name #{index}")
user.should_receive(:email_address).and_return("example email
address #{index}")
list << user
end
assigns[:users] = users
render 'users/index'
end
it 'should display the login names, displays names and email
address, in alternating odd and even rows' do
response.should have_tag('div.odd>div') do
with_tag('div.label', 'Login name:')
with_tag('div.info', 'example login name 1')
with_tag('div.label', 'Display name:')
with_tag('div.info', 'example display name 1')
with_tag('div.label', 'Email address:')
with_tag('div.info', 'example email address 1')
end
response.should have_tag('div.even>div') do
with_tag('div.label', 'Login name:')
with_tag('div.info', 'example login name 2')
with_tag('div.label', 'Display name:')
with_tag('div.info', 'example display name 2')
with_tag('div.label', 'Email address:')
with_tag('div.info', 'example email address 2')
end
end
end
So, yes, the book-keeping-free version is much easier to look at, but
it's also far too easy for the spec to pass with faulty input (jumble
the divs around in any order you want and it will still pass). So
improving have_tag, with_tag, or the underlying assert_select seems
to be a very worthwhile project; I've got a very basic prototype
implementation in place based on this small patch to actionpack/lib/
action_controller/assertions/selector_assertions.rb:
=== vendor/rails/actionpack/lib/action_controller/assertions/
selector_assertions.rb
==================================================================
--- vendor/rails/actionpack/lib/action_controller/assertions/
selector_assertions.rb (revision 7191)
+++ vendor/rails/actionpack/lib/action_controller/assertions/
selector_assertions.rb (local)
@@ -286,6 +286,13 @@
"Expected at most #{equals[:maximum]} elements, found #
{matches.size}."
end
+ if !matches.empty?
+ if @previous_match and matches[0].position <
@previous_match.position
+ raise "selector order mismatch ('#{matches[0].to_s}' at
position #{matches[0].position} appears before '#
{@previous_match.to_s}' at position #{@previous_match.position})"
+ end
+ @previous_match = matches[0]
+ end
+
# If a block is given call that block. Set @selected to allow
# nested assert_select, which can be nested several levels
deep.
if block_given? && !matches.empty?
Experimentation shows that the dynamic class created by RSpec for
each "it" block receives the assert_select messages and hangs around
for the duration of the block. So that means instance variables can
be used to store positional information and an exception can be
thrown if things don't happen in the expected order.
This works, at least for the sample spec I've posted above. In the
event of the ordering requirement failing the exception will include
diagnostic information that looks like this:
selector order mismatch ('<div class="label">Display name:</div>' at
position 109 appears before '<div class="info">example login name 1</
div>' at position 150)
Limitations, and doubts:
- I don't know what type of exception to throw, so for now I'm just
throwing a String (which becomes a RuntimeError)
- no way to make it optional at this stage
- this patch (or anything like it) is unlikely to get accepted in
Rails because it will probably break lots of existing specs/tests
which are written without caring about selector ordering
Any further ideas?
Cheers,
Wincent
More information about the rspec-users
mailing list