Unit Testing Strategies

I don't think I've met a developer that enjoys writing unit tests. I don't enjoy writing unit tests either, but I do enjoy the confidence that they provide in my code.

I don't think I've met a developer that enjoys writing unit tests. I don't enjoy writing unit tests either, but I do enjoy the confidence that they provide in my code. Some folks swear by Test Driven Development, but that always seems a bit awkward to me. Most of the time, I don't know what the units of code will look like until I've started writing it, and experimenting. When I code, I generally write the code to solve the problem, and then write unit tests to verify the solution. I've written a lot of unit tests, and I've come up with a few guidelines I follow, which help me power through them.

For every file that has logic, create a file that tests that logic.

Some may believe this to be superfluous. I've seen unit tests that are meant to cover entire subsystems, but I've noticed a problem with that approach. It's difficult to put all of the tests for an entire subsystem into a single file. Either you have a file with thousands of lines, or you aren't thoroughly testing each unit of code within the subsystem. Unit tests are made to be broken. If we've got to dig through thousands of lines of code in a unit test to fix it, we've wasted time. If we don't test everything in the subsystem, we lose the confidence we gain from writing the unit test in the first place.

Test every execution path.

Unit testing gives you confidence in the code you write. Not only that it works when you first write it, but also that you didn't break anything when you changed it. Every time you write a conditional statement, your code branches. If you only test a subset of those branches, like the ideal execution path, you can't have that confidence. You only know that your logic works in specific cases.

Take the following example:

class MyClass {
    method(condition1: boolean, condition2: boolean, condition3: boolean) {
        if (condition1) {
            if (condition2) {
                if (condition3) {
                    return true;
                }
            }
        }
        return false;
    }
}

A naive approach may be to write a test case for each possible combination of values for our parameters. Unfortunately, this would grow the number of tests we need to write factorialy. Since each parameter can be one of two values, and we have three parameters, this would result in 8 tests (2 * 2 * 2). However, many of the possible combinations can be grouped together, as they don't result in different execution paths. For instance, if condition1 is false, it doesn't matter what condition2 and condition3 are. Likewise, if condition2 is false, condition3 is ignored.

Mentally stepping through the code can help us determine the possible execution paths:

- if condition1 is false, return false.

- if condition1 is true, and condition2 is false, return false.

- if condition1 is true, condition2 is true, and condition3 is false, return false.

- if condition1 is true, condition2 is true, and condition3 is true, return true.

This allows us to lower the number of tests we need to write, and still ensure we completely cover all possible execution paths.

Mock all the things

When writing unit tests, make every effort to test only a specific unit. Use mocks to handle calls to third party libraries, and other units of code within your app. Third party libraries should already have been tested, and other units of code within your app should be tested seperately. In addition to refactoring code to limit the number of conditional statements you are testing at a given time, there are a few other things you may want to refactor that can make mocking simpler.

​Use interfaces instead of concrete types when possible

This makes mocking your dependencies much easier. If you aren't familiar with the Interface Segregation Principal (the "I" in SOLID), read up on it. It may be tempting to create an interface that just mirrors an existing class, but if your interface only has the methods required by the client using it, you only mock the methods that you need to mock for your tests anyway.

Check out the following example:

interface IProductRepository {
    getProduct(id: string): IProduct,
    updateProduct(id: string, IProductChangeSet): IProduct,
    deleteProduct(id: string),
    createProduct(options: IProductCreateOptions)
}
​
class ProductDetailController {
    private readonly productRepo: IProductRepository;
    
    constructor(productRepo: IProductRepository) {
        this.productRepo = productRepo;
    }
    
    getProductDetailView(productId: string) {
        const product = this.productRepo.getProduct(productId);
        return new ProductDetailView(product);
    }
}

Even though I'm only using the getProduct method of the IProductRepository, I'll need to mock all of the repository. I might even be tempted to try and come up with a single MockProductRepository I can use for all tests where I need to mock IProductRepository. I've never seen that work well.

interface IProductProvider {
    getProduct(id: string): IProduct,
}
​
class ProductDetailController {
    private readonly productProvider: IProductProvider;
    
    constructor(productProvider: IProductProvider) {
        this.productProvider = productProvider;
    }
    
    getProductDetailView(productId: string) {
        const product = this.productProvider.getProduct(productId);
        return new ProductDetailView(product);
    }

This example uses the Interface Segregation Principal, and it allows me to mock only the getProduct method, which I would need to mock anyway.

Avoid private methods

I'm aware that using private methods is a valid software design pattern, but when it comes time to write unit tests, you won't be able to mock them. This means that any private methods, and their conditional statements, must be accounted for when writing our test cases.

Take a look at this example:

public class MyClass {
    
    private check(x) {
        if (x <= 0) {
            throw new Error('Only positive numbers allowed');
        }
    }
    
    public add(left, right) {
        this.check(left);
        this.check(right);
        return left + right;
    }
    
    public subtract(left, right) {
        this.check(left);
        this.check(right);
        return left - right;
    }
}

Since I can't mock the check method, I have to add test cases for it's conditional statement in my tests for both the add and subtract method. This means I'm testing check more than once, and my other tests are unnecessarily complicated.

I will note that there are ways around this in JavaScript and Typescript because you have the ability to replace a method's "this" context at run-time, like so:

const mock = {
    checkArgs: [],
    check(x) {
        this.checkArgs.push(x);
    }
};
​
const result = MyClass.add.call(mock, 5, 10);
​
assert(mock.checkArgs[0] === 5);
assert(mock.checkArgs[1] === 10);
assert(result === 15);

In C# or Java, you can use protected methods instead, and create a test harness by inheriting from the class you want to test:

class MyClassTester : MyClass {
    public List<int> checkArgs = new List<int>();
    
    protected void check(int x) {
        this.checkArgs.add(x)
    }
}
​
...
​
var tester = new MyClassTester();
var result = tester.add(10, 5);
​
assert(result == 15);
assert(tester.checkArgs[0] == 10);
assert(tester.checkArgs[1] == 5);

For complex logic, test scenarios

Sometimes you can't help but have a ton of conditional statements in a method. I've mostly seen this when using language features like LINQ in C# or chaining promises in JavaScript. In these situations, it's often possible to establish a set of scenarios where each represents a different combination of conditions. Instead of manually testing each combination, you run each, and use the tools available in your language to process the results as an array:

type Scenario {
    condition1: boolean,
    condition2: boolean,
    condition3: boolean,
    condition4: boolean,
    condition5: boolean
    result?: string[],
    error?: Error
}
​
const scenarios: Scenarios[] = [];
​
[true, false].forEach(condition1 =
    [true, false].forEach(condition2 =>
        [true, false].forEach(condition3) =>
            [true, false].forEach(condition4) =>
                [true, false].forEach(condition5 => {
                    let result: string[] | undefined;
                    let error: Error | undefined;
                    
                    try {
                        result = someMethod(condition1, condition2, condition3, condition4, condition5);
                    } catch (e) {
                        error = e
                    }
                
                    scenarios.push({
                        condition1,
                        condition2,
                        condition3,
                        condition4,
                        condition5,
                        result,
                        error
                    });
                })
            )
        )
    )
);
​
// test that no errors were thrown
scenarios.forEach(x => assert(!x.error));
​
// test that the result array has 'Condition 1' if condition 1 is true
scenarios.forEach(x => {
    if (x.condition1) {
        assert(x.result.contains('Condition 1'));
    }
});

Finally, don't worry about brittle tests.

You want your code to be robust, capable of handling errors with grace, and defensive against unintended input. Your unit tests should be brittle, as they only need to do two things:

  1. Verify that your code works.
  2. Break when you make changes.

If you make a change to the logic in your method, and all of your tests still pass, that should be a red flag.

The JBS Quick Launch Lab

Free Qualified Assessment

Quantify what it will take to implement your next big idea!

Our assessment session will deliver tangible timelines, costs, high-level requirements, and recommend architectures that will work best. Let JBS prove to you and your team why over 24 years of experience matters.

Get Your Assessment