Wolfmans Howlings

A programmers Blog about Ruby, Rails and a few other issues

XPath matchers for rspec

Posted by Jim Morris Wed, 02 Jan 2008 21:32:50 GMT

UPDATE I found this which is a nicer way of doing it using hpricot, which is faster. I have created a slightly different version that does have_xpath, for XML.

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}'\nIt 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}\nFound #{@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 ,  | Tags , ,  | 2 comments | no trackbacks

Comments

  1. Jimmy Z said 29 minutes later:

    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 about 18 hours later:

    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.

Trackbacks

Use the following link to trackback from your own site:
http://blog.wolfman.com/articles/trackback/339

(leave url/email »)

   Comment Markup Help Preview comment