I ran into an issue with an external gem today that required me to write a custom RSpec matcher to test against. I’ve written these before for libraries I’ve developed, but it turned out to be an interesting use case, so I thought I’d share.
Background
The gem in question was intended to provide a common serialization method for publishing events onto a queue. In reality, it was only a very thin wrapper that abstracted the queue implementation away from the application. That’s an important aspect of any library, but not a particularly robust wrapper in my opinion as this meant that applications now needed to be aware of the structure of the message to be put on the queue. This included setting its own timestamps and managing unique IDs for the messages. It also needed to serialize that message to a JSON string first.
For various reasons that are irrelevant here, substituting this library was infeasible.
Testing Woes
So I knew we had some issues with the library, but I figured if I test the shape of the payload sent to it, I’d at least have high confidence my code works. This is not the ideal approach in its own right, but sometimes perfect is the enemy of good.
In particular I had to make sure an ID in my message was correct else bad things happen.
Let’s say I wanted to send this library something like this, and the key object_id
is critical:
{
object_id: 1,
object_type: "SomeObject",
action: "GET"
}
What I’d need to do for the library is make sure it had the right ID and timestamp, and serialize it as JSON:
metadata = { id: SecureRandom.uuid, timestamp: Time.now.iso8601 }
payload = {
object_id: 1,
object_type: "SomeObject",
action: "GET"
}.merge(metadata)
WrapperLibrary.send_message payload.to_json
So as you can see we have 2 fields (id
and timestamp
) that are volatile. And the actual payload we send to the library looks like:
"{\"object_id\":1,\"object_type\":\"SomeObject\",\"action\":\"GET\",\"id\":\"1a847276-998a-405e-91eb-634b897a5592\",\"timestamp\":\"2023-01-20 01:08:56 -0500\"}"
Initial Attempt
I assumed testing this would be somewhat straightforward – I could mock out the SecureRandom.uuid
and Time.now
calls so they would always return the same values. Since my payload was static, this meant I’d have a reliable test for the object_id
I really cared about.
I created a spy to listen on what arguments the wrapper received and checked whether the payload was correct.
RSpec.describe Implementation do
let(:time_now) { Time.now }
let(:id) { SecureRandom.uuid}
let(:wrapper) { spy(WrapperLibrary) }
let(:payload) do
{
id: SecureRandom.uuid,
timestamp: Time.now.iso8601,
object_id: 1,
object_type: "SomeObject",
action: "GET"
}.to_json
end
before do
allow(Time).to receive(:now).and_return(time_now)
allow(SecureRandom).to receive(:uuid).and_return(id)
end
it "sends message with correct object ID" do
Implementation.run!
expect(spy).to have_received(:send_message).with(payload)
end
end
This failed because we use SecureRandom.uuid
to generate lots of UUIDs in our code – ActiveRecord models and FactoryBot factories use them heavily, for instance, and so I caused a bunch of index violations on UUID columns in the database.
Working Attempt
The second time, I realized I don’t actually care about what the timestamp and message ID are, so I can just ignore them. There’s no need to mock either of those two calls, either.
What I did was write a custom matcher:
RSpec::Matchers.define :json_message_including do |expected|
raise ArgumentError.new("Expected values must be a hash!") unless expected.is_a?(Hash)
match do |actual|
actual = JSON.parse(actual)
expected.all? { |key, value| value == actual[key.to_s] }
end
end
This takes an expected
and actual
and can match whatever you pass into it. In our case, the expected value will be a hash (so I can pass in arbitrary key / value pairs and have a partial match). The actual value is deserialized from JSON and then is tested to make sure all the expected values are contained within it.
Now I just needed to update the test:
it "sends message with correct object ID" do
Implementation.run!
# Could test for more key / value pairs here, but object_id is the only mission-critical one.
expect(spy).to have_received(:send_message).with(json_message_including(object_id: 1))
end
Takeaways / Next Steps
This ended up being a nice workaround for a shortcoming in an external dependency for us. I’ll admit this is a weird use case for most people, but custom matchers in general are very powerful. They’re great for when you want to intelligently compare data structures on a deeper level than just plain equality (==
), and especially nice for partial matches like what was described above.
Obviously this particular implementation has shortfalls, such as lackluster handling of deeply nested hashes, hashes with other data structures like arrays, etc. We could add diffing to make failures more readable, and better error messages would probably be welcome as well. RSpec supports all these things out of the box.
Be sure to check out the documentation for custom matchers. If you’d like to play around with a slightly larger example above, feel free to check out the accompanying GitHub repo.
That’s all for now. Thanks for reading!