You might have watched the great Sandi Metz talk about testing. If you haven’t already, go do it now, I’ll wait, it’s worth your 30 minutes. Long story short, you should unit-test only three kinds of things:
- If you’re making a query (think: pure function), only assert the result is what you expect. Specifically don’t mock dependencies if you don’t have to, and certainly don’t set expectations on certain methods on those dependencies to be called (this also applies to private methods, btw, as they can easily be refactored into public methods on a private dependency). This way you don’t couple your test with the implementation, making your suite less fragile to change.
- For command-type of messages, when you primarily expect some side effects (in short: some other part of your system needs to be notified), you mock out your dependency (just make sure you don’t mock what you don’t own) and set an expectation for it to receive a certain method call, probably with specific parameters.
- The last thing is for situations when you’re changing the object’s internal state (so in simple terms, it’s a command but with only local side effects). In this case, what you’d like to make is an assertion about the direct public side effects. Eg. you’re adding an item to the queue, you make sure it’s length changes.
I was testing this way with much success for years now. It’s a great framework that yields great results for me. But lately, an odd exception to those rules caught me by surprise.
Testing outgoing query messages
I recently wanted to reduce the response times of one of my API endpoints. As it turns out, there was both an expensive SQL query as well as an n+1 queries problem. The solutions were fairly simple: the expensive query was easily cacheable, and the rest were combined into a single one.
Testing this change turned out to be a challenge. The new requirements mandated that one of the repository methods was not to be called if the cache is fresh, while another one should be limited to one invocation (with an array of ids, instead of multiple calls with a single parameter each).
Suddenly, the implementation details of my query call became important. This meant that the first rule, only asserting the result of the query, could no longer apply. In fact, the test that checked the correctness of this method did not change, since the same results were expected for given inputs. The way of obtaining those results needed to change. And this implementation detail became a part of the contract, justifying this over-specified test.
Time is a side effect
Next time you test a query method think about the performance requirements and ask yourself if the time of execution isn’t a hidden side effect of your function. And if it is, make assertions and set expectations about the implementation details that would let you assume the best performance practices are applied.