The Proper Use of Ruby Mocks

Mocks are a powerful tool for testing Ruby code, but they're frequently misused.

Test mocks in Ruby are sort of strange. They’re not usually discussed when developers first are introduced to Ruby, usually via the Rails Way of building web applications. Nonetheless, testing is an ingrained part of Rails and Ruby culture in general, so eventually developers learn how to use them. The problem with this is that developers often learn how to use mocks but not why to use them. This post seeks to bridge that gap by discussing why and when to use mocks in a Ruby application.

The Proper Use of Mocks

In her influential book, Practical Objected-Oriented Design, An Agile Primer Using Ruby, Sandi Metz discusses the subject of mock usage in the final chapter of her book. Sandi states that outgoing messages, method invocations on other objects, can be understood as either commands or queries. She believes that outgoing messages which trigger no side-effects should not be tested. These messages are referred to as “queries”. On the other hand, she asserts that method invocations with side-effects should be verified by using mocks. These sorts of messages are called “commands”.

So what does this mean in practice? Imagine a situation in which we’re designing some code for assigning a user account to a league on a fasntasy sports site.

class League
  def assign(user:)
    assignment_audit = AssignmentAudit.new(user:, league: self)

    if assignment_audit.allows_assignment?
      user.invite_to(league: self)

      true
    else
      false
    end
  end
end

class AssignmentAudit
  RULES = [FreemiumLimiter, DuplicationDetector, SuspensionDetector]

  def initialize(user:, league:)
    @user = user
    @league = league
  end

  def allows_assignment?
    # Calls `evaluate` on elements of the RULES constant, returning `true` if all
    # evaluations return `true`.
  end
end

class User
  def invite_to(league:)
    # Logic for inviting a user to a league
  end
end

When calling League#assign, the method sends two outgoing messages. First it sends allows_assignment? to an instance of AssignmentAudit, and then it potentially sends invite_to to an instance of User. In this case, AssignmentAudit#allows_assignment? exclusively returns data to the method being called, making it a query. On the other hand, User#invite_to triggers side-effects relevant to the user receiving the message. It triggers invitations to be sent via whatever channels the application uses. When we test League, the tests for assign could look like this.

RSpec.describe League do
  describe '#assign' do
    subject { league.assign(user:) }

    let(:league) { build(:league) }

    context 'when the assignment audit passes' do
      let(:user) { build(:user) }

      before { allow(user).to receive(:invite_to) }

      it { is_expected.to be true }

      it 'sends invitations to the user' do
        subject

        expect(user).to have_received(:invite_to).with(league:)
      end
    end

    context 'when the assignment audit fails' do
      let(:user) { build(:user, :invalid_for_assignment) }

      it { is_expected.to be false }
    end
  end
end

Here we’ve followed Sandi Metz’ suggestion for testing outbound messages. The invocation of AssignmentAudit#allows_assignment? is not mocked, meaning it is neither verified nor is its response stubbed. We do however mock invite_to sent to the instance of User. This is a great example of how mocks can make practical improvements to test suites by isolating the object under test. User#invite_to could send invitations via email or push notification, but the consequences of that outbound message aren’t important to the implementation of League#assign. Consequently, if stakeholders ask us to add text message invitations in the future, we won’t have to modify our test for League#assign.

Misusing Mocks

So if mocking the outgoing message sent to the instance of User improved the isolation of the object under test, why wouldn’t we want to also mock the message sent to the instance of AssignmentAudit? Wouldn’t that make our test setup less onerous? Sandi Metz suggests we shouldn’t mock the outgoing message since it’s a query, but let’s look at what the practical implications would be.

Let’s say we did mock the message sent to the instance of AssignmentAudit. We might get specs that look like this.

RSpec.describe League do
  describe '#assign' do
    subject { league.assign(user:) }

    let(:user) { build(:user) }
    let(:league) { build(:league) }
    let(:audit_double) { instance_double(AssignmentAudit) }

    before { allow(AssignmentAudit).to receive(:new).and_return(audit_double) }

    context 'when the assignment audit passes' do
      before do
        allow(audit_double).to receive(:allows_assignment?).and_return(true)
        allow(user).to receive(:invite_to)
      end

      it { is_expected.to be true }

      # Omitting the mock expectation on `user` for brevity
    end

    context 'when the assignment audit fails' do
      before { allow(audit_double).to receive(:allows_assignment?).and_return(false) }

      it { is_expected.to be false }
    end
  end
end

With this mock, if the behavior of AssignmentAudit#allows_assignment? changes, we no longer need to modify our test data. On its face, that seems like a good thing. Unfortunately, it is not. In fact, the ability to use these regression tests to assert the behavior of activities like refactoring has now declined.

Consider the situation in which the rules that are used to audit league assignment become more complex. For instance, imagine that the site starts allowing public leagues, and there are new spam filter results which we want to persist to the database. Now AssignmentAudit#allows_assignment? returns an instance of a class with multiple boolean fields. This is a huge problem. AssignmentAudit#allows_assignment? will never return a falsey value now!

Unfortunately since we’ve stubbed AssignmentAudit#allows_assignment? to always return boolean values, our regression suite isn’t doing it’s job! We made a change to AssignmentAudit#allows_assignment? which breaks League#assign, but specs won’t fail like they should. We’ve reduced our ability to confidently refactor code by preventing our test suite from indicating when behavior has changed.

Now, let’s consider another situation. Perhaps we’ve identified a circumstance in which we can create a nice duck type in our application, but we need to change the interface of AssignmentAudit to make it work. So, we change allows_assignment? to the method name we need to satisfy the shared interface. Then we change all references to allows_assignment? in our app folder, and finally we run our regression suite. Uh oh. Every single class that depends on AssignmentAudit#allows_assignment? now has failing tests. This is counter-intuitive. We made an implementation-only refactoring of assign, but somehow the specs are failing. That’s not the behavior we want to see from our test suite, and it’s going to be time consuming and frustrating to go change all of the mocks. That frustration will be multiplied if you aren’t the person who wrote the tests in the first place, or if you wrote them a long time ago.

Keen readers might have noticed that the previous argument neglects an important fact. Command messages, which this article suggests should be mocked, would have the same problem. For instance, if we changed the interface of User so as to rename invite_to, we’d have to go change all of those mocks as well. That’s correct, although I still believe that these situations are different in nature. The behavior of League#assign is completely dependent on the value returned by AssignmentAudit#allows_assignment?. In order to accurately assess the behavior of League#assign, the value of AssignmentAudit#allows_assignemnt? must be accurate. The response of sending AssignmentAudit#allows_assignment? directly impacts the behavior of League#assign. In the case of User#invite_to, sending invite_to to an instance of User is the behavior of League#assign. Other than sending the outbound message, the results of User#invite_to have no further impact on League#assign.

A Decoupled Solution to Query Responses

Although it is a misuse of mocks to verify and stub queries, that doesn’t mean tests inherently have to be bound to the data setup required by their dependencies. In fact, there’s a way of inverting this relationship so that dependencies have to abide by an interface decided by their caller. This technique is called Dependency Injection, and it’s a complex and nuanced topic of its own which I’d like to address in a separate article.

Summary

Test mocks are a powerful tool which can help prevent a test file from including unrelated behavior. Mocks are a sharp knife though, and using them to stub query messages can lead to tests suites which don’t serve their purpose. It also adds a maintenance burden to the test suite which is cumbersome and unnecessary.