2

I have an array of hashes and I'm trying to assert that the array has exactly a certain number of hashes in a certain order that have a certain key.

So let's say I have an array of fruits.

fruits = [
  { name: 'apple', count: 3 },
  { name: 'orange', count: 14 },
  { name: 'strawberry', count: 7 },
]

When I use the eq matcher with hash_including (or include which is its alias), the assertion fails.

# fails :(
expect(fruits).to eq([
  hash_including(name: 'apple'),
  hash_including(name: 'orange'),
  hash_including(name: 'strawberry'),
])

It's weird that this doesn't work and I've always found a way around it and moved on, but it's been bothering me for a while, so I decided to post about it this time.

What I'm not looking for

Obviously this works but I like the other syntax because that's kinda the point of these matchers: so I don't have to transform my data structures by hand and have more readable specs.

fruit_names = fruits.map { |h| h.fetch(:name) }
expect(fruit_names).to eq(['apple', 'orange', 'strawberry'])

contain_exactly and include work but I care about the exact size of the array and the order of elements, which they fail to assert.

# passes but doesn't assert the size of the array or the order of elements
expect(fruits).include(
  hash_including(name: 'apple'),
  hash_including(name: 'orange'),
  hash_including(name: 'strawberry'),
)

# passes but doesn't assert the exact order of elements
expect(fruits).contain_exactly(
  hash_including(name: 'apple'),
  hash_including(name: 'orange'),
  hash_including(name: 'strawberry'),
)
mechnicov
  • 12,025
  • 4
  • 33
  • 56
Amiratak88
  • 1,204
  • 12
  • 18
  • Since you want to assert the size and the order, wouldn't it be simpler to use `to_s`, as in `expect(fruits.to_s).to eq({......}.to_s)`? – user1934428 Aug 15 '22 at 06:40
  • 1
    It'd be easier if `fruits` is instead a hash, e.g `fruits = { apple: { count: 3 }, orange: { count: 14 }, strawberry: { count: 7 } }` – Sebastián Palma Aug 15 '22 at 07:45
  • @de-russification but this require some data transformation – mechnicov Aug 15 '22 at 11:18
  • @user1934428 the reason i’m using include is because i only care about one key in the hash. This is just a contrived example. In reality each of those hashes is ginormous. – Amiratak88 Aug 15 '22 at 16:44

3 Answers3

3

Looks like you just need to use match

fruits = [
  { name: 'apple', count: 3 },
  { name: 'orange', count: 14 },
  { name: 'strawberry', count: 7 },
]

expect(fruits).to match([
  include(name: 'apple'),
  include(name: 'orange'),
  include(name: 'strawberry'),
])

This test will fail if some array element is missing or extra

This test will fail if some of hashes doesn't include specified key-value pair

This test will fail in case of wrong array elements order

mechnicov
  • 12,025
  • 4
  • 33
  • 56
  • The 3.11 docs for the [`match`](https://relishapp.com/rspec/rspec-expectations/v/3-11/docs/built-in-matchers/match-matcher) matcher only shows examples for string and regex usage and mentions "data structures" very briefly. You can see some examples in the specs: https://github.com/rspec/rspec-expectations/blob/v3.11.0/spec/rspec/matchers/built_in/match_spec.rb#L80-L95 – Stefan Aug 15 '22 at 11:53
  • @Stefan yes, docs are not so good unfortunately, `match` works not only with arrays, but with hashes too: https://stackoverflow.com/a/71444640/10608621 – mechnicov Aug 15 '22 at 12:59
0

I don't remember any built-in matcher that allows checking of inclusion and order at the same time (UPD. I'm wrong) while being flexible in terms of matching values, but if you need these checks often you could create a simple custom matcher quite easily.

The very basic version is quite straightforward:

RSpec::Matchers.define :contain_exactly_ordered do |expected_collection|
  match do |actual|
    expected_collection.each_with_index.all? do |expected_element, index|
      values_match?(expected_element, actual[index])
    end
  end
end

and then

specify do
  fruits = [
    { name: 'apple', count: 3 },
    { name: 'orange', count: 14 },
    { name: 'strawberry', count: 7 },
  ]

  expect(fruits).to contain_exactly_ordered([
    hash_including(name: 'apple'),
    hash_including(name: 'orange'),
    hash_including(name: 'strawberry'),
  ]) #=> This should be green
end

There is one serious drawback though - in case of the wrong order this matcher doesn't provide enough information to fix the error easily (it doesn't say which elements exactly are mismatched - not a big deal for 2-3 elements but for bigger collections, this might be a pain). So, ideally, we need to tailor the failure message to make it really helpful. Something like the following:

RSpec::Matchers.define :contain_exactly_ordered do |expected_collection|
  match do |actual|
    @mismatched_items = expected_collection.each_with_index.reject do |expected_element, index|
      values_match?(expected_element, actual[index])
    end

    @mismatched_items.none?
  end

  failure_message do
    @mismatched_items.map do |expectation, i|
      "  [#{i}]: expected #{expectation.description} to match #{actual[i].inspect}"
    end.unshift("Mismatched items found at the following indices:").join("\n")
  end
end

Now if we mess up the order:

specify do
  fruits = [
    { name: 'apple', count: 3 },
    { name: 'orange', count: 14 },
    { name: 'strawberry', count: 7 },
  ]

  expect(fruits).to contain_exactly_ordered([
    hash_including(name: 'orange'),
    hash_including(name: 'apple'),
    hash_including(name: 'strawberry'),
  ])
end

our error message is quite helpful:

1) ... should contain exactly ordered hash_including(:name=>"orange"), hash_including(:name=>"apple"), and hash_including(:name=>"strawberry")
     Failure/Error:
       expect(fruits).to contain_exactly_ordered([
         hash_including(name: 'orange'),
         hash_including(name: 'apple'),
         hash_including(name: 'strawberry'),
       ])
     
       Mismatched items found at the following indices:
         [0]: expected hash_including(:name=>"orange") to match {:name=>"apple", :count=>3}
         [1]: expected hash_including(:name=>"apple") to match {:name=>"orange", :count=>14}

Konstantin Strukov
  • 2,899
  • 1
  • 10
  • 14
-1

What you're doing is a bit of an anti-pattern. You should almost never write tests with that level of complexity, or bundle multiple tests into a single expectation. Doing so gives you poor granularity on results, and makes it harder for tests to identify problems.

You're much better off having different specs for each result, or simply looking for expected data or a full data structure of the structure is invariant. For example:

describe "#fruits" do
  let(:fruits) do
    [
      { name: 'apple', count: 3 },
      { name: 'orange', count: 14 },
      { name: 'strawberry', count: 7 },
    ]
  end

  it "should contain an apple" do
    expect(fruits.keys).to inlcude("apple")
  end

  it "should contain an orange" do
    expect(fruits.keys).to inlcude("orange")
  end

  it "should contain an apple" do
    expect(fruits.keys).to inlcude("strawberry")
  end
end

Otherwise, if you want the whole structure, just make sure it's ordered. Arrays are ordered, and need to be ordered to be equal. Hashes guarantee insert order, but are equal regardless of order if they contain the same content, so you'd want to compare:

  # under your "#fruits" tests...
  describe "sorted fruits" do     
    let(:sorted_fruits) { fruits.sort_by { _1[:name] } }

    it "should have the same structure as the original array of hashes" do
      expect(fruits).to eql(sorted_fruits)
    end

    it "should contain an apple as the first fruit" do
      expect(sorted_fruits.first).to eql("apple")
    end
  end

There are other ways to do this, of course, but the point is that you want your tests to be DAMP, not DRY, and to keep your tests as free of new logic as possible. Otherwise, you're likely to introduce complexity to your tests that is exercising untested testing logic rather than simply testing the object under test. YMMV.

Todd A. Jacobs
  • 81,402
  • 15
  • 141
  • 199
  • 2
    `expect(fruits.keys).to inlcude(...)` – `fruits` is an array of hashes, not a single hash. The OP just wants to test that it contains the 3 items in the correct order. – Stefan Aug 15 '22 at 11:36
  • If the order of things in some operation's result is a part of contact to be tested, this recommendation `if you want the whole structure, just make sure it's ordered.` is simply non-feasible. – Konstantin Strukov Aug 15 '22 at 13:02
  • Thank you for your time. I read this a few times and I’m confused. I’m not sure if you understood my question. I’ll be happy to clarify. Also i think the example code is wrong. – Amiratak88 Aug 15 '22 at 16:30
  • What’s under test is “the order of elements”. Not sure what you mean by bundling things together. – Amiratak88 Aug 15 '22 at 16:33
  • Coming back to this, I think maybe you’re thinking that I’m trying to assert that a key points to a certain value in the hash and also that the hash exists in the array? Maybe that’s what you meant by bundling? – Amiratak88 Aug 15 '22 at 16:47
  • It looks that way but what i’m trying to test is the order of the hashes in the array. Now the way i’m distinguishing one hash from another is by saying the one that has this key. So basically in english, i’m trying to say “the hash that has ‘name => apple’ comes first, then ‘name => orange’, then ‘name => banana’ – Amiratak88 Aug 15 '22 at 16:50