Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,9 @@ Metrics/AbcSize:
Exclude:
- "test/**/*"

Metrics/ClassLength:
Exclude:
- "test/**/*"

Metrics/MethodLength:
Enabled: false
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
51 changes: 40 additions & 11 deletions lib/minitest/attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions sig/minitest/attributes.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
114 changes: 114 additions & 0 deletions test/minitest/test_attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>Vampire Queen</p>") }

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("<p>Vampire Queen</p>") }

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
Loading