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:
- 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.
- 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.
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
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
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
#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 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
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:
Driverthat 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.
Clientthat 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.
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.