Timur's Blog
... on life, software engineering and human languages

How to match Hashes in RSpec like a pro

September 22, 2022

Introduction

If you code in Ruby, there is a good chance that you write tests in RSpec.

I personally like RSpec a lot, especially with the rubocop-rspec rules.

I always missed one feature though: how to elegantly use rspec matchers when testing hash result?


For example, let's assume we have a hash like this, coming from some function:

{
  timestamp: Time.parse('2022-09-22'),
  status: :success,
  message: 'Hello world!'
}

And let's say we want to test that we get an instance of Time class in the :timestamp key. And we don't want to test the exact value in that field, because we expect it to change all the time.

As a side note, timestamps in a controlled environment can always be frozen with timecop gem; let's imagine that we don't want to do this or any other stubbing, like with doubles, in this particular situation.


Initial approach

It is always possible to do something like..

expect(response[:timestamp]).to(be_kind_of(Time))

..but then we can't test the hash as a whole:

let(:expected_response) {
  {
    # Problem: we need to stub or know this value
    timestamp: some_value,
    status: :success
    message: 'Hello world!'
  }
}

it { is_expected.to(eq(expected_response)) }
it { subject; expect(response[:timestamp]).to(be_kind_of(Time)) }

To avoid stubbing :timestamp , we can do this:

let(:partial_response) {
  {
    status: :success,
    message: 'Hello world!'
  }
}

it { is_expected.to(include(partial_response)) }
it { subject; expect(response[:timestamp]).to(be_kind_of(Time)) }

Now the overview of the full hash structure is missing, but it does the trick.


Issues with the initial approach

However, what if the response changed and now it includes a huge hash?

{
  timestamp: Time.parse('2022-09-22'),
  status: :success,
  message: 'Hello world!',
  huge_hash: some_huge_hash
}

Then the test wouldn't notice it. Of course, it can be acceptable, because the contract "a hash that has at least :timestamp , :status, :message key-value pairs" is preserved and a :huge_hash extension doesn't hurt.


But it is important to catch things like this: tests are the most up-to-date documentation of the code.

Tests are not only meant to ensure that the code works, they are also a communication channel with developers in the future (including ourselves). If someone were to look into this test, they wouldn't know about :huge_hash at all.

Besides, with the current implementation, even :timestamp might slip from the attention, as it is absent from the expected_response or partial_response params.


An idea how to make hash tests better

It would be cool to be able to write something like this:

let(:expected_response) {
  {
    timestamp: be_kind_of(Time),
    status: :success,
    message: 'Hello world!',
    huge_hash: be_kind_of(Hash)
  }
}

But running it with:

it { is_expected.to(eq(expected_response)) }

will fail, because it would simply compare a matcher like be_kind_of with the element in the hash.


The undocumented feature

However, there is a fairly unknown and undocumented feature that match predicate allows exactly this. I discovered it by chance simply trying out different options.

This:

it { is_expected.to(match(expected_response)) }

will actually work as desired.


The cool thing is that it even allows chaining the matchers in the hash, like:

let(:expected_response) {
  {
    timestamp: be_kind_of(Time),
    status: :success,
    message: 'Hello world!',
    huge_hash: be_kind_of(Hash).and(include(some_important: :contract))
  }
}

Note that in this case, unlike in the previous implementation, we explicitly let the future readers of the test know that we're aware of the :huge_hash key-value pair and that it largely doesn't interest us.


Conclusion

I use this feature quite often, as it allows me to elegantly test the hashes (for example, in controller or in external API tests) by focusing on the parts that matter to me while still retaining an overview of the hash result; and I get to use idiomatic rspec.

I hope you enjoyed this article and I'm looking forward to receiving feedback.

© Copyright 2022 Timur's Blog. Powered with by CreativeDesignsGuru