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.