Wolfmans Howlings

A programmers Blog about Programming solutions and a few other issues

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.

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.

Posted in Rails,RSpec  |  Tags rspec,xpath,matcher  |  5 comments

Comments

  1. Jimmy Z said on Wed Jan 02 14:01:54 -0800 2008
    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!
  2. Jimmy Z said on Thu Jan 03 07:22:11 -0800 2008
    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.
  3. Randy said on Thu Jan 08 16:35:11 -0800 2009
    The github link in your update @top appears to be stale. Meanwhile, I'll try this version :)
  4. wolfmanjm said on Thu Jan 08 18:18:43 -0800 2009
    I actually have a version I rewrote using hpricot, I'll post that too
  5. Johnathon Wright said on Tue Jun 02 13:18:36 -0700 2009
    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.

(leave email »)