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

The Developer Who Cuts Corners to Look Fast

Speed looks impressive—until the shortcuts catch up with you. Cutting corners may make a developer look fast today, but it costs the team tomorrow.

Read more

Hiring a Generalist vs a Specialist Backend Developer — What Actually Matters

The generalist vs specialist debate in backend hiring is really a question about where your system complexity lives — and the wrong hire relative to your actual complexity creates either over-engineering or operational risk.

Read more

Why Warsaw Startups Are Hiring Async Remote Backend Contractors to Stay Ahead of Local Salary Inflation

Warsaw's backend salaries have been climbing steadily. The startups moving fastest have found a way to get work done that doesn't depend on where the local market lands next.

Read more

How to Run Your Spring Boot App and Database Together With Docker Compose

Getting a Spring Boot application and PostgreSQL to start together correctly in Docker Compose requires more than just listing both services — you need health checks, proper dependency ordering, and connection URL configuration that works inside a container network.

Read more