This post is aimed at people just getting started with unit tests.

What are side effects?

Here is my attempt at an informal definition of side effects in computer programming: "Changes made by a function to the state of things outside of that function's scope."

What kinds of "things", you may ask? Here are some examples:

Non-local variables

let outOfScope = "This variable is non-local to makeSideEffect()";

function makeSideEffect() {
  outOfScope = "This operation is a side effect";
}

Arguments passed by reference

function makeSideEffect(argumentPassedByReference: object) {
  argumentPassedByReference.foo = "This operation is a side effect";
}

Input / Output

function makeSideEffect() {
  console.log("This operation is a side effect");
}

When getting started with unit testing and Test Driven Development, the first thing people generally start with is writing tests for simple pure functions.

Here is an example of a pure function

function add(x: number, y: number) {
  return x + y;
}

It is a pure function because 1) it has no side effects, 2) it is deterministic - its return value is the same for the same arguments - every time you call add with the argument 2 and 5 the return value will always be 7.

Testing a pure function is quite easy, and that alone is a good reason to try to make our functions pure, as much as possible. Here is how you could test the above add function.

test('when 2 and 5 are passed as arguments, add should return 7', () => {
  const sum = add(2, 5);
  expect(sum).toBe(7);
}

Easy, peasy. And if you are reading this article, there is a good chance you have testing pure functions down pat. You're probably here because you tried testing an impure function, something that looks more like this

function printSumToConsole(x: number, y: number) {
  console.log(x + y);
}

How can we make sure this function is doing its job properly, when there is no return value. Let's try anyway and see where we run into problems. First let's write our test case definition to make it clear what requirement our function should be fulfilling

test('when 2 and 5 are passed as arguments, printSumToConsole should print 7', () => {
  
}

Ok, so far so good. In fact, it looks a lot like our previous test. The only change is that we are no longer trying to determine that the function is returning the correct value, but rather that it is printing the correct value to the console.

Now let's think about this, how can we check that the function is printing the correct value to the console. One way would be to run the program and look at the screen to see if 7 is printed. That is what we call a manual test and that's not what we want. We want automated tests.

Another option would be to put a camera in front of the screen, write a program that interprets the camera feed, run our program, have the camera program interpret what appears on screen and determine if it was a 7 or not. It would be an automated test, but super complicated to setup, and certainly way bigger than a unit test.

So that leaves us with two viable options. The first one, involves extracting the logic of our function into a separate, pure and easily testable function. This shouldn't be too hard to do as we already implemented exactly the kind of function we need earlier with our add function.

function printSumToConsole(x: number, y: number) {
  console.log(add(x, y));
}

function add(x: number, y: number) {
  return x + y;
}

We already know how to test the add function, so that's taken care of. And in fact, it would be quite reasonable in this scenario to decide to only test the add function. Our printSumToConsole doesn't do much except wrap console.log(), all the real work happens in the add function - which we are testing - and in the log function, which is implemented by the fine folks at Google, Mozilla or wherever else your browser of choice comes from, and we can trust (can we though?!) that they are being very thorough with their testing.

Still, let's say that for some reason you still wanted to test the printSumToConsole function. Maybe because, like in the real world, it does a bit more than just wrap the console and actually has a bit of conditional logic in it. How would we go about doing that?

Let's think about this. We've already determined that we don't want to actually test that the correct value appears on the screen, because it would be way too long and complicated to do that. What would be the next best thing? Well, we've determined previously that it was reasonable to trust that console.log does it's job properly and prints whatever value we pass to it. So really, all we need to do is test that our printSumToConsole function calls console.log with the correct value to be printed.

But how do we check what is going on inside a function? Checking the output is easy, we did that with our add function; just save the result of the function call to a variable and check that the value of that variable is what we expected it to be. But we can't do that in this case... or can we? The answer is... kind of.

You can think of a pure function as a friendly function, an ally. It is helpful, open, transparent. Willing to give us information... in the form of a return value. An impure function, like our printSumToConsole, on the other hand, is the enemy. It is unhelpful, closed, opaque. It refuses to let us in, doesn't want us to know what is going on in there.

What do you do when you're at war and want to know what is going on in the enemy's camp? You send in a spy to collect information and report back. That is exactly what we need to do with our printSumToConsole function.

You may say, in the words of Isaiah, "Alas, whom shall I send, and who will go for us?". I already gave you the answer: a spy. That's right, in the world of computer programming, there is such a thing as a spy. A test spy. Let's write one.

First, like in any good movie involving spies, you can't just have your spy walk in through the front door. You need a good disguise. Often, you'll even see the spy knock an enemy guard on the head, steal its clothes, dump it in a haystack and infiltrate the camp impersonating the now naked and snoozing enemy guard. That's essentially what we are going to do. We need to knock console.log over the head, steal its clothes and walk into the camp... hum... function, impersonating it.

But we have a problem, at the moment, console.log never enters or exits the camp. It seems quite content to spend its entire day by the comfort of a warm fire while it lets every one else go out on patrol, that lazy bum. What can we do?

In order to steal it's place, we need access to console.log outside the camp. Thankfully, we do have some control over that, we are the almighty programmer after all. We can rewrite it's standing orders so that it no longer only hides inside the camp but actually has to go through the camp's gate. You're still with me, right, camp = function. Good.

As a reminder, here is what the enemy's camp looks like at the moment

function printSumToConsole(x: number, y: number) {
  console.log(x + y);
}

Let's change that

function printSumToConsole(x: number, y: number, terminal = console) {
  terminal.log(x + y);
}

Whoa, whoa, whoa, what just happened here?! Simple, we are now injecting the console as a function parameter instead of depending directly on it. In other words, we made it so that console has to go through the gate to get inside the camp.

Time to knock it on the head and steal the clothes off its back.

test('when 2 and 5 are passed as arguments, printSumToConsole should print 7', () => {
  const consoleSpy = {
    log(message) {
      consoleSpy.pocket = message;
    }
  }
}

As you can see, our spy has cleverly disguised itself in the form of the console object. It has the very log method we are so interested about. Except that our spy, instead of sending whatever message will be given to him by the unsuspecting enemy, will hide it in its pocket and report to us. Lets watch as our spy infiltrates the enemy camp and goes through with its mission.

test('when 2 and 5 are passed as arguments, printSumToConsole should print 7', () => {
  const consoleSpy = {
    log(message) {
      consoleSpy.pocket = message;
    }
  }
  
  printSumToConsole(2, 5, consoleSpy);
}

Muhahaha, mission accomplished! Now all we need to do is check that the message captured by our spy is what we expect it to be.

test('when 2 and 5 are passed as arguments, printSumToConsole should print 7', () => {
  const consoleSpy = {
    log(message) {
      consoleSpy.pocket = message;
    }
  }
  
  printSumToConsole(2, 5, consoleSpy);
  
  expect(consoleSpy.pocket).toBe(7);
}

And there you have it. A way to test that impure functions are doing their job.