Why I write unit tests

Unit testing is a software testing technique that involves testing individual units of source code to ensure that they work as expected. Unit tests are automated tests that execute small pieces of code and validate their behaviour. These tests help developers catch defects early in the development cycle before they become more difficult and costly to fix.

There are two main approaches to writing unit tests; code-first and test-first. In code-first, a developer writes the functional component of the application before writing unit tests for that component. This approach is popular with inexperienced developers as well as legacy code that has no unit tests in place. In both cases, writing unit tests as an afterthought is a difficult task. This is because it usually results in rewriting (refactoring) some of the code under test.

The test-first approach is where unit tests are written before actual functional code. Inexperienced developers struggle with this approach as it requires different thinking; writing unit tests for code that does not exist. With the test-first approach, no code needs refactoring in order to be testable. Unit tests written in advance also provide guardrails for functional code. It forces a developer to write code that is loosely coupled, maintainable and modular. It is, however, not a substitute for well-thought-out design patterns and clean code principles.

In my view, the benefits of unit testing cannot be overstated. There are arguments against unit testing mostly centred around resource management: time spent writing unit tests can be spent writing functional code. However, the opposite is true: unit testing saves you a ton of time in the long run! Some of the benefits of unit testing include:

  1. Reduced bugs: One of the primary benefits of unit testing is the ability to detect and fix bugs early in the development process. By testing individual units of code in isolation, developers can identify issues before they are integrated into the larger codebase, making them easier to resolve. Imagine refactoring code that has no unit tests. For code with any meaningful complexity, It is hard in this scenario to be sure that refactoring has not introduced any bugs. With unit tests in place, you only have to make sure that the tests pass before and after refactoring.

  2. Better code quality: Unit testing forces developers to write better code by ensuring that each unit of code not only behaves as expected but is also structured in a testable way. This leads to higher-quality code that is easier to maintain, modify, and extend. As pointed out earlier, a unit test acts as a guardrail for your functional code not just functionally but structurally as well. It will force the developer to write smaller, single-purpose methods and use abstractions rather than relying on implementations.

  3. Faster development: Unit testing helps speed up the development process by identifying bugs early and preventing them from becoming more significant issues later on. This allows developers to focus on writing new code and improving existing code rather than fixing bugs. Unit tests also mean that less time is spent on manually retesting existing code when new code is introduced.

  4. Simplified debugging: When unit tests fail, developers can easily pinpoint the location of the bug and fix it quickly. This helps simplify the debugging process and makes it easier to maintain the codebase.

Unit tests are not a silver bullet to bad coding practices or eliminating bugs in code bases. They are an effective tool when properly employed with the right mindset. When writing unit tests becomes about code coverage numbers, then they are not being employed effectively. Let's look at some of the downsides of unit tests:

  1. Over-reliance on unit tests: It's easy to fall into the trap of relying solely on unit tests to catch bugs, leading to a false sense of security. Unit tests can only test individual units of code, and bugs can still exist in the larger codebase that is not caught by unit tests. This is why integration tests must be part of a test suit for any business application.

  2. Time-consuming: Writing unit tests can be time-consuming, especially for large codebases. Developers must strike a balance between writing enough tests to ensure adequate code coverage while still delivering code on time. This is especially true in legacy code bases. In greenfield applications, it is easier to maintain good test coverage with less effort compared to legacy applications.

  3. False positives and negatives: Sometimes, unit tests can fail for reasons other than bugs, such as environmental issues or test setup problems. These false negatives can lead to wasted time and effort and make it harder to identify real bugs. Sufficient effort needs to be spent to remediate the cause of false negatives and ensure that tests are reliable and consistent.

I want to end this article with an example of a unit test using the test-first approach. This example is trivial compared to what you will encounter in practice. However, it demonstrates the thought process when writing a unit test before actual code.

Let's say you are asked to implement a class that calculates the sum of two numbers. The first question will be what name makes sense for this class? I will call it Calculator. The second question is what methods will the class have? Since we have only been given one functional requirement, i.e. calculate the sum of two numbers, I will have a single method called Add. I expect this method to take 2 parameters i.e. the numbers to be added and return the result. There are additional questions you must ask yourself here such as what is the number type (int, double, decimal etc.), how will parameters be passed, what is the return type etc. Below is an example of how we can write a test for this imaginary class and method using the NUnit testing framework:

[TestFixture]
public class CalculatorTests
{
    [Test]
    public void TestAddition()
    {
        Calculator calculator = new Calculator();
        long result = calculator.Add(2, 3);
        Assert.AreEqual(result, 5);
    }
}

In this example, we're creating a new instance of the Calculator (class under test) class and calling its Add method with two arguments. We're then using the Assert.AreEqual method to check that the result of the Add method is equal to the expected value of 5. We do not care how Add is performing the actual addition of the two numbers. All we care about is that given inputs of 2 and 3, we should get 5 as the output. We can then go ahead and create our Calculator class with the Add method. When our unit test starts passing then we know our work is done.

In conclusion, unit testing is a critical aspect of software development that helps catch bugs early and improve code quality. In the long term, it speeds up development by enabling developers to only focus on new functionality and not manually retesting existing code. However, developers should be aware of the pitfalls of unit testing, such as false positives/negatives and over-reliance on unit tests, to ensure that they are getting the most out of their testing efforts.