Benefits of unit testing with concrete examples

I wasn't well aware of the importance of unit tests before seeing them actually working at real systems. In this post, I'm going to demonstrate some benefits along with concrete examples which would persuade my past self about how crucial unit tests are.

I have tried to find simple but also real-life-relating examples and used Java for the code snippets.

1. Guarding against breaking changes. Imagine you are using a library to find and parse all the timestamps in a raw text.

/** Returns all the timestamps in the given text, sorted by oldest first. */
@Beta
public List<Timestamp> getTimestamps(String text);

Note that the function is marked with @Beta which means it is subject to breaking changes with the upcoming releases.

Now assume that you want to make sure this function will always return the timestamps in oldest-first order. To achieve this, you can perform another sort on the returned list. However, this will be unnecessary for the moment, as the function already returns in oldest-first order.

Therefore, a better solution would be writing a unit test that assumes oldest-first order of elements and fails if this is not the case. This way, you will be able to confidently take new releases of the library, knowing a breaking change would fail your unit test and the broken code won't make its way through the CI.

2. Working with error-prone code patterns easier. Some patterns are error-prone by their nature so they make it harder to have confidence about the correctness of the code.

A good example is index access functions (the root causes of infamous off-by-one errors). Imagine you want to split a list into two from a specific index where the index should be included in the first list.

List first = list.subList(0, index + 1);
List second = list.subList(index + 1, list.size());

Here, writing a unit test that checks a couple of cases is a very quick way of making yourself confident about the correctness of the split.

Another good example is implementing a custom object comparison function. In Java, sometimes it's not super obvious what compareTo should return in order to achieve the correct sorting order that is required by the business logic.

@Override
public int compareTo(Student other) {
    return this.grade - other.grade;
}

Again, a small unit test will help to verify if the custom sort is working as expected.

3. Improving the design implicitly. If a component cannot be tested in a convenient way, this usually points out the bad design of the component.

Consider the following design. It is for a simple system that provides the latest logins by querying a database.

public class Main {
    public static void main(String[] args) {
        ExecutorServicePool pool = ExecutorUtils.newPool();
        LoginManager manager = new LoginManager(pool);
    }
}

public class LoginManager {
    private final DatabaseConnection dbConnection;

    public LoginManager(ExecutorServicePool pool) {
        ExecutorService executor = pool.getExecutor();
        dbConnection = DatabaseUtils.connect(executor);
    }
    
    public Login getLatestLogin() {
        return dbConnection.getLatestLogin().findFirst();
    }
}

If we were to write unit tests for this system, we would realize that LoginManager class is not easily testable. It takes a pool as its only input and the getLatestLogin() behavior is not directly dependent on the given pool input. Therefore we cannot mock the executor pool behavior directly, which means testing is not very convenient for LoginManager.

In order to make this class easily unit-testable, we would require a mockable database connection instance in LoginManager as follows.

public class Main {
    public static void main(String[] args) {
        ExecutorService executor = pool.getExecutor();
        DatabaseConnection dbConnection = DatabaseUtils.connect(executor);
        
        LoginManager manager = new LoginManager(database);
    }
}

public class LoginManager {
    private final DatabaseConnection dbConnection;

    public LoginManager(DatabaseConnection dbConnection) {
        this.dbConnection = dbConnection;
    }
    
    public Login getLatestLogin() {
        return database.getLogins().findFirst();
    }
}

In this version, getLatestLogin() behavior is directly dependent on the dbConnection input of LoginManager. A unit test can now be conveniently written by passing a mock dbConnection instance.

It can be argued that the first version of the system did not have a good design in terms of adding new components. For example, if we are to introduce a SignupManager, ideally we would want to use the same database connection that LoginManager uses. This would require a refactor which lifts the creation of the connection instance to the upper class (which is Main). This is exactly the same refactor that our testing introduced. In short, refactoring our system to be testable also improved the design to be extendable in an early stage of development.

4. Acting as documentation. Unit tests are precise descriptions of how a component should be used and how it behaves.

Imagine you have a function that schedules a periodic task.

public void schedulePeriodicTask(Runnable task, Duration period);

The writer of this function might forget to document (or choose to skip documenting) some details of the behavior such as "Does the first run of the task happen just after the scheduling, or after the first period passes?". In those cases, people should be able to take a quick peek at the unit test and understand the behavior.

@Test
public void testSchedulePeriodicTask() {
    List<Instant> runInstants = new ArrayList<>();
    Runnable insertionTask = () -> runInstants.add(Instant.now());
    
    scheduler.schedulePeriodicTask(insertionTask, Duration.ofMillis(100));
    
    Instant startInstant = Instant.now();
    Thread.sleep(250);

    assertThat(runInstants).containsExactly(
        startInstant.plusMillis(100),
        startInstant.plusMillis(200)
    );
}

A unit test such as the one above will be able to help people who are trying to understand the behavior of schedulePeriodicTask() function. The assertion part clearly indicates that the function is expected to run the task only after an initial period of 100 ms is passed (i.e. it doesn't run at the time it gets scheduled).