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.
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.
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.
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.
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.
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.