Skip to content

Instantly share code, notes, and snippets.

@Phlip
Created March 9, 2009 06:34
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Phlip/76136 to your computer and use it in GitHub Desktop.
Save Phlip/76136 to your computer and use it in GitHub Desktop.
An RSpec HTML matcher that nests contexts
=begin
Note that assert{ 2.0 }'s assert_xhtml (and .be_html_with) now supersede this Gist! Get them with
gem install nokogiri assert2
require 'assert2/xhtml'
And see: http://groups.google.com/group/merb/browse_thread/thread/3588d3f75fa0e65c
----8<---------------------------
One Yury Kotlyarov recently posted this Rails project as a question:
http://github.com/yura/howto-rspec-custom-matchers/tree/master
It asks: How to write an RSpec matcher that specifies an HTML
<form> contains certain fields, and enforces their properties
and nested structure? He proposed this:
render '/users/new'
response.should have_form('/users') do
with_field_set 'Personal Information' do
with_text_field 'First name', 'user', 'first_name'
end
end
The form in question is a familiar user login page:
<form action="/users">
<fieldset>
<legend>Personal Information</legend>
<ol>
<li id="control_user_first_name">
<label for="user_first_name">First name</label>
<input type="text" name="user[first_name]" id="user_first_name" />
</li>
</ol>
</fieldset>
</form>
If that form were full of <%= eRB %> tags, testing it would be
mission-critical. (Adding such eRB tags is left as an exercise for
the reader!)
This post creates a custom matcher that satisfies the following
requirements:
- the specification <em>looks like</em> the target code
* (except that it's in Ruby;)
- the specification can declare any HTML element type
_without_ cluttering our namespaces
- our matcher can match attributes exactly
- our matcher strips leading and trailing blanks from text
- the specification only requires the attributes and structural
elements that its matcher demands; we skip the rest -
such as the <ol> and <li> fields. They can change
freely as our website upgrades
- at fault time, the matcher prints out the failing elements
and their immediate context.
First, we take care of the paperwork. This spec works with Yuri's
sample website. I add Nokogiri, for our XML engine:
=end
require File.dirname(__FILE__) + "/../../spec_helper"
require 'spec/matchers/wrap_expectation'
require 'nokogiri'
describe "/users/new" do
it 'should have a form with a fieldset' do
render '/users/new'
response.body.should be_html_with{
form :action => '/users' do
fieldset do
legend 'Personal Information'
label 'First name'
input :type => 'text', :name => 'user[first_name]'
end
end
}
end
=begin
That block after "response.body.should be_html_with" answers
Yuri's question. Any HTML we can think of, we can specify
it in there.
If we inject a fault, such as :name => 'user[first_nome]', we
get this diagnostic:
<input type="text" name="user[first_nome]">
does not match
<fieldset>
<legend>Personal Information</legend>
<ol>
<li id="control_user_first_name">
<label for="user_first_name">First name</label>
<input type="text" name="user[first_name]" id="user_first_name">
</li>
</ol>
</fieldset>
The diagnostic only reported the fault's immediate
context - the <fieldset> where the matcher sought the
errant <input> field. It would not, for example, spew
an entire website into our faces.
To support that specification, we will create a new
RSpec "matcher":
=end
class BeHtmlWith
def matches?(stwing, &block)
@scope.wrap_expectation self do
begin
bwock = block || @block || proc{}
builder = Nokogiri::HTML::Builder.new(&bwock)
match = builder.doc.root
doc = Nokogiri::HTML(stwing)
@last_match = 0
@failure_message = match_nodes(match, doc)
return @failure_message.nil?
end
end
end
=begin
The trick up our sleeve is Nokogiri::HTML::Builder. We passed
the matching block into it - that's where all the 'form',
'fieldset', 'input', etc. elements came from. And this trick
exposes both our target page and our matched elements to the
full power of Nokogiri. Schema validation, for example, would
be very easy.
The matches? method works by building two DOMs, and forcing
our page's DOM to satisfy each element, attribute, and text
in our specification's DOM.
To match nodes, we first find all nodes, by name, below
the current node. Note that match_nodes() recurses. Then
we throw away all nodes that don't satisfy our matching
criteria.
We pick the first node that passes that check, and
then recursively match its children to each child,
if any, from our matching node.
=end
def match_nodes(match, doc)
node = doc.xpath("descendant::#{match.name}").
select{|n| resemble(match, n) }.
first or return complaint(match, doc)
this_match = node.xpath('preceding::*').length
if @last_match > this_match
return complaint(match, doc, 'node is out of specified order!')
end
@last_match = this_match
match.children.grep(Nokogiri::XML::Element).each do |child|
issue = match_nodes(child, node) and
return issue
end
return nil
end
=begin
At any point in that recursion, if we can't find a match,
we build a string describing that situation, and pass it
back up the call stack. This immediately stops any iterating
and recursing underway!
Two nodes "resemble" each other if their names are the
same (naturally!); if your matching element's
attributes are a subset of your page's element's
attributes, and if their text is similar:
=end
def resemble(match, node)
valuate(node.attributes.pass(*match.attributes.keys)) ==
valuate(match.attributes) or return false
match_text = match.children.grep(Nokogiri::XML::Text).map{|t| t.to_s.strip }
node_text = node .children.grep(Nokogiri::XML::Text).map{|t| t.to_s.strip }
match_text.empty? or 0 == ( match_text - node_text ).length
end
=begin
That method cannot simply compare node.text, because Nokogiri
conglomerates all that node's descendants' texts together, and
these would gum up our search. So those elaborate lines with
grep() and map() serve to extract all the current node's
immediate textual children, then compare them as sets.
Put another way, <form> does not appear to contain "First name".
Specifications can only match text by declaring their immediate
parent.
The remaining support methods are self-explanatory. They
prepare Node attributes for comparison, build our diagnostics,
and plug our matcher object into RSpec:
=end
def valuate(attributes)
attributes.inject({}) do |h,(k,v)|
h.merge(k => v.value)
end # this converts objects to strings, so our Hashes
end # can compare for equality
def complaint(node, match, berate = nil)
"\n #{berate}".rstrip +
"\n\n#{node.to_html}\n" +
" does not match\n\n" +
match.to_html
end
attr_accessor :failure_message
def negative_failure_message
"yack yack yack"
end
def initialize(scope, &block)
@scope, @block = scope, block
end
end
def be_html_with(&block)
BeHtmlWith.new(self, &block)
end
end
class Hash
def pass(*keys)
select{|k,v| keys.include? k }
end
def block(*keys)
reject{|k,v| keys.include? k }
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment