How Writing Tests for Your Future Self Will Make Your Tests Better

As a full-stack developer with over a decade of experience across multiple languages and frameworks, I‘ve seen firsthand the difference that a clear, maintainable test suite can make to a project‘s long-term success. Too often, testing is treated as an afterthought, a chore to be rushed through on the way to shipping new features. But neglecting the quality of your tests is a short-sighted approach that will inevitably lead to pain down the road.

The Epidemic of Unclear Tests

If you‘ve worked on a real-world codebase of any significant size or age, chances are you‘ve encountered tests that were difficult to understand, modify, or extend. Perhaps they were littered with mysterious magic numbers, relied heavily on mocking and side effects, or were so abstracted that it was hard to tell what exactly was being tested.

You‘re not alone. A 2019 study by the University of Zurich analyzed over 2500 real-world Java projects and found that on average, 28% of test suites exhibited at least one test smell – a characteristic that indicates a potential maintainability problem. The most common test smells were Obscure Test (a test that is difficult to understand), Conditional Test Logic (a test with complex conditional code), and Eager Test (a test that verifies too many different behaviors). [^1]

These statistics underscore what many seasoned developers already know from experience – writing clear, maintainable tests is hard, and it‘s all too easy for a test suite to devolve into a confusing mess over time.

The High Cost of Confusing Tests

What‘s the big deal with a few unclear tests? They still catch bugs, right? Well, not necessarily. Confusing tests often mask potential bugs by making incorrect assumptions or verifying the wrong things. They can lead to false positives that give us a false sense of security.

But even if an unclear test manages to correctly verify the code‘s behavior today, it creates a maintenance burden that slows down development velocity in the long run. When a test is hard to understand, it makes developers afraid to modify it – so bugs go unfixed and new features go unimplemented. Or worse, a developer may misunderstand the test‘s intent and accidentally introduce a bug while trying to extend it.

Unclear tests also fail to serve as effective documentation. One of the great benefits of a test suite is that it provides executable examples of how the code is meant to be used. But if the tests are confusing, they don‘t fill this role effectively – a new developer can‘t look at the tests to quickly understand the system‘s behavior.

The Art of Writing Clear Tests

So what distinguishes a clear test from a confusing one? Here are some key principles I‘ve learned over the years:

  1. Test one thing per test. Each test should verify a single, specific behavior. Resist the temptation to stuff multiple assertions into one test – it makes it harder to tell what‘s being tested and what went wrong when the test fails.

  2. Make your test names descriptive. The test name should clearly convey what behavior is being verified. A good test name is like a well-written git commit message – it should complete the sentence "This test verifies that…"

  3. Use clear, minimal test data. The input data used in a test should be as simple as possible while still fully exercising the code under test. Avoid using complex real-world data that obscures what properties actually matter for the test. And if you find yourself using the same test data in multiple tests, consider extracting it into a factory function.

  4. Prefer explicit assertions over implicit ones. It should be obvious from reading a test what the expected behavior is. Avoid assertions that rely on the implementation details of the code under test – instead, explicitly state what output or state changes you expect.

  5. Keep test code DRY, but not at the expense of clarity. Some duplication in tests is fine if it makes each individual test more clear and self-contained. A little copy-paste is preferable to introducing a confusing abstraction just to avoid repetition.

Here‘s an example of a test that violates some of these principles:

it(‘filters numbers correctly‘, () => {
  const result = myFilter([1, 2, 3, 4, 5], x => x % 2 === 0);
  expect(result).toEqual([2, 4]);
});

While this test is concise, it‘s not very clear. The test name doesn‘t convey what "correct" filtering means, and the input data doesn‘t clearly illustrate the filter predicate. Here‘s how we could rewrite it for clarity:

describe(‘myFilter‘, () => {
  it(‘keeps only elements that satisfy the predicate‘, () => {
    const numbers = [1, 2, 3, 4, 5];
    const isEven = x => x % 2 === 0;

    const result = myFilter(numbers, isEven);

    expect(result).toContain(2);
    expect(result).toContain(4);
    expect(result).not.toContain(1);
    expect(result).not.toContain(3);  
    expect(result).not.toContain(5);
  });
});

Now the test name clearly states what behavior is being verified, and the test body explicitly lists out the expected properties of the result. The input data is also more illustrative of what the predicate function does. While this version is more verbose, it will be much easier for a future reader to understand.

Tests as a Design Tool

One of the best ways to ensure your tests are clear and well-structured is to write them before you write the implementation code. This practice, known as test-driven development (TDD), forces you to think through the desired behavior and interface of your code upfront. Writing tests first also ensures your code is inherently testable – you won‘t be tempted to take shortcuts or introduce untestable side effects.

TDD isn‘t just about ensuring test coverage – it‘s a design tool. The process of writing a test first forces you to consider how the code will be used and what inputs and outputs it should have. It often leads to simpler, more modular designs.

As an example, let‘s say we need to write a function that calculates a bowling score given an array of frame scores. We might start by writing a test like this:

it(‘calculates the correct total score‘, () => {
  const frames = [
    [10],
    [7, 3],
    [9, 0],
    [10],
    [0, 8],
    [8, 2],
    [0, 6],
    [10],
    [10],
    [10, 8, 1],
  ];

  const result = bowlingScore(frames);

  expect(result).toBe(167);
});

This test clearly defines the input and output of the function we need to write. It also serves as a nice high-level specification of how bowling scoring works. From here, we could start implementing the scoring logic incrementally, adding tests for edge cases like spares and strikes as we go.

Compare this approach to starting with the implementation first and adding tests later. We might have been tempted to start with a more complex data structure or a less clear interface, making our code harder to test and reason about. By starting with the test, we keep ourselves honest and ensure our implementation is focused and testable.

Tests as Living Documentation

Clear tests don‘t just make future development and refactoring easier – they also serve as invaluable documentation of how the system works. Well-written tests provide executable examples of how to use the code and what outputs to expect for given inputs.

This is especially valuable for edge cases and non-obvious behaviors that might not be clear from reading the implementation code alone. A good test suite can onboard new developers faster and provide a safety net for future modifications.

For example, consider this test for a text search function:

it(‘matches punctuation and spaces verbatim‘, () => {
  const text = ‘Hello, world! This is a test.‘;

  expect(searchMatches(text, ‘Hello, world!‘)).toBe(true);
  expect(searchMatches(text, ‘hello, world!‘)).toBe(false);
  expect(searchMatches(text, ‘This is a test.‘)).toBe(true);
  expect(searchMatches(text, ‘this is a test‘)).toBe(false);
});

From this test alone, a new developer can quickly grasp that the search function is case-sensitive and matches punctuation and whitespace characters exactly. This behavior might not be obvious from reading the implementation code, especially if the search function uses a complex regex or third-party library. By explicitly codifying these expectations in a test, we make them clear and unambiguous.

The Long-Term Payoff

Writing clear, maintainable tests takes effort and discipline in the short term. It can feel tedious to spell out every little detail and edge case when you just want to ship new features. But the long-term benefits are immense.

With a suite of clear, reliable tests, you can refactor and extend your code with confidence, knowing that any breaking changes will be immediately caught. You can onboard new developers faster, as they can use the tests to understand how the system works. And you can have greater assurance that your code actually works as intended, even in edge cases and under unusual conditions.

Investing in your test suite is investing in your codebase‘s future. By taking the time to write tests that are clear, focused, and maintainable, you‘re not just catching bugs today – you‘re enabling faster, more confident development for years to come. Your future self will thank you.

Conclusion

Writing good tests is hard. Like any skill, it takes practice and persistence to master. But by keeping your future self in mind as you write your tests, you can create a test suite that is an asset rather than a liability.

Focus on clarity over cleverness. Strive to make each test tell a clear, concise story about what behavior it‘s verifying. Treat your tests as living documentation, not just bug-catching nets. And most importantly, remember that a little clarity goes a long way – the extra effort you put in now will pay dividends for your codebase‘s entire lifespan.

Happy testing!

[^1]: Davide Spadini, Martin Schvarcbacher, Ana-Maria Oprescu, Magiel Bruntink, Alberto Bacchelli. "Investigating Severity Thresholds for Test Smells." Proceedings of the 17th International Conference on Mining Software Repositories (MSR ‘20), October 5-6, 2020, Seoul, Republic of Korea.

Similar Posts