Search code examples
rubyrspecrspec3rexml

Compare REXML elements for name/attribute equality in RSpec


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.


Solution

  • 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