Writing Testable Code

SkySpecs Engineering
6 min readApr 29, 2021

Matt Fisher, Principal JavaScript Engineer, SkySpecs

One of the things we have discussed at length as a team is how we test our code. Automation vs manual QA. Unit vs integration tests. Static typing as “testing”. These are all conversations that have come up. But one of the more interesting but nebulous questions we have asked as a team is “how do we write testable code?” This is a great question, but it leads to an even more basic question. What IS testable code? Are there key features or objective measures we can use to say whether or not the code we are writing is “testable”?

On one level, all code is testable in that you can fire up the application and see if it works. So for the sake of argument let’s say we mean “automatically testable”. This leaves us with, really, 2 types of tests to consider. Integration tests and unit tests. A unit test is meant to test precise pieces of code in isolation, while integration tests are meant to test the interactions between multiple systems. I am going to focus on“testability” in the context of unit tests in this post.

Features of (unit) testable code

  1. Single Responsibility
  2. Deterministic
  3. Idempotent
  4. Free of Side Effects (as much as possible)

Single Responsibility

This may seem obvious, but in order to test something you must know what it is supposed to do, and the more complex that thing is, the harder it is to test all cases. One simple way around this is to make sure your functions only try to do one thing, and that they only do the thing they say they are going to try to do.

At some point a function will need to compose or combine these single-responsibility functions, but if each step in the process is written as a single-responsibility function, then each step can be tested in isolation. Let’s say you have a reducer function that reads from a database, transforms the results and returns them. Here would be a non-single-responsibility way of writing that code:

const getFoos = ids => {
const stmt = 'select * from db.foo where id in ($1)';
const foos = await db.query(stmt, ids);
return foos.map(foo => {
//...do some transform/business logic here
});
}

Thats pretty decent code, but there is no way to test your transform logic without mocking a database call. Conversely, there is no way to test the raw results of your database call without reverse engineering your transform logic. If you were to change this into a set of single-responsibility functions, then each piece could be tested cleanly in isolation.

const loadFoos = async ids => db.query(stmt, ids);
const transformFoos = foos => foos.map(transformFoo);
const transformFoo = foo => { /*... do some transform ...*/}
const getFoos = ids => {
const foos = await loadFoos(ids);
const transformedFoos = transformFoos(foos);
};

Now you can test loading, transforming a list, and even transforming a single item individually. If you want, you can test the composition of these functions by writing a test around getFoos as well.

While this is a very naive example, its principles only become more valuable as you work with longer and more complicated code. Imagine a scenario where the original non-single-responsibility function stretched for another 50 lines of code and included 5 more discrete steps in its transformation logic. It becomes a black box where you cannot test anything but the end result. By breaking the different discrete pieces of logic into single-responsibility functions, writing tests becomes both simpler and more precise.

Deterministic

Deterministic code is code that always gives the same output for the same input. It doesn’t depend on hidden inputs: values that affect the result but are not controlled by the function caller. Non-deterministic code is much more difficult to write unit tests for, as it requires mocking the state that the system needs to be in when the function is called. Or in the worst case scenario, when the state is not within the tester’s control, it is simply impossible to 100% control and predict what result you will get. This is a key cause of intermittently failing tests.

Global state within a project is a major cause of non-determinism. If the value of a variable that I don’t see when calling a function can affect the output, testing becomes very challenging. It means more setup/teardown, and it means that multiple tests can affect one another if they aren’t isolated. The simplest way to avoid this at the function level is simply to pass in the values that you need, rather than internally reading from global state.

Any kind of I/O is inherently non-deterministic as you cannot guarantee a connection or the state of data from an external system. Because of this, it is impossible to make real-world code 100% deterministic, but we can isolate I/O as much as possible so any code that can be deterministic is deterministic.

// non-deterministic
const makeFoo = (name) => {
const foo = { name, insertedAt: new Date() }
return foo;
};
// deterministic
const makeFoo2 = ( name, insertedAt) => {
const foo = { name, insertedAt};
return foo;
}

Again, kind of a silly example, but the first one is impossible to unit test completely, as the insertedAt field is non-deterministic.

Idempotent

This is similar to the above, so we will not belabor the point, but idempotent code will result in the same state no matter how many times it is called with the same input. Not everything can be idempotent. For example a POST in a restful API is not idempotent because it will create an object the first time and then return an error each time after that. But when we can achieve idempotence, it makes testing much simpler.

Side-Effect Free

Going back to our first point, single-responsibility, a side effect would be anything that affects state that is NOT directly related to that single responsibility. (Pedantically, a side effect is ANY change in state, but for the sake of testing, I am aiming more at unintended side effects). It could be changing a global counter, writing to an event stream, or any number of other behind-the-scenes interactions that aren’t directly related to your function’s output. These kinds of side effects nearly always violate one or more of the principles we have discussed above. Increment a counter? Not idempotent or deterministic. Write to a DB table on the side? Not single-purpose or idempotent.

Once again, we cannot avoid side effects completely, but we can minimize and isolate them. At the very least we need to be aware that we are putting them in our code so we can choose when it is appropriate and account for them.

// yes this uses global state,
// but it is just to show a simple example of a side effect!
let biggestBar = 0;
// side effects
const calculateBar = (foo1, foo2) => {
const bar = foo1 * foo2;
if ( bar > biggestBar) biggestBar = bar;
return bar;
}
// no side effects
const calcBar2 = (foo1, foo2) => foo1 * foo2;
const bar = calcBar2(f1, f2);
biggestBar = Math.max(biggestBar, bar);

There is no reason that calculating a value should affect an outside value. If that is important to you it can be its own step in the process. Think this looks silly and nobody would do this? I have seen production code that exported a file to an FTP server inside a toString() function. Talk about a side effect!

Some helpful heuristics

These are not laws, but are some guidelines that encourage more testable code:

  • Limit the size of files and functions - A function should fit on a single screen & a file should not be more than 100 lines of code
  • Don’t nest logic - If you have a loop or map, encapsulate the internal logic in a function
  • Isolate I/O in their own functions - All this function should do is make a call and pass back the result
  • Pass in everything you need in a function - Closures are really helpful for this
  • When possible, compose logic rather than nesting it
  • Don’t use global state - Just don’t
  • Ask yourself, can I make this function smaller? Can I break a chunk of it into its own function?
  • Avoid mutating state as much as possible - Instead of mutating and returning an input, copy and mutate the copy

Conclusion

This is not an exhaustive list of “the things that will make your code testable”, and none of these things are hard and fast rules that you should follow 100% of the time. But I believe that if you were to take these 4 ideas and apply them, either using some of the above heuristics or patterns of your own, it would immediately improve your ability to write good clean unit tests on just about any code base, whether it be a React front end, a database layer, or a GraphQL API. And we all could benefit from more testable code, right?

--

--