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.

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

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 url/email »)