Compare REXML elements for name/attribute equality in RSpec

358 views Asked by At

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.

1

There are 1 answers

0
David Moles On BEST ANSWER

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