How Custom Assertion Matchers will keep mocks away
Custom assertions are a handy compromise alternative to mocks when we don’t have the time to refactor to a functional style.
With the 3X model, Kent Beck almost explicity tells us that we should take technical debt at the beginning of a feature or project. Using no-brainer CRUD frameworks, like Rails, is very effective to get the first version of a new product out. When the project hopefully becomes a success, many things happen. Tests become important, but also messy and slow. Unfortunately, in this fast expansion phase, we’re lacking time for refactoring. Even if moving to a more functional style would be the test thing to clean up our tests, we often end up adding mocks.
💡 When we are in a hurry, we get lured into mocking to workaround complicated tests
Custom assertions are a handy compromise. They avoid most of the downsides of mocks, while preparing for a potential refactoring. If you don’t know what custom assertions are, here is pseudo code that uses a custom assertion :
I already blogged about the benefits of Custom Assertion Matchers. Here I’m going to dive in their advantages against mocking.
This is the fifth post in a series about how to avoid mocks. If you haven’t yet, I recommend you to start from the beginning.
Why would we end up with mocks when we don’t have matchers ?
Let’s walkthrough a small story. Suppose we are building an e-commerce website. When someone passes an order, we want to notify the analytics service. Here is some very simple code for that.
class AnalyticsService def initialize @items =  end attr_reader :items def order_passed(customer, cart) cart.each do |item| @items.push(customer: customer, item: item) end end end class Order def initialize(customer, cart, analytics) @customer = customer @cart = cart @analytics = analytics end def pass # launch order processing and expedition @analytics.order_passed(@customer, @cart) end end describe 'Order' do it "notifies analytics service about passed orders" do cart = ["Pasta","Tomatoes"] analytics = AnalyticsService.new order = Order.new("Philippe", cart, analytics) order.pass expect(analytics.items).to include(customer: "Philippe", item: "Pasta") expect(analytics.items).to include(customer: "Philippe", item: "Tomatoes") end end
Let’s focus on the tests a bit. We first notice that the verification section is large and difficult to understand. Looking in more details, it knows too much about the internals of AnalyticsService. We had to make the items accessor public just for the sake of testing. The test even knows how the items are stored in a list of hashes. If we were to refactor this representation, we would have to change the tests as well.
We could argue that responsibility-wise, our test should only focus on Order. It makes sense for the test to use a mock to verify that the Order calls AnalyticsService as expected. Let’s see what this would look like.
it "notifies analytics service about passed orders" do cart = ["Pasta","Tomatoes"] analytics = AnalyticsService.new order = Order.new("Philippe", cart, analytics) expect(analytics).to receive(:order_passed).with("Philippe", cart) order.pass end
Sure, the test code is simpler. It’s also better according to good design principles. The only glitch is that we now have a mock in place with all the problems I described before.
This might not (yet) be a problem in our example but, for example, the mock ‘cuts’ the execution of the program. Suppose that someday, the Order starts expecting something from the AnalyticsService. We’d then need to ‘simulate’ the real behavior in our mock. This would make the test very hard to maintain.
Matchers to the rescue
Let’s see how a matcher could help us here. The idea is to improve on the first ‘state checking’ solution to make it better than the mock one. We’ll extract and isolate all the state checking code in a custom matcher. By factorizing the code in a single matcher, we’ll reduce duplication. The matcher remains too intimate with the object, but as it is now unique and well named, it’s less of a problem. Plus, as always with matchers, we improved readability.
RSpec::Matchers.define :have_been_notified_of_order do |customer, cart| match do |analytics| cart.each do |item| return false unless analytics.items.include?(customer: customer, item: item) end true end end describe 'Order' do it "notifies analytics service about passed orders" do cart = ["Pasta","Tomatoes"] analytics = AnalyticsService.new order = Order.new("Philippe", cart, analytics) order.pass expect(analytics).to have_been_notified_of_order("Philippe", cart) end end
Here is how we could summarize the pros and cons of each approach :
|👎 duplicated code||👎 duplicates the program behavior||❤️ customizable error messages|
|👎 breaks encapsulation||❤️ more readable|
|👎 intimacy with the asserted object|
|❤️ factorizes the assertion code|
As I said in the introduction, customer matchers are often an alternative to mocks when we don’t have the time for a better refactoring. Later down the road, you might find the time for this refactoring. Functional style and the “Tell, don’t ask!” principles are often the solution here.
In our example, a publish-subscribe pattern might do. A better design should fix the encapsulation problem of the matcher. Here again, the custom assertion matchers will help. In most cases, it will be enough to change the implementation of the matchers only.
💡 Custom assertion matchers make refactoring easier by factorizing test assertions.
Summary of small-scale techniques
I’m done with small scale mock avoiding techniques. To summarize, the first thing to do is to push for more and more immutable value objects. Not only does it help us to avoid mocks, but it will also provides many benefits for production code. Practices like Test Data Builders and Custom Assertion Matchers simplify dealing with Immutable Value Objects in tests. They also help to keep tests small and clean, which is also a great thing against mocks.
In the following posts, I’ll look into architecture scale techniques to avoid mocks. I’ll start with Hexagonal architecture.
Thanks to Dragan Stepanovic who’s comments brought me to update this post.
Leave a comment