[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