Bugs Are Infectious

The earlier you find a bug, the easier it is to fix. But it's even more true that the later you find a bug, the more pain it will cause to fix.

When a bug exists, people often unintentionally write code that relies on it. This code can be nearby or far away, causing problems downstream or upstream.

We notice things that go wrong, not things that go right. When things work, we assume they work as intended, but often they work for reasons we can't imagine.

Committing a bug to your codebase can lead to other code relying on it, making it harder to fix in the future. The sooner you find the bug, the fewer dependencies you'll need to clean up.

Think of bugs as infectious - each one can create new problems as code is written around it or relies on its incorrect behavior. The best way to stop this spread is to eliminate bugs early on.

Don’t rely on users

Don't count on users to detect problems. They might not notice or assume it's a feature.

A better approach is automated testing. Run tests frequently to catch bugs early and prevent them from becoming hard to fix.

However, writing tests takes time. But detecting problems later is even more costly.

To make automated testing work, you need a testing framework, deployment system, and a team committed to testing. It won't work unless everyone is on board.

Automated testing poses challenges

Some projects are harder to test than others. For example, writing an audio compression codec is challenging because it's hard to measure the quality of the decompressed audio.

It's also difficult to test code that's hard to measure success. For instance, testing animated characters in a game requires human evaluation to determine if it looks realistic.

In such cases, a hybrid approach is necessary.

We can test what we can, control what we can, and acknowledge that not everything can be tested. Areas not covered by automated tests will need to be tested manually.

However, we can still make our code easier to test. We can make it modular, with separate components that can be tested independently, and use interfaces to make it more testable. For example, we can design an interface for the audio compression and decompression algorithms, allowing us to test these steps separately.

Stateless code is easier to test

One important strategy is to reduce the amount of state in your code. It’s a lot easier to test code that doesn’t rely on state. Any pure function—a bit of code that relies only on its direct inputs, has no side effects, and has predictable results—is easy to test.

function sumArray(values: number[]): number {
    let sum = 0;
    for (const value of values) {
        sum += value;
    }
    return sum;
}

function reduce(
    initialValue: number,
    reduceFunction: (accumulator: number, currentValue: number) => number,
    values: number[]
): number {
    let reducedValue = initialValue;
    for (const value of values) {
        reducedValue = reduceFunction(reducedValue, value);
    }
    return reducedValue;
}

To test sumArray, you just need a set of inputs and the expected outputs for those inputs. That’s exactly the sort of thing that test-driven development frameworks excel at. If there’s state involved, the set of inputs required to thoroughly exercise the code gets a lot more complicated.

Testing reduce is harder—in the apparent pursuit of generality, or maybe as a half-step toward threading, it repeatedly calls a passed-in function on the values in array. You can certainly use reduce to sum the values in the array:

function sum(value: number, otherValue: number): number {
    return value + otherValue;
}

const arraySum: number = reduce(0, sum, values);

Testing reduce can be challenging because it's hard to predict what the reduce function will do. Does it rely on external state or have side effects? What if it manipulates the input vector while iterating over it? This requires anticipating and testing many scenarios, making the test suite far more extensive than for a pure function like sumArray.

Stateless code is also easier to write correctly initially. Test-driven development reveals an advantage: code that's easier to test is often easier to write.

When you think about testing code before writing it, you'll naturally write simpler code.

Handle unremovable state

When circumstances force you to keep state, internal testing can be a good solution. For instance, if you have a sorted list of characters that is updated frequently, your stateless implementation might be inefficient.

If it's difficult to write external tests due to internal state, try writing an internal test instead. One simple way to do this is to create an auditing method that checks if the internal state is consistent.

Internal testing has its advantages, especially when used alongside external testing. You can keep internal tests running continuously, testing real-world scenarios rather than artificial ones.

Don’t trust callers

As a programmer, your code will be used by others, and you can't assume they'll do it correctly. Mistakes will happen, and it's your responsibility to detect and handle them.

The best way to find mistakes is to check for them in your own code. This ensures that you catch errors even if the caller made a mistake. Good design aims to ""Eliminate Failure Cases"", but sometimes it's impossible to cover all scenarios.

The correct usage pattern for your code is straightforward: initialize, use, shut down. However, users often forget or abuse this pattern, leading to errors such as forgetting to initialize, asking for non-existent object state, or setting state on an invalid ID.

Ignoring these errors is a bad idea, as it allows mistakes to go unnoticed and may lead to unexpected results. In practice, the results may be predictable, but the interface does not guarantee this, which is a sign of poor design.

To prevent mistakes, you can also check for other errors, such as forgetting to initialize or calling the code multiple times. The key is to flag the error, not how you do it. This is a team convention debate that can be resolved by deciding on a consistent approach to error handling.

Ultimately, detecting and handling errors in your code is crucial to preventing mistakes and ensuring that your code is used correctly.

Keeping code healthy

Code that's easy to test stays healthy. To achieve this, start by writing automated tests before you write the code. You can also opt for stateless implementations or internal auditing.

This approach helps you discover bugs early, reducing the number of issues to fix and making fixes easier.

Additionally, testing-friendly techniques also make code easier to write. Simplifying use cases, reducing state, and making interfaces more stable all contribute to cleaner code.

In the end, it's a two-way win.

Keep things simple and keep testing.