From 720a2a0e3470e0deb4a8e1970eef374aca6e3ed9 Mon Sep 17 00:00:00 2001 From: Abdullah Hashim Date: Mon, 28 Sep 2026 08:33:21 -0400 Subject: [PATCH] Add assert_hash_attributes extracted from Catberry Applies the same nil, Time, and rich text comparisons as assert_attributes to hash values. Keys are looked up as given, then as strings, so symbol keys match parsed JSON. A missing key fails, even when the expected value is nil. --- .rubocop.yml | 4 ++ CHANGELOG.md | 6 ++ README.md | 21 +++++- lib/minitest/attributes.rb | 51 +++++++++++--- sig/minitest/attributes.rbs | 6 +- test/minitest/test_attributes.rb | 114 +++++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 14 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index c14c377..03f375d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,5 +17,9 @@ Metrics/AbcSize: Exclude: - "test/**/*" +Metrics/ClassLength: + Exclude: + - "test/**/*" + Metrics/MethodLength: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 6997c6b..06363de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `assert_hash_attributes(hash, **expected)` assertion, applying the same comparisons to hash values + - Keys are looked up as given, then as strings, so symbol keys match parsed JSON + - A missing key fails, even when the expected value is `nil` + ## [0.1.0] - 2026-09-24 ### Added diff --git a/README.md b/README.md index e3c8743..72ba890 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Then run: bundle install ``` -Requiring the gem mixes `assert_attributes` into `Minitest::Assertions`, so it is available in every `Minitest::Test`, `ActiveSupport::TestCase`, and integration test. Bundler requires it for you in a Rails app; otherwise add `require "minitest/attributes"` to your test helper. +Requiring the gem mixes `assert_attributes` and `assert_hash_attributes` into `Minitest::Assertions`, so it is available in every `Minitest::Test`, `ActiveSupport::TestCase`, and integration test. Bundler requires it for you in a Rails app; otherwise add `require "minitest/attributes"` to your test helper. ## Usage @@ -44,6 +44,25 @@ A mismatch fails with a message such as: Expected Person#last_name to be "Bubblegum", but was "Abadeer" ``` +### Hashes + +`assert_hash_attributes` makes the same comparisons against the values of a hash. Each key is looked up as given and then as a string, so symbol keys also match parsed JSON: + +```ruby +assert_hash_attributes( + JSON.parse(response.body), + "@type": "Person", + name: "Marceline", + nickname: nil +) +``` + +A missing key fails with the hash's keys, even when the expected value is `nil`: + +``` +Expected hash to have key :nickname, but its keys were ["@type", "name"] +``` + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then run `bundle exec rake` to run the tests and RuboCop. You can also run `bin/console` for an interactive prompt. diff --git a/lib/minitest/attributes.rb b/lib/minitest/attributes.rb index bdc5405..7e9d326 100644 --- a/lib/minitest/attributes.rb +++ b/lib/minitest/attributes.rb @@ -14,52 +14,81 @@ module Minitest # compared within +TIME_DELTA+ seconds, and an actual value that responds to # +to_plain_text+ (such as an Action Text rich text) is compared as plain # text. Everything else falls through to +assert_equal+. + # + # +assert_hash_attributes+ applies the same comparisons to the values of a + # hash. module Attributes TIME_DELTA = 1 def assert_attributes(record, **expected_attributes) expected_attributes.each do |attribute, expected_value| - assert_attribute(record, attribute, expected_value) + assert_value( + "#{record.class.name}##{attribute}", + expected_value, + record.public_send(attribute) + ) + end + end + + # Like +assert_attributes+, but reads each key from a hash. A key is looked + # up as given, then as a string, so symbol keys also match the string keys + # of parsed JSON: + # + # assert_hash_attributes(JSON.parse(body), "@type": "Person", age: 12) + # + # A missing key fails, even when the expected value is +nil+. + def assert_hash_attributes(hash, **expected_attributes) + expected_attributes.each do |key, expected_value| + assert_hash_attribute(hash, key, expected_value) end end private - def assert_attribute(record, attribute, expected_value) - actual_value = record.public_send(attribute) + def assert_hash_attribute(hash, key, expected_value) + found_key = [key, key.to_s].find { |candidate| hash.key?(candidate) } + assert( + found_key, + "Expected hash to have key #{key.inspect}, " \ + "but its keys were #{hash.keys.inspect}" + ) + + assert_value( + "hash[#{found_key.inspect}]", expected_value, hash[found_key] + ) + end + def assert_value(label, expected_value, actual_value) if expected_value.nil? assert_nil( actual_value, - attribute_message(record, attribute, expected_value, actual_value) + value_message(label, expected_value, actual_value) ) elsif expected_value.is_a?(Time) assert_in_delta( expected_value.to_f, actual_value.to_f, TIME_DELTA, - attribute_message(record, attribute, expected_value, actual_value) + value_message(label, expected_value, actual_value) ) elsif actual_value.respond_to?(:to_plain_text) plain_actual_value = actual_value.to_plain_text assert_equal( expected_value, plain_actual_value, - attribute_message( - record, attribute, expected_value, plain_actual_value - ) + value_message(label, expected_value, plain_actual_value) ) else assert_equal( expected_value, actual_value, - attribute_message(record, attribute, expected_value, actual_value) + value_message(label, expected_value, actual_value) ) end end - def attribute_message(record, attribute, expected_value, actual_value) - "Expected #{record.class.name}##{attribute} to be " \ + def value_message(label, expected_value, actual_value) + "Expected #{label} to be " \ "#{expected_value.inspect}, but was #{actual_value.inspect}" end end diff --git a/sig/minitest/attributes.rbs b/sig/minitest/attributes.rbs index 03fa589..14ce973 100644 --- a/sig/minitest/attributes.rbs +++ b/sig/minitest/attributes.rbs @@ -4,10 +4,12 @@ module Minitest TIME_DELTA: Integer def assert_attributes: (untyped record, **untyped expected_attributes) -> void + def assert_hash_attributes: (Hash[untyped, untyped] hash, **untyped expected_attributes) -> void private - def assert_attribute: (untyped record, Symbol attribute, untyped expected_value) -> void - def attribute_message: (untyped record, Symbol attribute, untyped expected_value, untyped actual_value) -> String + def assert_hash_attribute: (Hash[untyped, untyped] hash, Symbol key, untyped expected_value) -> void + def assert_value: (String label, untyped expected_value, untyped actual_value) -> void + def value_message: (String label, untyped expected_value, untyped actual_value) -> String end end diff --git a/test/minitest/test_attributes.rb b/test/minitest/test_attributes.rb index 6efcf65..dccba76 100644 --- a/test/minitest/test_attributes.rb +++ b/test/minitest/test_attributes.rb @@ -130,5 +130,119 @@ def test_raises_when_attribute_does_not_exist assert_attributes(person, middle_name: "the") end end + + def test_hash_passes_when_every_symbol_key_matches + hash = { first_name: "Marceline", last_name: "Abadeer" } + + assert_hash_attributes(hash, first_name: "Marceline", last_name: "Abadeer") + end + + def test_hash_passes_when_every_string_key_matches + hash = { "@type" => "Person", "first_name" => "Marceline" } + + assert_hash_attributes(hash, "@type": "Person", first_name: "Marceline") + end + + def test_hash_passes_with_no_expected_attributes + assert_hash_attributes({}) + end + + def test_hash_prefers_the_key_as_given_over_its_string_form + hash = { first_name: "Marceline", "first_name" => "Finn" } + + assert_hash_attributes(hash, first_name: "Marceline") + end + + def test_hash_fails_with_a_descriptive_message_on_mismatch + hash = { "first_name" => "Marceline", "last_name" => "Abadeer" } + + error = assert_raises(Minitest::Assertion) do + assert_hash_attributes(hash, first_name: "Marceline", last_name: "Bubblegum") + end + + assert_match( + /Expected hash\["last_name"\] to be "Bubblegum", but was "Abadeer"/, + error.message + ) + end + + def test_hash_fails_when_key_is_missing + hash = { "first_name" => "Marceline" } + + error = assert_raises(Minitest::Assertion) do + assert_hash_attributes(hash, middle_name: "the") + end + + assert_match( + /Expected hash to have key :middle_name, but its keys were \["first_name"\]/, + error.message + ) + end + + def test_hash_passes_when_expected_nil_and_actual_nil + hash = { "nickname" => nil } + + assert_hash_attributes(hash, nickname: nil) + end + + def test_hash_fails_when_expected_nil_and_actual_present + hash = { "nickname" => "Marcy" } + + error = assert_raises(Minitest::Assertion) do + assert_hash_attributes(hash, nickname: nil) + end + + assert_match( + /Expected hash\["nickname"\] to be nil, but was "Marcy"/, + error.message + ) + end + + def test_hash_fails_when_expected_nil_and_key_is_missing + hash = {} + + error = assert_raises(Minitest::Assertion) do + assert_hash_attributes(hash, nickname: nil) + end + + assert_match(/Expected hash to have key :nickname/, error.message) + end + + def test_hash_passes_when_time_is_within_delta + born_at = Time.utc(2000, 1, 1, 12, 0, 0) + hash = { born_at: born_at + 0.5 } + + assert_hash_attributes(hash, born_at:) + end + + def test_hash_fails_when_time_is_outside_delta + born_at = Time.utc(2000, 1, 1, 12, 0, 0) + hash = { born_at: born_at + 2 } + + error = assert_raises(Minitest::Assertion) do + assert_hash_attributes(hash, born_at:) + end + + assert_match(/Expected hash\[:born_at\] to be 2000-01-01 12:00:00 UTC/, error.message) + end + + def test_hash_compares_rich_text_as_plain_text + hash = { bio: RichText.new("

Vampire Queen

") } + + assert_hash_attributes(hash, bio: "Vampire Queen") + end + + def test_hash_fails_on_rich_text_mismatch_with_plain_text_in_message + hash = { bio: RichText.new("

Vampire Queen

") } + + error = assert_raises(Minitest::Assertion) do + assert_hash_attributes(hash, bio: "Bass Player") + end + + assert_match( + /Expected hash\[:bio\] to be "Bass Player", but was "Vampire Queen"/, + error.message + ) + end end end