XPath matchers for rspec
Posted by Jim Morris on Wed Jan 02 13:32:50 -0800 2008
I've been working on a project that is mostly Java for the last many months, so haven't had much Ruby or Rails stuff to share.
However one thing I found when working on my tests in Java was an xpath matcher for JUnit 4.0 using the Hamcrest libraries.
When I dropped back into Ruby to write some cron scripts that process information from the database and generate xml files I wanted to check the script with an rspec, and check the xml files it was generating.
To do this I wanted to use a similar matcher to the Hamcrest ones, but use it in RSpec.
I Googled around and found a simple example, but it wasn't very sophisticated and didn't check what I needed so I upgraded it to do the kind of matches I needed, the results are here.
require 'rexml/document' require 'rexml/element' module Spec module Matchers # check if the xpath exists one or more times class HaveXpath def initialize(xpath) @xpath = xpath end def matches?(response) @response = response doc = response.is_a?(REXML::Document) ? response : REXML::Document.new(@response) match = REXML::XPath.match(doc, @xpath) not match.empty? end def failure_message "Did not find expected xpath #{@xpath}" end def negative_failure_message "Did find unexpected xpath #{@xpath}" end def description "match the xpath expression #{@xpath}" end end def have_xpath(xpath) HaveXpath.new(xpath) end # check if the xpath has the specified value # value is a string and there must be a single result to match its # equality against class MatchXpath def initialize(xpath, val) @xpath = xpath @val= val end def matches?(response) @response = response doc = response.is_a?(REXML::Document) ? response : REXML::Document.new(@response) ok= true REXML::XPath.each(doc, @xpath) do |e| @actual_val= case e when REXML::Attribute e.to_s when REXML::Element e.text else e.to_s end return false unless @val == @actual_val end return ok end def failure_message "The xpath #{@xpath} did not have the value '#{@val}' It was '#{@actual_val}'" end def description "match the xpath expression #{@xpath} with #{@val}" end end def match_xpath(xpath, val) MatchXpath.new(xpath, val) end # checks if the given xpath occurs num times class HaveNodes #:nodoc: def initialize(xpath, num) @xpath= xpath @num = num end def matches?(response) @response = response doc = response.is_a?(REXML::Document) ? response : REXML::Document.new(@response) match = REXML::XPath.match(doc, @xpath) @num_found= match.size @num_found == @num end def failure_message "Did not find expected number of nodes #{@num} in xpath #{@xpath} Found #{@num_found}" end def description "match the number of nodes #{@num}" end end def have_nodes(xpath, num) HaveNodes.new(xpath, num) end end end
The first matcher HaveXPath was pretty much the original I found on
the net, it simply checks that an XPath exists, I don't use this one.
The next matcher MatchXPath is more like what I was using in Java,
it gets an element from the xpath and checks the value is equal to the
one expected string. I will eventually add regex matching and arrays
of strings or regexs to check against.
The last one HaveNodes I find handy to make sure a given xpath matches the
expected number of nodes.
I even wrote an rspec to check the matchers, and this also is handy to show the way to use them.
Note that you can pass the matchers a String containing the XML or (much faster) a REXML::Document.
require 'matchers' describe "test matchers" do before(:each) do @xml= <<-EOFXML <?xml version='1.0'?> <claims> <testnode1> <day> <rank order='1' value='0' userid='26' alias='user25'/> <rank order='2' value='0' userid='93' alias='user92'/> <rank order='3' value='0' userid='55' alias='user54'/> <sometext>this is text</sometext> </day> </testnode1> </claims> EOFXML @doc= REXML::Document.new(@xml) end it "should test xpath" do @xml.should have_nodes("/claims/*", 1) @doc.should have_nodes("/claims/*", 1) @doc.should have_nodes("/claims/testnode1/day/rank", 3) @doc.should have_xpath("/claims/testnode1/day/rank[@order='1']") @doc.should_not have_xpath("/claims/testnode1/day/rank[@order='10']") @doc.should match_xpath("/claims/testnode1/day/rank[1]/@order", "1") @doc.should match_xpath("/claims/testnode1/day/rank[2]/@value", "0") @doc.should match_xpath("/claims/testnode1/day/rank[3]/@alias", "user54") @doc.should match_xpath("/claims/testnode1/day/sometext", "this is text") end end
Awesome! I was just about to my own custom matchers for XPath when I decided to google for an existing matcher. This is exactly what I was wanting. Nice work!
It seems REXML's XPath does not work when you are specifying attribute conditions if there is a namespace declared in the xml. I was able to fix this by adding an extra parameter to the matchers to accept a namespace hash to specify a namespace prefix. For example, if your example xml had a namespace declared on claims: `<claims xmlns='http://jimmyzimmerman.com/namespace'>` then you need a namespace prefix on your elements like so: `xml.should have_xpath("/z:claims/z:testnode1/z:day/z:rank[@order='1']", {'z' => 'http://jimmyzimmerman.com/namespace'})` Feel free to email me if you would like a patch.
The github link in your update @top appears to be stale. Meanwhile, I'll try this version :)
I actually have a version I rewrote using hpricot, I'll post that too
There is a bug in MatchXpath... for invalid xpaths, REXML::XPath.each(doc, @xpath) results in an empty set, so the value ok never gets a chance to be set to false. (I'm assuming this is not the expected behavior... ) I'm on the road so I can't commit to github right now. Until I can, here are some failing specs... require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') describe Spec::Matchers::MatchXpath do it 'should produce a list of nodes at the given xpath' do matcher = Spec::Matchers::MatchXpath.new('/ride/info', 'x') matcher.xml = '<ride><info>x</info><info>y</info></ride>' matcher.node_values.should == ['x', 'y'] end describe 'when matching a path that does not exist' do before(:all) do value = '2009-09-09T09:09:00+09:00' @document = "<ride><end_time>#{value}</end_time></ride>" @matcher = Spec::Matchers::MatchXpath.new('/invalid/end_time', value) @matcher.matches?(@document) end it 'should not match' do @matcher.matches?(@document).should be_false end it 'should indicate that there were no nodes in the failure message' do @matcher.failure_message.should == 'There were no nodes at /invalid/end_time' end end describe 'with a valid xpath and one matching node of many' do before(:all) do @document = "<ride><info>a</info><info>b</info></ride>" @matcher = Spec::Matchers::MatchXpath.new('/ride/info', 'a') @matcher.matches?(@document) end it 'should match' do @matcher.matches?(@document).should be_true end end end and a new version of the class..... class MatchXpath def initialize(xpath, val) @xpath = xpath @val= val end def document @xml.is_a?(REXML::Document) ? @xml : REXML::Document.new(@xml) end def nodes REXML::XPath.match(document, @xpath) end def node_values nodes.map do |node| case node when REXML::Attribute node.to_s when REXML::Element node.text else node.to_s end end end def any_nodes_there? (node_values.size > 0 ) end def matching_nodes? node_values.any?{|value| value==@val} end attr_accessor :xml def matches?(response) @xml = response matching_nodes? end def failure_message if matching_nodes? "The xpath #{@xpath} did not have the value '#{@val}' It was '#{@actual_val}'" else "There were no nodes at #{@xpath}" end end def description "match the xpath expression #{@xpath} with #{@val}" end end enjoy.