← Back to blog

Your RSpec Hash Diff Is Unreadable. Here Is How to Fix It

· Lachlan Young

You write what should be the simplest assertion in the suite:

expect(response_body).to eq(expected_payload)

It fails, and RSpec hands you this:

Failure/Error: expect(response_body).to eq(expected_payload)

  expected: {"id" => 7, "name" => "Ada", "email" => "ada@example.test", "role" => "admin", "seats" => 3, "active" => true, "trial_ends_at" => nil}
       got: {"active" => true, "email" => "ada@example.test", "id" => 7, "name" => "Ada", "role" => "editor", "seats" => 3.0, "trial_ends_at" => nil}

  (compared using ==)

  Diff:
  @@ -1,7 +1,7 @@
  -{"id" => 7,
  - "name" => "Ada",
  - "email" => "ada@example.test",
  - "role" => "admin",
  - "seats" => 3,
  - "active" => true,
  +{"active" => true,
  + "email" => "ada@example.test",
  + "id" => 7,
  + "name" => "Ada",
  + "role" => "editor",
  + "seats" => 3.0,
   "trial_ends_at" => nil}

Almost every line is marked as changed. Two things actually differ. This gets worse fast: a serializer response with thirty keys and two levels of nesting produces a wall of red and green where nothing stands out, and the real difference hides in the middle of it.

Why the diff looks like that

RSpec’s differ does not compare your two hashes structurally. It pretty prints both objects with PP, then runs a line-based text diff over the resulting strings. That is a reasonable general strategy, since it works for any object that responds to pretty_print, but it means the diff you read is a diff of formatting, not of data.

Two consequences follow, and they explain most unreadable hash diffs.

Key order counts. {a: 1, b: 2} == {b: 2, a: 1} is true in Ruby, because Hash#== ignores insertion order. The text diff does not ignore it. A hash built by your test and a hash that came back from as_json, an HTTP response, or an ActiveRecord row will usually have different insertion order, so every line shifts and the diff marks all of them.

Line boundaries count. PP decides where to break lines based on total width. Change one value from 3 to 3.0 and the wrapping downstream of it can change too, so lines you never touched get flagged.

There is a third thing the format actively hides. In the diff above, "seats" => 3 became "seats" => 3.0. That is an Integer turning into a Float, exactly the kind of change that causes a real bug later, and it is one character wide in a screen of output. Same story for nil becoming "", or a symbol key becoming a string key after a round trip through JSON.

Sort before you compare

The cheapest fix is to stop feeding the differ unordered input. Normalize both sides recursively, then assert:

# spec/support/deep_sort.rb
module DeepSort
  def deep_sort(object)
    case object
    when Hash
      object.keys.sort_by(&:to_s).each_with_object({}) do |key, result|
        result[key] = deep_sort(object[key])
      end
    when Array
      object.map { |element| deep_sort(element) }
    else
      object
    end
  end
end

RSpec.configure { |config| config.include DeepSort }
expect(deep_sort(response_body)).to eq(deep_sort(expected_payload))

The assertion means exactly the same thing, because == never cared about order anyway. What changes is that both sides now pretty print in the same sequence, so the text diff has a chance of lining them up and marking only the lines that genuinely moved. On the example above this alone reduces the diff from six changed lines to two.

Note that this sorts hash keys, not array elements. Do not sort arrays, since order in a JSON array is usually meaningful and sorting it would make your test pass on a genuinely wrong response.

Stop asserting on the whole hash

Often the readable-diff problem is really a scope problem. If the test cares about two fields, assert on two fields:

expect(response_body).to include("role" => "admin", "seats" => 3)

include on a hash checks only the pairs you name and reports only those in the failure message. For nested structures, match composes with other matchers, which lets you be exact where it matters and loose where it does not:

expect(response_body).to match(
  "id" => 7,
  "name" => "Ada",
  "role" => "admin",
  "created_at" => a_string_matching(/\A\d{4}-\d{2}-\d{2}/),
  "account" => a_hash_including("plan" => "pro")
)

This is worth doing for its own sake. A test that asserts on the entire serialized payload fails every time anyone adds a field, which trains the team to update expectations without reading them. That is how a wrong value gets pasted into a fixture and stays there.

The honest exception is the contract test whose whole job is to pin the full response shape. Keep one of those per endpoint if you want it, and use include or match for the rest.

Write a matcher that reports the difference

When you do need whole-hash equality, you can replace the text diff with a structural one. A custom matcher gives you full control over the failure message:

# spec/support/matchers/match_hash.rb
RSpec::Matchers.define :match_hash do |expected|
  match { |actual| actual == expected }

  failure_message do |actual|
    added   = actual.keys - expected.keys
    removed = expected.keys - actual.keys
    changed = (expected.keys & actual.keys).reject { |key| expected[key] == actual[key] }

    lines = ["Hashes differ:"]
    lines << "  added keys:   #{added.inspect}" if added.any?
    lines << "  removed keys: #{removed.inspect}" if removed.any?
    changed.each do |key|
      before, after = expected[key], actual[key]
      type_note = before.class == after.class ? "" : "  (#{before.class} to #{after.class})"
      lines << "  #{key.inspect}: #{before.inspect} => #{after.inspect}#{type_note}"
    end
    lines.join("\n")
  end
end

On the failing example, that prints:

Hashes differ:
  "role": "admin" => "editor"
  "seats": 3 => 3.0  (Integer to Float)

Two lines, both of them the actual answer, with the type change called out rather than buried. This version is deliberately shallow, since it compares top-level keys and treats a nested hash as a single value. That is usually enough, and making it recursive is a reasonable afternoon if your payloads are deep.

The gem option

If you would rather not maintain a matcher, super_diff replaces RSpec’s differ globally with one that understands Ruby data structures. Add it to the :test group, require it in rails_helper, and every eq failure on a hash or array starts reporting structural changes with colored, indented output instead of a line diff. It is the right call when the whole suite has this problem rather than a few specs.

What to check when the values look identical

Sometimes the diff is readable and still makes no sense, because the two hashes genuinely look the same on screen. In that case the difference is a type or an invisible value, and the usual suspects are short: string keys against symbol keys, nil against "", an Integer against a Float or a BigDecimal, a Time against an ActiveSupport::TimeWithZone that formats identically but carries different subsecond precision, and HashWithIndifferentAccess against a plain Hash. Calling .class on the specific value is a faster route to the answer than staring at inspect output.

Ruby 3.4 changed Hash#inspect to print {name: "Ada"} for symbol keys instead of {:name=>"Ada"}, which is a real improvement for reading these diffs. It also means output pasted from an older Ruby looks different from output produced today, so be careful comparing a diff from CI against one from your laptop if they are on different versions.

When you have two hashes in front of you and the eye test is failing, stop doing it by eye. Try RubyHash to paste both, get them parsed, sorted, and diffed side by side, with type changes flagged explicitly. If you want the same thing inline in your test output, the minitest-hashdiff gem does it for Minitest’s assert_equal.

Enjoyed this post?

Subscribe to get notified when we publish more Ruby and Rails content.