Comparing JSON Responses in Rails Tests Without Losing Your Mind
You write an integration test for a JSON endpoint. You know exactly what the response should be, so you assert on the whole body. The test fails, prints two walls of JSON that look the same, and now you are squinting at a terminal trying to find the one field that moved. Comparing JSON responses is one of the most common things a Rails test does, and it is quietly one of the easiest to get wrong.
The problems are almost never in your endpoint. They are in how the comparison is set up. Once you know the handful of things that trip it, these assertions get short and reliable.
Start by parsing, never by comparing strings
The first mistake is asserting on the raw response body as text:
get "/api/users/1"
assert_equal '{"id":1,"name":"Alice","role":"admin"}', response.body
This is brittle for a reason that has nothing to do with your data being wrong. JSON objects have no meaningful key order, so {"id":1,"name":"Alice"} and {"name":"Alice","id":1} are the same object and different strings. Your serializer is free to emit keys in any order, and the day it changes, this test breaks even though the response is correct. Whitespace does the same thing: one extra space from JSON.pretty_generate and the string comparison fails on a difference that does not exist.
Always parse first, then compare data structures:
get "/api/users/1"
body = JSON.parse(response.body)
assert_equal({ "id" => 1, "name" => "Alice", "role" => "admin" }, body)
Now you are comparing two Ruby hashes, and Hash#== does the right thing. Key order does not matter, whitespace does not matter, only the actual keys and values do. In a Rails integration or controller test you can skip the manual parse and use response.parsed_body, which parses according to the response content type:
assert_equal({ "id" => 1, "name" => "Alice", "role" => "admin" }, response.parsed_body)
The symbol versus string trap
Here is the failure that wastes the most time. You parse the body, you compare it to the hash you built in the test, and it fails while looking pixel for pixel identical:
expected = { id: 1, name: "Alice" }
assert_equal expected, JSON.parse(response.body)
# Expected: {:id=>1, :name=>"Alice"}
# Actual: {"id"=>1, "name"=>"Alice"}
JSON.parse returns string keys by default. The hash you wrote with id: shorthand has symbol keys. In Ruby, :id and "id" are different keys, so the two hashes are not equal even though every visible character lines up. The failure output shows :id on one side and "id" on the other, but that leading colon is the whole story and it is very easy to read straight past.
You have two clean fixes. Parse with symbol keys so both sides match:
expected = { id: 1, name: "Alice" }
assert_equal expected, JSON.parse(response.body, symbolize_names: true)
Or write your expected hash with string keys so it matches the raw parse:
expected = { "id" => 1, "name" => "Alice" }
assert_equal expected, JSON.parse(response.body)
Pick one convention and hold it across the whole suite. Mixing them is how this bug keeps coming back. This is the same symbol-versus-string distinction that bites everywhere in Ruby, it just hurts more here because JSON only has string keys and your test code naturally reaches for symbols.
Assert on a subset, not the whole world
Full-body assertions are fragile because they force your test to know about fields it does not care about. A response that includes created_at, updated_at, a generated token, or a total_count that shifts as other tests seed data will break a whole-hash comparison for reasons unrelated to what you are testing.
When you only care about part of the response, pull out that part and assert on it:
body = response.parsed_body
assert_equal "Alice", body["name"]
assert_equal "admin", body["role"]
assert_equal [1, 2, 3], body.dig("permissions", "group_ids")
dig is the right tool for reaching into nested JSON without a chain of [] calls that explodes on the first nil. If an intermediate key is missing, dig returns nil and your assertion fails cleanly, instead of raising a NoMethodError on nil that hides which key was actually absent.
When you do want to check that a hash contains a set of expected pairs but do not care what else is in it, compare a slice:
assert_equal expected, body.slice(*expected.keys)
That keeps the assertion honest about the fields you own while ignoring the volatile ones you do not.
Arrays are where order actually matters
Hashes ignore order. Arrays do not. [1, 2, 3] and [3, 2, 1] are different values, so a JSON response with a list will fail comparison if the ordering is not deterministic. This is a real bug in a lot of endpoints: the underlying query has no ORDER BY, so the database returns rows in whatever order it likes, and the test passes locally and fails in CI.
The correct fix is usually to make the endpoint deterministic by adding an explicit order, because clients almost always want stable ordering too. If order genuinely does not matter for a given assertion, sort both sides before comparing, or compare as sets:
ids = response.parsed_body.map { |u| u["id"] }
assert_equal [1, 2, 3], ids.sort
Do not reach for the set trick to paper over an endpoint that should have been ordered in the first place. Fix the ordering where the response should be stable, and only relax to an unordered comparison where the lack of order is a real, intended property.
Numbers do not always survive the round trip
One more quiet source of failures: JSON has one number type, so the distinction Ruby cares about between an integer, a float, and a BigDecimal can blur as values pass through serialization. A money column stored as BigDecimal may serialize to "19.99" if you render it as a string, or to 19.99 as a float, and those compare differently against the BigDecimal you might reach for in the test. Decide how the field is represented in the response, then assert against that representation rather than the in-memory type:
assert_equal "19.99", response.parsed_body["price"]
If you assert against a float and get a string back, the failure will show 19.99 versus "19.99", a type change that is easy to miss when the digits are identical.
Seeing the difference when it still fails
Even with all of this, you will sometimes stare at two large JSON bodies that differ somewhere and refuse to tell you where. A nested response with thirty fields, one of which changed from nil to an empty string, or an integer that became a string, is exactly the kind of thing your eye skims over.
That is what the RubyHash diff viewer is for. Paste the expected hash and the actual response, and it converts both to sorted JSON and highlights precisely which key changed, flagging type changes like nil to string or integer to float explicitly rather than letting two near-identical renderings pass for equal. When the difference is a symbol-versus-string key mismatch or a single moved value buried in a deep structure, seeing it laid out side by side is usually all it takes to know which of the fixes above you need. It all runs in your browser, so a response body you are debugging never leaves your machine.
Comparing JSON responses is not hard once you stop comparing strings, keep your keys consistent, assert on the parts you own, and respect the difference between hashes and arrays. Do those four things and most of your mystery failures turn back into ordinary assertions.
Debugging a Rails test where the JSON response looks right but will not match? Try RubyHash, paste the expected and actual bodies and see exactly which key or type changed.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.