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

Ruby Symbols vs Strings — When It Actually Matters in Production

Most Ruby developers know symbols are "faster" than strings, but few can explain exactly why or when the difference is worth caring about. Here's where it genuinely matters at scale.

Read more

Setting Career Goals as a Contractor

No promotion cycle. No manager checking your progress. As a contractor, your career only moves if you decide where it’s going.

Read more

Why “Don’t Touch This Code” Is a Huge Engineering Red Flag

Hearing “don’t touch this code” might seem like harmless advice, but it often signals deep problems in a codebase and the team culture around it.

Read more

Message Queues: The Part of System Design Most Backends Skip Too Long

Asynchronous messaging solves a class of reliability and decoupling problems that synchronous HTTP calls cannot. Most teams discover this after their first major production incident involving a slow downstream dependency.

Read more