Monday, July 14, 2008

HaveBetterXpath

I'm rspeccing some REST controllers which return XML, and wanting to use XPath to validate the responses.

I came across this

http://blog.wolfman.com/articles/2008/01/02/xpath-matchers-for-rspec

Thanks to him. It worked nicely (couldn't be bothered messing about with hpricot to get that to go), but I didn't like the API as much as I could have.

Example of that API:

response.body.should have_xpath('/root/node1')
response.body.should match_xpath('/root/node1', "expected_value" )
response.body.should have_nodes('/root/node1/child', 3 )

I didn't like the fact that there were 3 distinct matchers, and that match_xpath didn't work with regexes. I re-worked it, so the API is now

response.body.should have_xpath('/root/node1')
response.body.should have_xpath('/root/node1').with("expected_value") # can also pass a regex
response.body.should have(3).elements('/root/node1/child') # Note actually extends string class and uses normal rspec have matcher

Extending the String class to support elements(xpath) is a win also because it lets you do things like


response.body.elements('/child').each { |e| more complex assert for e here }

Without further ado, new code here:


# Code borrowed from
# http://blog.wolfman.com/articles/2008/01/02/xpath-matchers-for-rspec
# Modified to use one matcher and tweak syntax

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)
        
        if @expected_value.nil?
          not REXML::XPath.match(doc, @xpath).empty?
        else # check each possible match for the right value
          REXML::XPath.each(doc, @xpath) do |e|
            @actual_value = e.is_a?(REXML::Element) ? 
              e.text : 
              e.to_s # handle REXML::Attribute and anything else
  
            if @expected_value.kind_of?(Regexp) && @actual_value =~ @expected_value
              return true
            elsif @actual_value == @expected_value.to_s
              return true
            end
          end
          
          false # our loop didn't hit anything, mustn't be there
        end
      end
      
      def with_value( val )
        @expected_value = val
        self
      end
      alias :with :with_value

      def failure_message
        if @expected_value.nil?
          "Did not find expected xpath #{@xpath}"
        else
          "The xpath #{@xpath} did not have the value '#{@expected_value}'\nIt was '#{@actual_value}'"
        end
      end

      def negative_failure_message
        if @expected_value.nil?
          "Found unexpected xpath #{@xpath}"
        else
          "Found unexpected xpath #{@xpath} matching value #{@expected_value}"
        end
      end

      def description
        "match the xpath expression #{@xpath}, optionally matching it's value"
      end
    end

    def have_xpath(xpath)
      HaveXpath.new(xpath)
    end
    
    # Utility function, so we can do this: 
    # response.body.should have(3).elements('/images/')
    class ::String
      def elements(xpath)
        REXML::XPath.match( REXML::Document.new(self), xpath)
      end
      alias :element :elements
    end

  end
end

No comments: