Unit Testing Explained & Simplified

When developers write software, they split it up into smaller groups of coherent code (functions, classes, or modules). Developers also write unit tests to get immediate feedback and avoid regression bugs in the future, regardless of the type of project or platform.

Unit tests run a small piece of code, providing specific input and verifying the outcome. They're one of the most important building blocks of software testing and even software engineering in general.

There are many different strategies and tools to go about unit testing. In this post, we'll get into what unit testing is and how to do it. We'll also look at some examples of unit tests.

A quote about the idea of unit testing

What Is Unit Testing?

Unit testing is the practice of testing a unit of code. A unit test will set up prerequisites, execute the code with certain inputs, and then verify the outcome.

The idea behind unit testing is to test an isolated unit of code. There is ongoing discussion on how small this unit should be. But many teams don't have hard boundaries. The more components we add to the test, the less it's considered to be a unit test and the more we can categorize it as an integration test.

Developers are responsible for writing and executing unit tests. Usually, they write them in parallel to the development. This provides developers fast feedback without having to spin up and step through the application. A unit testing tool can automatically find and execute the tests. This also allows the team to run the unit tests on a build server.

Not only do unit tests provide fast feedback, but they also push developers to write clean code. Testable functions and classes will be more cohesive (i.e., they focus on performing a single task) and more loosely coupled (i.e., they're less affected by changes in other parts of the code).

Finally, an extensive suite of unit tests gives programmers more confidence to refactor their code base. If they accidentally break something, the unit tests will uncover the bug, which is something that would be harder with manual tests, especially if it's an edge case.

How to Perform Unit Testing

Unit testing is supposed to be simple. That doesn't mean it's always easy. But a good unit test consists of a small piece of code that reveals how the code is performing. Lengthy unit tests that are hard to read or understand are probably good candidates for end-to-end tests.

Preparing for Unit Testing

Before we can write a unit test, the feature specifications must be clear to the developer. That's not to say that the developers need a large analysis of how they must implement the feature in code, but there should be a shared understanding of what must be implemented exactly.

A skilled developer will then split up the problem into smaller problems that they can solve individually in code.

Any dependencies of the code should also be clear at this stage, although the team may uncover new dependencies as they go forward. The developer will need to abstract these dependencies, as it's not the unit test's task to test these or to test the integration.

Selecting a Unit Testing Framework

If you haven't chosen a unit testing framework yet, you can probably choose the framework that is most popular in your programming languages. For example, in .NET, NUnit and xUnit.net are great choices. Java has JUnit, PHP has PHPUnit, and JavaScript or NodeJS has mocha.

There are many alternatives in each programming language, so you should be able to find one that fits your preference.

Strategies for Unit Testing

When developers need to write new code and they'll be writing unit tests, there are basically two opposing strategies.

The test-driven development (TDD) approach is to have your tests drive the design of your code base. This means you write your tests first. The idea is to write a failing test first, then make it succeed by implementing the function correctly, and finally refactoring your code if necessary.

Test-driven development forces you to think about the public API of your function or class before you dive into the implementation. It makes you think about test cases, the happy flow, and the edge cases. The last step is also important because after adding several unit tests, your implementation might have room for improvement.

In the end, TDD gives you a modular code base with highly cohesive and loosely coupled components.

The other strategy is to write your production code first and then write the unit tests. This can sometimes be useful if the developer is still exploring the feasibility of a certain implementation. The implementation might still change several times, and writing unit tests would be a waste of time in that case.

A possible drawback could be that you end up with an implementation that's difficult to test with unit tests.

I recommend the TDD approach for developers who find it harder to write loosely coupled code. At a certain level of experience, the code-first approach can also be successful.

Techniques for Running Tests

As we mentioned, unit tests should test a small unit of code. This means it should be isolated from other units of code. But all these individual units inevitably must integrate to implement a certain feature. To make matters worse, the unit you're trying to test may have to call a database, read a file, or interact with an external web service. You don't want to make these calls in your unit tests. You may even want to simulate specific responses from these dependencies.

These aren't difficult problems to solve if you use the correct techniques. This is where mock objects, stubs, or fakes come into play. Basically, you provide a fake implementation to the unit you want to test. You now have control over this fake implementation—you can tell it to act in a certain way, and you can verify if it was accessed correctly.

This makes your unit test more of a white box test than a black box test—your test now tests parts of the inner workings of the unit. This can potentially be a drawback. If the implementation changes significantly, you'll also have to change your test. But it's still more valuable than having no test at all.

Best Testing Tools

Being the best is more than being the most technically advanced. It's also about offering support (i.e., a community), integration in your favorite editor or IDE, integration with your build server, good documentation, regular updates, etc.

And, of course, there's personal preference. But the unit testing libraries I mentioned (JUnit, NUnit, xUnit.net, PHPUnit, etc.) are great choices.

Not everything can be covered with unit tests, of course. But there are plenty of good choices for different types of tests. For example, there are JMeter and K6 for load testing, Selenium for web application end-to-end testing, and Waldo for mobile app testing.

Unit Testing Examples

Let's run through some examples of unit testing.

Test First

First, here's a very simple example. Let's say we want to write a function that handles general errors in a NodeJS web application (written in TypeScript). We know the specification is to return a HTTP 500 status code. This is our test:


import "mocha";
import { expect } from "chai";
import * as httpMocks from "node-mocks-http";
import { handleGeneralError } from "../../src/handleGeneralError";

describe("handleGeneralError", () => {
    let req: httpMocks.MockRequest<Request>;
    let res: httpMocks.MockResponse<Response>;

    beforeEach(async () => {
        const error = new Error();
        await handleGeneralError(error, req, res);
    });

    it("should set the status to a HTTP 500", () => {
        expect(res.statusCode).to.equal(500);
    });
});


Immediately, we can see some interesting things.

The "handleGeneralError" function has two dependencies: a Request and a Response object. This is part of the ExpressJS web framework. Because these are quite complex objects, we're using a library ("node-mocks-http") to create fake implementations.

Next, we pass these dependencies and an error to our function. Finally, we check if the "statusCode" of the Response object has been set to 500.

If we run this, the test fails because the function doesn't yet exist or because it doesn't contain the correct implementation. A failed test is useful, as we'll see later.

Implementing the "handleGeneralError" function is easy:


function handleGeneralError(error: any, req: Request, res: Response): void {
    res.status(500);
}

If we now run the test, we'll see that the test passes.

Let's add a new specification. What if we want to return a HTTP 502 in case a downstream service timed out, but only if we're OK with exposing these internal workings? Our code could look something like this:

Notice how we have two functions? The "determineStatus" function is a private matter for the "handleGeneralError" function. So, it's not a dependency that must be mocked or stubbed, nor do we need to test it separately.

We could now write a test to verify that we get a HTTP 500 in case the "error.expose" value is set to "false" (I removed the previous test for brevity):


import "mocha";
import { expect } from "chai";
import * as httpMocks from "node-mocks-http";
import { handleGeneralError } from "../../src/handleGeneralError";

describe("handleGeneralError", () => {
    let req: httpMocks.MockRequest<Request>;
    let res: httpMocks.MockResponse<Response>;
    
    beforeEach(async () => { 
        const error = new Error();
        (error as any).code = "timedout";
        (error as any).expose = false;
        await handleGeneralError(error, req, res);
    }); 
        
    it("should set the status to a HTTP 500", () => { 
        expect(res.statusCode).to.equal(500);
    }); 
});


When we run this test, it passes. But it would also pass without changes to our "handleGeneralError" function because either our test is wrong or our code is wrong. The test sets the error code to "timedout," whereas the production code expects "timeout" (without the "d").

This is a simple example, but it shows why it's important to have a failing test first when making code changes. If your test passes without any code changes, there might be an issue with your test.

If you're writing tests for existing code, this is different, of course. The test will immediately pass. But you could remove or change the code you want to test to verify that the test fails without it.

If we combine these two tests, we might think we're done. However, it would be good to run a code coverage tool. Such a tool will show you which pieces of code your tests cover. It can be useful to uncover new test cases.

Our example covers all lines. But there's still a case that we're not testing. What if the code is "timeout" and "expose" is set to "true"? This shows that code coverage is useful, but you still need to think about your code.

Debugging

Another example where unit tests shine is debugging. Say an end user filed a bug and, after looking at the logs, you see that it's an input validation error. The client is providing the wrong input to your HTTP endpoint. This causes the following error object to be thrown:


{
    validationErrors: [{
        field: "username",
        error: {
            description: "Username is required",
            code: "username_required"
        }
    }, {
        field: "phone",
        error: {
            code: "phone_regex_mismatch"
        }
    }]
}


We expect a HTTP 400 but see a 500. This is our function:


function handleValidationError(error: any, req: Request, res: Response): void {
    res.status(400);

    const body = error.validationErrors.map(e => {
        return {
            field: e.field,
            error: e.error.description
        };
    });
}

It might seem obvious where the error lies, but real-life scenarios aren't always clear. However, if we can reproduce the bug in a unit test, we can do some debugging and find the issue. This is our test:


import "mocha";
import { expect } from "chai";
import * as httpMocks from "node-mocks-http";
import { handleGeneralError } from "../../src/handleGeneralError";

describe("handleValidationError", () => {
    let req: httpMocks.MockRequest<Request>;
    let res: httpMocks.MockResponse<Request>;
    
    beforeEach(async () => { 
        const error = {
            validationErrors: [{
                field: "username", error: {
                    description: "Username is required",
                    code: "username_required"
                }
            }, {
                field: "phone", error: {
                    code: "phone_regex_mismatch"
                }
            }]
        };

        await handleValidationError(error, req, res);
    }); 
        
    it("should set the status to a HTTP 400", () => { 
        expect(res.statusCode).to.equal(400);
    }); 
});

By debugging it, we found that our code can't handle a validation error without a "description" field. We can easily fix this by changing our code to this:


function handleValidationError(error: any, req: Request, res: Response): void {
    res.status(400);

    const body = error.validationErrors.map(e => {
        return {
            field: e.field,
            error: e.error?.description // we changed this line to handle missing descriptions
        };
    });
}

Continuous Integration

Once we have our unit tests in place, we should commit them to source control so other team members can run them. This is continuous integration—we constantly share our new code with team members so they can profit from the value of our tests. If they accidentally make breaking changes to our code, the unit tests will notify them.

Unit Testing in Review

Unit testing is writing code that can automatically test small parts of your production code. It provides fast feedback when implementing new features, helps you write clean and modular code, gives confidence to refactor, and can help track down regression bugs. Unit tests should be written at an early stage when developers are modifying the code.

This is not to say that there's no need for manual testing anymore. Manual software testing may point to issues that we didn't think of when we were writing unit tests. That's why it's still important in your software development process. Manual testing is also useful to test the application end-to-end.

This post was written by Peter Morlion. Peter is a passionate programmer that helps people and companies improve the quality of their code, especially in legacy codebases. He firmly believes that industry best practices are invaluable when working towards this goal, and his specialties include TDD, DI, and SOLID principles.