Writing Tests After the Fact Is Better Than Not Writing Them at All

by Eric Hanson, Backend Developer at Clean Systems Consulting

The Perfectionism Trap

The most common reason teams do not add tests to existing untested code is a belief that retroactive tests are somehow illegitimate. The argument goes: tests written after the implementation are just encoding what the code does, not what it should do. They are low-signal at best and misleading at worst. Better to leave the code untested than to add bad tests.

This argument is wrong. Not because retroactive tests are ideal — they are not — but because the alternative is not "good tests written upfront." The alternative is no tests at all. And no tests at all is always worse than imperfect tests that cover real behavior.

What Retroactive Tests Are Actually Good For

A test written after the fact cannot drive design. That ship has sailed. But it can do several other useful things.

It establishes a regression baseline. Before you change anything, a characterization test tells you what the code does right now. When you modify the function and the test breaks, you know the behavior changed — which is the first piece of information you need when deciding whether the change was intentional.

It documents undocumented behavior. Legacy code is often underdocumented precisely because the people who wrote it knew how it worked. A test that captures the specific output for a specific input is executable documentation. It is more reliable than a comment and more discoverable than a wiki page.

It catches future regressions. The test was not there to prevent the current bugs. But it will be there the next time someone touches the function. That is real value.

It exposes design problems. Writing a test for existing code is often harder than it should be. When it is very hard — when you need to construct elaborate setups, mock singletons, or instantiate half the application just to call one function — that difficulty is signal. The code is hard to test because it is doing too much, or because its dependencies are not injectable, or because its concerns are not separated. The pain of writing the test tells you something about the code that no static analysis tool will.

The Practical Process

When adding tests to untested code, the most effective approach is to start with characterization tests: tests that capture existing behavior without judging it.

# You find this function in a legacy codebase with no tests.
# You do not fully understand it yet.
def calculate_late_fee(days_overdue: int, base_amount: float) -> float:
    if days_overdue <= 0:
        return 0.0
    if days_overdue <= 7:
        return base_amount * 0.05
    if days_overdue <= 30:
        return base_amount * 0.10 + (days_overdue - 7) * 0.50
    return base_amount * 0.25 + (days_overdue - 30) * 1.00 + 11.50

# Step 1: Write characterization tests to capture current behavior
def test_calculate_late_fee_no_overdue():
    assert calculate_late_fee(0, 100.0) == 0.0

def test_calculate_late_fee_within_first_week():
    assert calculate_late_fee(3, 100.0) == 5.0

def test_calculate_late_fee_at_boundary():
    assert calculate_late_fee(7, 100.0) == 5.0
    assert calculate_late_fee(8, 100.0) == 10.50  # Crosses into next tier

def test_calculate_late_fee_extended():
    assert calculate_late_fee(31, 100.0) == pytest.approx(36.50)

These tests do not validate whether the fee schedule is correct — that is a business question that requires a stakeholder conversation. They validate that the code behaves consistently. If someone refactors the function and the tests break, that refactor changed something, and the team needs to decide whether that was intentional.

The Honest Limitations

Retroactive tests can encode bugs. If the code has been wrong for two years and you write a characterization test that expects the wrong answer, you have now memorialized the bug. This happens. The mitigation is to pair characterization tests with review: when you discover unexpected behavior while writing tests, flag it. Do not just capture it silently.

Retroactive tests also rarely achieve the design benefits of test-first development. The dependency injection, the small focused functions, the clear separation of concerns — those benefits come from the test-writing forcing better design decisions before implementation. You cannot retroactively get those benefits without also refactoring the code.

But refactoring the code is easier — and safer — when you have tests. So the sequence is: write retroactive tests, then use the tests as a safety net while improving the design. Not ideal. But functional. And always better than leaving the code untouched and untested while everyone is afraid to change it.

Start with the highest-risk functions: the ones that are changed most often, the ones that have produced bugs before, the ones that are hardest to reason about. Write the simplest test that captures their current behavior. That is the first step out of the untested codebase, and it is a step worth taking.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Docker Networking Is Confusing Until You Understand This One Thing

Most Docker networking confusion comes from conflating three distinct namespaces: how containers reach each other, how the host reaches containers, and how containers reach the outside world. Once you separate those three, the rules become predictable.

Read more

How to Document Mistakes So You Don’t Repeat Them

We all make mistakes—but some stick around longer than they should. Knowing how to document them can save hours, headaches, and frustration later.

Read more

Spring Boot API Rate Limiting — rack-attack Equivalent in Java

Rate limiting protects APIs from abuse, enforces fair usage, and prevents accidental runaway clients from taking down infrastructure. Here is how to implement per-user, per-IP, and per-endpoint rate limiting in Spring Boot with Bucket4j and Redis.

Read more

Stockholm Startups Can't Hire Backend Engineers Fast Enough — Here Is What Actually Works

You posted the backend role eight weeks ago. You've had twelve applicants, four interviews, and zero offers accepted. Meanwhile, the integration your sales team promised a client is still sitting in the backlog collecting dust.

Read more