Get rid of mock maintenance with full fledged in-memory fakes

6 minute read

Last week’s post was about how hexagonal architecture results in fast, mock-free tests around your core domain. Unfortunately, that does not remove all mocks, yet it groups them in the same, less critical, zone. In last week’s code sample, this was the controller. I concluded that at least, this was easier to manage. Let’s see how.

Hand written 'In-memory fake' with memory replaced by a RAM board

This is the 7th post in a series about avoiding mocks. If you haven’t, you might start from the beginning.

Mock concentration

Let’s get back to the last post’s code sample. As a reminder, it’s a very basic TODO app built on Rails. I extracted the domain part, the tasks, in a core domain area. This allowed to push all mocks out of this section. A consequence though, is that all mocks gathered in the controller test. Here is the controller code :


require 'core/task'
require 'infrastructure/task_repo'

class TasksController < ApplicationController
 before_action :set_task, only: [:show, :edit, :update, :destroy]

 # GET /tasks
 def index
   @tasks = Infrastructure::TaskRepo.all
 end

 # GET /tasks/1
 def show
 end

 # GET /tasks/new
 def new
   @task = Core::Task.new
 end

 # GET /tasks/1/edit
 def edit
 end

 # POST /tasks
 def create
   begin
     @task = Core::Task.new(task_params)
     Infrastructure::TaskRepo.save(@task)

     redirect_to task_url(@task.db_id), notice: 'Task was successfully created.'

   rescue ArgumentError
     render :new
   end
 end

 # PATCH/PUT /tasks/1
 def update
   begin
     @task.update(task_params)
     Infrastructure::TaskRepo.save(@task)

     redirect_to task_url(@task.db_id), notice: 'Task was successfully updated.'

   rescue ArgumentError
     render :edit
   end
 end

 # DELETE /tasks/1
 def destroy
   Infrastructure::TaskRepo.delete(@task)
   redirect_to tasks_url, notice: 'Task was successfully destroyed.'
 end

 private
   def set_task
     @task = Infrastructure::TaskRepo.load(params[:id])
     @task.notify_when_done do |task|
       TwitterClient::Client.update(task.description)
     end
   end

   # Never trust parameters from the scary internet, only allow the white list through.
   def task_params
     params.permit(:description, :done)
   end
end

The controller is now dealing both with the Twitter connection and the database. This is visible in the controller test :


require 'rails_helper'

RSpec.describe TasksController, type: :controller do

 before :each do
   allow(TwitterClient::Client).to receive(:update)
 end

  # ...

 describe "PUT #update" do
   context "with valid params" do
     let(:new_attributes) {
       {done: true}
     }

     it "updates the requested task" do
       task = Task.create! valid_attributes
       put :update, params: new_attributes.merge(id: task.to_param)
       task.reload
       expect(task).to be_done
     end

     it "tweets about completed tasks" do
       task = Task.create! valid_attributes

       expect(TwitterClient::Client).to receive(:update).with(task.description)

       put :update, params: {id: task.to_param, done: true}
     end

     it "redirects to the task" do
       task = Task.create! valid_attributes
       put :update, params: valid_attributes.merge(id: task.to_param)
       expect(response).to redirect_to(task_url(task.id))
     end
   end

   # ... 

  end
end

We need to stub out the twitter API for most tests. We are also still using a mock to verify that the tweet is sent. Finally, as we can see from the test execution times, we are still using the database in some tests.

Screen capture of the tests execution time

If the project grew large this would become an issue. Sadly, mocking is often the fix people jump on …

💡 Mocking is the unfortunate quick fix to slow tests.

From a mocking point of view, our current controller test can seem worse than before ! There’s something pretty effective we can do though !

In memory fakes

Instead of stubbing and mocking in every test, let’s write a full fledged in-memory fake that does the job we need. We could then install it once and for all, and forget about it !

Actually, this is nothing new. This is exactly what Rails provides out of the box with ActionMailer::Base.delivery_method = :test.

Here’s how we could do the same thing for our Twitter Client.

spec/rails_helper.rb

class FakeTwitterClient
 def initialize
   @tweets = []
 end

 attr_accessor :tweets

 def update(message)
   @tweets.push(message)
 end
end

RSpec.configure do |config|

  # ...
 config.before(:each) do
   stub_const("TwitterClient::Client", FakeTwitterClient.new)
 end
end
spec/controllers/tasks_controller_spec.rb

it "tweets about completed tasks" do
 task = Task.create! valid_attributes

 put :update, params: {id: task.to_param, done: true}

 expect(TwitterClient::Client.tweets).to include(task.description)
end

Simple isn’t it ?

Wait a sec …

There’s a catch though … How do we make sure that this fake is behaving the same way as the real thing ?

Let’s run the same tests on both ! We could mimic the twitter API in our fake, but that might not be a great idea. Do you remember the moto “Always wrap your 3rd parties” ? It takes all its meaning here, for 2 reasons.

The first is to make faking easier. We can build a minimal wrapper API that is just enough for our use. By keeping this interface small, we’ll make it a lot easier to fake.

The second reason is that we can write real integration tests on the 3rd party through this wrapper. They’d look like ordinary unit tests, except that they’d end up calling the real 3rd party in a sandbox. They are usually pretty slow, but as 3rd parties don’t change everyday, that’s ok. We can ensure up-front that integration will go well. As a bonus, we can be very fast to detect and contain changes to online services. (I’m looking at you Scrappers!)

Here is what it would look like for our Twitter client :

lib/infrastructure/twitter_client.rb

class FakeTwitterClient
 def initialize
   @tweets = []
 end

 attr_accessor :tweets

 def tweet(message)
   @tweets.push(message)
 end

 def search_tweets(text)
   @tweets.select {|tweet| tweet.include?(text) }
 end
end

class RealTwitterClient
 def initialize(&block)
   @client = Twitter::REST::Client.new(&block)
 end

 def tweet(message)
   @client.update(message)
 end

 def search_tweets(text)
   @client.search("from:test_user #{text}")
 end
end

As you can see, we renamed update to tweet in the wrapper. We’d have to update the calls accordingly. Let’s look at the tests.

spec/lib/Infrastructure/twitter_client_spec.rb

require 'rails_helper'
require 'infrastructure/twitter_client'
require 'securerandom'

RSpec.shared_examples "a twitter client" do |new_client_instance|
 let(:client) { new_client_instance }
 it "sends tweets" do
   token = SecureRandom.uuid
   message = "Philippe was here #{token}"
   client.tweet(message)

   expect(client.search_tweets(token)).to include(message)
 end
end

context FakeTwitterClient do
 it_behaves_like "a twitter client", FakeTwitterClient.new
end

context RealTwitterClient, integration: true, speed: :slow do
 it_behaves_like "a twitter client", (RealTwitterClient.new do |config|
   config.consumer_key        = "TEST_CONSUMER_KEY"
   config.consumer_secret     = "TEST_CONSUMER_SECRET"
   config.access_token        = "TEST_ACCESS_TOKEN"
   config.access_token_secret = "TEST_ACCESS_SECRET"
 end)
end

We had to add a search method to our interface for the sake of testing. This should remain “For testing only”. We’d also adapt the controller test to use this search_tweets method.

Let’s look at where we stand now. We’re injecting each mock only once. Tests are fast yet straightforward, almost as if they were testing the real thing. Doing so, we’ve split our system in cohesive parts and we’ve wrapped our 3rd parties. We’ve actually done a lot more than removing mocks ! Mocking really is a design smell.

💡 Merciless mock hunting will improve the design of your system !

Last word about implementation

Sometimes, this 3rd party wrapper can become pretty complicated. Try to reuse as much of it as possible between the real and the fake. For example, an ORM, like ActiveRecord for example, is a wrapper around the database. Reimplementing a fake ORM would be real challenge. We’re far better plugin it on top of SQLite instead !

References

Smart people have already spoken and written about this subject. If you want to learn more, I recommend that you have a look at Aslak Hellesøy’s Testable Architecture talk. James Shore, the author of The Art of Agile Development, also wrote a pattern language called Testing Without Mock.

Next week

This was the 7th blog post in a series about how to avoid mocks. Hopefully, I’m reaching the end ! Next week’s post should be the last in series, and deal with a few remaining points. What to do when you really need a mock ? What about mocking and legacy code ?

Comments