How to write unit testable code and how it improves code quality

Cem Tüver
Onfido Product and Tech
7 min readAug 1, 2022

I have been working on an Android library project which didn’t have any unit tests implemented. It is quite a small library, with only five classes. However, some of those classes have vital roles and directly impact the library’s output, such as generating file metadata depending on another file by doing some mathematical operations. Changing a single line or even a single integer value might break the whole functionality. I was like:

Bubbles Girl meme with text “test test test”
Bubbles Girl, origin unknown

I wanted to implement unit tests for the library to mitigate the risk of breaking existing functionalities while adding new ones or fixing bugs. At first, it looked like an “easy job” since there was a limited number of classes, but once I started writing unit tests, I realised that the code was not testable.

In this article, you will read my experience on how I managed to refactor that library code so that it became testable and what my learnings were on this journey.

Prerequisite

This article doesn’t focus on a specific platform or a programming language but on concepts. However, I believe it is always helpful to provide some examples. That is why I added example code pieces in Kotlin. I tried to keep them as simple as possible, and I am pretty sure they are easy to understand, whether you are familiar with Kotlin or not.

Why unit testing?

Generally, testing helps software companies deliver quality software and reduce development costs. Specifically, unit testing helps to find the bugs easily in every stage of development in a fast and reliable way. Yet the most valuable benefit of writing unit tests is that it forces developers to write testable code.

Unit Testable Code

We usually don’t realise how bad the code is until we start writing unit tests. Even though the code is easy to understand, we might overlook its cyclomatic complexity as it might hide its dependencies. As a result, we fail to realise how complex the code is.

Writing unit tests forces us to refactor our code to have single-responsibility and dependency inversion. These are already two of the five SOLID principles. That is why having unit testable code makes writing tests painless and makes the codebase less complex, easier to understand and more maintainable.

Deterministic Code

Writing unit tests is pretty straightforward, but testing non-deterministic code is impractical. Functions should produce the same output for the same input every time so that the unit tests can test their behaviour correctly. Otherwise, you would have either no unit tests or non-deterministic ones, which are not ideal as tests should be deterministic by definition.

Let’s take a look at the example code below. “shuffleQueue” function takes one argument, a one-dimensional integer array and returns a shuffled version.

Testing it should be pretty straightforward, right? Not. Because each run would produce a different result for the same inputs. However, this is something expected. Given the shuffling nature, the function should create kind of random results. So then, how can you test it? The first thing that comes to mind is checking whether the output differs from the input, as shown below.

However, we don’t have control over the implementation of the “IntArray.shuffle” function, and technically the output might be the same as the input at a random time. This makes the test unstable and non-deterministic. Even worse, you can’t ensure that the “shuffleQueue” function shuffles the queue and does not just return a static value. You can try different approaches to unit test this function, such as running it for million times and then checking whether the results differ or not. It might work, but the test execution would take too much time. The other, probably better approach is delegating the non-deterministic part of the code to another component, which I will explain in the following sections.

Single-Responsibility

The process of unit testing is quite simple. First, you set the preconditions; then invoke the tested unit; last, check the generated output against the expected one. It sounds simple; however, complex functions require complex preconditions and inputs, and generate complex results. As a result, the complexity of the unit test grows exponentially as the tested unit’s complexity grows because unit tests have to check every possible use case.

There are many ways to determine a function’s complexity, such as nested block count, lines of code or cyclomatic complexity. Small functions with fewer lines of code usually result in a cleaner and more understandable codebase. However, it is possible to make a small function very complex by poisoning it with multiple responsibilities. And worse, the code gets unstable when it has multiple responsibilities because updating a single functionality might break another one.

Let’s imagine writing unit tests for “logToFile” function below. You need to test three different functionalities:

  • Log line generation
  • Opening the correct log file
  • Writing the log line into the file

While the output of the function changes regarding the inputs, the unit tests for the log function should test three different cases:

  • With zero args
  • With only one arg
  • With multiple args

However, for each case, the unit tests should also check if the function generates the correct log line, if it writes the log line into the correct log file, and if it appends the log line at the end of the log file. Imagine getting a change request about updating the timestamp in the log lines to include the minutes and seconds. The function changes as shown below.

The unit tests would now fail and should be updated to check the new format of the log lines. However, not one but three unit tests should be updated as we need three unit tests to test the log function, which costs time and money. This is an excellent example of the functions with multiple functionalities requiring more complex unit tests.

Nevertheless, this is not the biggest problem. As you might have noticed, changing the timestamp broke the log file names. Instead of creating a log file per hour, the function now creates a log file per second. If the existing unit tests don’t check the file names, your software will have an evil bug. If they check, you must also update those parts, which means spending more time and money.

A better way to reimplement this log function and make it easily testable is by distributing these three functionalities into different components; one for generating a log line, one for getting the log file, and one for appending the line into the file. So the function becomes as shown below.

We can now test the new implementation of the function with only one unit test. The unit test should check:

  • If the function is passing its arguments to “logLineGenerator.generate” function to generate the log line
  • If it calls “logFileStorage.getCurrentLogFile” to get the current log file
  • If it calls “fileWriter.appendText” with the current log file and the generated log line.

As we revisit the requirement of updating the timestamp in the log lines, it would be enough to update “logLineGenerator” class and its unit tests. There is no need to update the actual log function. Moreover, there is no risk of breaking another functionality while updating it.

Dependency Inversion

Let’s recall our log function. The latest version delegates some functionality to other components, such as generating the log line. We talked about how testing this function would be easy as we only need to check if the log function calls the others correctly. However, there is a technical challenge. We don’t know how the log function accesses “logLineGenerator”, “logFileStorage” and “fileWriter”. Moreover, we don’t have any control over them, so we can’t easily test whether the log function calls the other components correctly or not.

The class above is one of the possible implementations of the log class. It creates instances of subcomponents, and the log function uses them. However, if you want to test this function, there is no way to interact with those subcomponents. You can’t check whether the log function invokes the subcomponents correctly or not. Luckily, we can break the strong relation between the log class and its subcomponents by moving the instance creation somewhere else and sharing those instances with the log class.

The new version of the log class allows us to modify the implementation of subcomponents and inspect them effortlessly while running unit tests. We can mock subcomponents or replace them with the test implementations which produce predefined outputs. However, some of the subcomponents might be closed for inheritance, so you can’t create a test implementation class replacement. With the help of Dependency Inversion, we can overcome this problem. Dependency Inversion is one of the SOLID principles stating that modules shouldn’t depend on implementations but should depend on abstractions. Having abstraction lets us create whatever implementation we want for any subcomponent and use those implementations for unit tests.

Finally, the test for the log class is now all clear. It injects the test implementations of subcomponents and checks if the log function calls them with the correct parameters.

As the new log function requires us to provide the subcomponents, we need a pattern to do it conveniently in our code. Some software patterns, frameworks and libraries provide easy-to-use APIs to apply dependency inversion. For example, you can look up dependency injection and service locator patterns.

Sum up

The limitations of unit testing force us to write better code as we saw that making our code unit testable makes it easier to understand, more flexible, and more maintainable. That is why I believe in always thinking about testability and asking the question, “How can I test this?” while coding or following a test-centric process such as test-driven development helps us to have clear visibility of the code complexity and reduce it.

--

--