2

Is there a matcher for comparing REXML elements for logical equality in RSpec? I tried writing a custom matcher that converts them to formatted strings, but it fails if the attribute order is different. (As noted in the XML spec, the order of attributes should not be significant.)

I could grind through writing a custom matcher that compares the name, namespace, child nodes, attributes, etc., etc., but this seems time-consuming and error-prone, and if someone else has already done it I'd rather not reinvent the wheel.

David Moles
  • 48,006
  • 27
  • 136
  • 235
  • Can you show us what you tried. Some sample data and what you expect the output to be, and what the actual output was instead? – Taryn East May 07 '15 at 06:41
  • I don't think it's worth showing. String comparison is a dead-end approach without first converting it to [canonical XML](http://www.w3.org/TR/xml-c14n2/) or something. – David Moles May 07 '15 at 21:46
  • 1
    Also, it's not really a question of a mismatch between actual results and expectations. If I'm converting two XML elements to strings, and they differ only in the order (though not names or values) of their attributes, I pretty much know comparing the strings is going to fail. – David Moles May 07 '15 at 21:47

1 Answers1

2

I ended up using the equivalent-xml gem and writing an RSpec custom matcher to convert the REXML to Nokogiri, compare with equivalent-xml, and pretty-print the result if needed.

The test assertion is pretty simple:

expect(actual).to be_xml(expected)

or

expect(actual).to be_xml(expected, path)

if you want to display the file path or some sort of identifier (e.g. if you're comparing a lot of documents).

The match code is a little fancier than it needs to be because it handles REXML, Nokogiri, and strings.

  module XMLMatchUtils
    def self.to_nokogiri(xml)
      return nil unless xml
      case xml
      when Nokogiri::XML::Element
        xml
      when Nokogiri::XML::Document
        xml.root
      when String
        to_nokogiri(Nokogiri::XML(xml, &:noblanks))
      when REXML::Element
        to_nokogiri(xml.to_s)
      else
        raise "be_xml() expected XML, got #{xml.class}"
      end
    end

    def self.to_pretty(nokogiri)
      return nil unless nokogiri
      out = StringIO.new
      save_options = Nokogiri::XML::Node::SaveOptions::FORMAT | Nokogiri::XML::Node::SaveOptions::NO_DECLARATION
      nokogiri.write_xml_to(out, encoding: 'UTF-8', indent: 2, save_with: save_options)
      out.string
    end

    def self.equivalent?(expected, actual, filename = nil)
      expected_xml = to_nokogiri(expected) || raise("expected value #{expected || 'nil'} does not appear to be XML#{" in #{filename}" if filename}")
      actual_xml = to_nokogiri(actual)

      EquivalentXml.equivalent?(expected_xml, actual_xml, element_order: false, normalize_whitespace: true)
    end

    def self.failure_message(expected, actual, filename = nil)
      expected_string = to_pretty(to_nokogiri(expected))
      actual_string = to_pretty(to_nokogiri(actual)) || actual

      # Uncomment this to dump expected/actual to file for manual diffing
      #
      # now = Time.now.to_i
      # FileUtils.mkdir('tmp') unless File.directory?('tmp')
      # File.open("tmp/#{now}-expected.xml", 'w') { |f| f.write(expected_string) }
      # File.open("tmp/#{now}-actual.xml", 'w') { |f| f.write(actual_string) }

      diff = Diffy::Diff.new(expected_string, actual_string).to_s(:text)

      "expected XML differs from actual#{" in #{filename}" if filename}:\n#{diff}"
    end

    def self.to_xml_string(actual)
      to_pretty(to_nokogiri(actual))
    end

    def self.failure_message_when_negated(actual, filename = nil)
      "expected not to get XML#{" in #{filename}" if filename}:\n\t#{to_xml_string(actual) || 'nil'}"
    end
  end

The actual matcher is fairly straightforward:

  RSpec::Matchers.define :be_xml do |expected, filename = nil|
    match do |actual|
      XMLMatchUtils.equivalent?(expected, actual, filename)
    end

    failure_message do |actual|
      XMLMatchUtils.failure_message(expected, actual, filename)
    end

    failure_message_when_negated do |actual|
      XMLMatchUtils.failure_message_when_negated(actual, filename)
    end
  end
David Moles
  • 48,006
  • 27
  • 136
  • 235