Simplifying Tests by Extracting Side-Effects

Joël Quenneville

Functional programming gives us a mental model that can be helpful when looking at unit testing our object-oriented code. In particular, it gives us two terms:

  1. Pure functions - these are deterministic methods with explicit inputs and outputs (e.g. no implicit dependency on the system clock). This kind of method is the easiest to test.
  2. Side-effects - Any source of inputs or outputs for a method that are not part of the arguments or return value. Examples include calls to Time.now, or writing the output to a file. These types of methods are non-deterministic, which makes them trickier to test and usually involves some mocking or stubbing.

Test-driven development, object-oriented design, and functional programming converge on some similar ideas. One of these is turning our side-effectful methods into pure functions.

Passing in the side-effect

Pain in your tests should push you to change the design of objects being tested

– TDD mantra

Consider the following innocent-looking piece of code. It defines a token that expires 24 hours after creation.

class Token
  attr_reader :created_at

  def initialize
    @created_at = Time.now
  end

  def expires_at
    created_at + 24.hours
  end
end

This is actually harder to test than might first be apparent because the dependency on Time.now makes the code non-deterministic. We are going to need a way to stub the system clock. Either manually, with a gem, or with framework helpers. If we want to test behavior in the past or future, we’ll need some form of time-travel.

it "sets expiry in 24 hours" do
  # this could also be done with a time-travelling helper
  # I'm showing the explicit stub here for illustrative purposes
  allow(Time).to receive(:now).and_return(Time.new(2020, 11, 2))
  token = Token.new(created_at: created_at)

  expect(token.expires_at).to eq Time.new(2020, 11, 3)
end

So how might we change a design to avoid this awkward stubbing? Looking at the problem through the functional programming mental model, we might rephrase the question as “how can we turn this side-effectful method into a pure one?”.

The classic solution to this problem is to turn implicit dependencies into explicit ones by passing them in as arguments.

The dependency on Time.now in the constructor of our Token object is a side-effect. Consider the following small change to the constructor: instead of calculating Time.now it takes the created at time as an argument.

def initialize(created_at:)
  @created_at = created_at
end

There is no longer any dependency on the system clock. The Token constructor and #expires_at methods are now pure functions. This allows us to write tests without any stubbing:

it "sets expiry in 24 hours" do
  created_at = Time.new(2020, 11, 2)
  token = Token.new(created_at: created_at)

  expect(token.expires_at).to eq Time.new(2020, 11, 3)
end

As bonus, we are now able to create tokens that started in the past or that will start in the future.

Passing in IO

“Passing in the side-effect” can be used in all sorts of situations. I’m particularly fond of this technique when dealing with IO. For example, instead of making raw calls to Kernel#puts, I’ll pass in my own IO object:

class MyTask
  def initialize(io)
    @io = io
  end

  def write_lines(lines)
    @io.puts lines.joins("\n")
  end
end

This is much easier to test than attempting to stub $stdout because we can pass in our own IO object and assert directly on it:

it "puts each line to the given IO" do
  io = StringIO.new
  task = MyTask.new(io)

  task.write_lines ["1", "2", "3"]

  expect(io.rewind.read).to eq("1\n2\n3\n")
end

As a bonus, MyTask can now write to stdout, a file, or any other IO we pass it.

Isolating side-effects

This is well and good for simple side-effects like time and IO, but what about code where side-effects are all over the code such as when integrating a third-party API?

This is where we get to put our OO designer hats on and try to separate code that is side-effectful (in this case, network code) from everything else. We don’t always have to pull the side-effects out. Sometime we can do the opposite and find some behavior that can be pulled into a “pure” object. For example if we’re sending JSON to this API, it might make sense to extract a serializer object. We might also pull out some lightweight value objects out. Both of these have no side-effects and can be easily unit-tested.

That’s a win but we can do better. Notice how the reason this scenario is more difficult is because side effects are all over the code? This is a code smell. It’s a violation of the single responsibility principle, and making changes to it will likely cause shotgun surgery.

When I test-drive API integration code, I often find myself breaking out two classes:

  1. A Driver that isolates all the network code in one place. This is particularly nice if I have to make multiple network calls for one task like conditionally refreshing an OAuth token before calling an endpoint.
  2. A Client that handles the actual business logic.

By coalescing all the network code into a single Driver object, I’ve extracted all the side-effects into one place which allows me to pass them into the Client. Now my client tests don’t need to stub the network anymore.

Since Ruby is a duck-typed language, I can pass in a test double that pretends to be a driver. This allows me to pass in a “driver” that always succeeds, always fails, returns a particular payload, or whatever I need for the particular scenario that I’m testing.

So what happens if you take this to an extreme? Eventually, all your side-effects get pushed to the edges of your system. Gary Bernhardt explores this in his classic “Functional Core, Imperative Shell”. You may hear this pattern referred to as “bring your own IO” in other programming communities, as can be seen in the Python h11 library.

Same problem, multiple perspectives

If you come from a test-driven development background, modifying your design by extracting objects to avoid awkward stubs may already be part of your practice.

If you do a lot of object-oriented design, you may find many design principles also lead you to the same place. Coalescing similar code together and extracting objects around them is the single responsibility principle. Passing dependencies in as arguments rather than hard-coding them is dependency injection. Even the concept of isolating your side-effects from your pure code may sound familiar as command-query separation.

This is because TDD, OO design, and FP are all looking to create code with low coupling. Tight coupling is what makes tests hard to write, and side-effects/implicit dependencies are a particularly pernicious form of coupling. Low coupling makes it much easier to test in isolation.

Having different mental models in your toolbox allows you to analyze the same problem from multiple perspectives. Each can give you different insights to better understand the issue at hand and help you come to the best solution.