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

by Arif Ikhsanudin, Backend Developer

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

Recovering From a Public Mistake (Like a Website Crash)

Seeing your website go down in front of everyone is a stomach-dropping moment. But a public mistake doesn’t have to be a career-ender—it can be a chance to show professionalism and resilience.

Read more

Burnout in Software Engineering Looks Different Than You Expect

Engineering burnout rarely announces itself as exhaustion. It shows up as detachment, cynicism about work that used to matter, and a grinding decline in the quality of your judgment — often months before you recognize what's happening.

Read more

Why Not Using a Git Server Is a Recipe for Lost Code

Trusting your local folder to hold the only copy of your code? It sounds fine until the inevitable crash—or accidental delete—happens.

Read more

Sydney Backend Engineers Are Expensive and Being Poached by the Big Four Banks — What Startups Do Instead

Your best backend engineer just got an offer from CommBank. You can't match it. You already know how this ends.

Read more