The Problem With Writing Tests After the Feature Is Already Merged

You know the scene. The pull request sits open for days, sometimes weeks, collecting comments about naming conventions and edge-case handling. Eventually, it clears review. The branch gets merged into main. There’s a collective exhale. And then, almost sheepishly, someone says it: “We’ll add tests in a follow-up.”

Most teams nod along. It feels reasonable. The feature works. QA gave a thumbs-up. What’s the harm in deferring a few unit tests? The answer, if you’ve spent any time inside a codebase older than six months, is that the harm has already happened. You just won’t feel it until the next sprint—or the next 3 a.m. incident.

Writing tests after a merge isn’t a time-management compromise. It’s a structural mistake that quietly erodes code confidence, distorts design intent, and punishes anyone who touches that code later. I’ve watched this pattern play out in startups and enterprise teams alike. The outcome is always the same: tests that never get written, or tests that get written so poorly they become dead weight.

The Immediate Decay of Context

The first problem is biological. When you write a feature, your brain holds a dense, fragile model of how the code behaves. You know which functions assume a certain input shape. You remember the three-hour debugging session that led to that weird null-check on line 47. This mental model peaks right before you open the pull request. It decays rapidly after you move to the next task.

By the time “add tests” appears on a sprint board two weeks later, you’ve lost the finer details. You open the file and have to re-derive what you originally intended. You miss the subtle invariants because they weren’t documented—they lived in your head. The resulting tests cover the obvious paths but skip the brittle ones. You test what the code does, not what it was supposed to do. That’s a critical distinction. Tests written against the implementation rather than the specification become a tautology: they pass because they mirror the code, not because they verify correctness.

Worse, the person writing those follow-up tests may not be the original author. They’re a colleague interpreting your code through the lens of current behavior. Any misunderstanding gets baked into the test suite as a false positive. A year later, someone refactors the module, a test fails, and nobody can tell if the failure exposes a real bug or a stale assumption. The test becomes noise.

Developer staring at a monitor with code, looking frustrated
A developer loses the thread when context decays.

Design Feedback Arrives Too Late

Tests are not just verification. They are a design tool. When you write tests alongside code, you feel the friction of a poorly structured API immediately. A function that requires seven mock dependencies to test is a function that’s too coupled. A class that can’t be instantiated without a database connection is a class that will resist change. These insights shape the code while it’s still fluid.

Write tests after the merge, and that feedback loop is severed. The API is already set in stone—other modules may already depend on it. You can’t easily refactor to improve testability without breaking the downstream consumers that sprouted in the meantime. So you compromise. You write sprawling integration tests that mock half the system, or you expose internal state through friend classes and test hooks, violating encapsulation to compensate for a design you can no longer change. The test suite becomes a scaffolding of workarounds, not a statement of intent.

I’ve seen this lead to a peculiar form of technical debt: tests that are harder to maintain than the production code. They break on every refactor because they’re coupled to implementation details. Developers start ignoring failing tests. The suite loses its authority. You end up with a safety net full of holes, and everyone is too busy to mend it.

The Follow-Up That Never Ships

Let’s be honest about human nature. A ticket labeled “add tests for user-auth flow” has zero urgency compared to the next feature request. Product managers don’t celebrate it. Stakeholders don’t see it. Sprint planning tends to push it into the next iteration, and the next, until it vanishes from the backlog entirely. I’ve audited codebases where the “TODO: add tests” comment was older than some of the junior developers.

Even when the tests do get written, they’re often rushed. The developer knows this is a chore, not a craft. They hit the minimum coverage threshold and move on. The tests assert the happy path and maybe one exception. They don’t probe boundary conditions. They don’t simulate the production data shapes that actually cause failures. The coverage metric turns green, but the code remains as vulnerable as before.

This creates a bizarre organizational dynamic: the team congratulates itself on maintaining 80% test coverage while harboring entire modules that have never survived a real regression. The metric becomes a lie everyone agrees to believe.

Sticky notes on a whiteboard with crossed-out tasks
Test tickets often get deprioritized until they disappear.

Regression Blindness in Code Review

One underappreciated role of tests is that they serve as documentation during code review. When a pull request includes tests, the reviewer can see not only what changed but what the author considers worth protecting. A test that says “expect this function to throw when the input list is empty” tells me more about the intended contract than any docstring.

Without tests in the diff, the reviewer is flying blind. They can assess code style and logic flow, but they can’t judge whether the change preserves existing behavior or introduces subtle regressions. The merge becomes an act of faith. Months later, when a bug surfaces, the git blame points to a commit with no test coverage. The original reviewer—if they’re still around—can’t recall why they approved it. The institutional memory is gone.

I’ve watched teams try to compensate by requiring manual QA sign-off on every merge. That’s slow, expensive, and still misses edge cases. A test suite wouldn’t catch everything either, but it catches the things you explicitly thought about. Without it, you’re relying on hope.

The Cost of Retroactive Testing

Let’s say you do go back and add tests. What does that actually cost? I’ve timed it. Writing tests for code I wrote yesterday takes roughly 40% of the original implementation time. Writing tests for code I wrote three weeks ago takes 70-100%, depending on complexity. The re-learning phase consumes hours. I have to reconstruct the mental model, trace the call graph, and sometimes reverse-engineer business logic from commit messages that say things like “fix edge case.”

That’s time not spent on new features, bug fixes, or paying down other debt. It’s a pure efficiency loss. And the resulting tests are still worse than what I would have written contemporaneously because I’m now testing against an artifact, not a design. The tests become descriptive, not prescriptive.

Multiply this across a team of ten engineers, each deferring tests on a few features per quarter. The cumulative waste is staggering. You’re paying a premium for lower quality. No business case supports that.

A person reviewing printed code with a red pen, looking analytical
Retroactive testing means re-learning code you’ve already forgotten.

What to Do Instead

The fix isn’t complicated, but it requires discipline. Tests must be part of the definition of done for any code change. No merge without tests. This isn’t about process for its own sake—it’s about preserving the value of the work. A feature without tests is a feature you can’t safely change, and code that can’t change is already legacy.

Start by making testability a first-class design constraint. If you’re writing a function and thinking “I’ll test this later,” stop. Write the test now. Feel the pain of that awkward dependency and fix it before it’s committed. Use test-driven development if it fits your style, but even test-during development is infinitely better than test-after.

For existing codebases where tests were deferred, triage ruthlessly. Identify the modules that change most frequently or have caused the most production incidents. Test those first. Write characterization tests that capture current behavior, even if it’s not ideal, so you have a safety net for refactoring. Accept that you’ll never backfill everything—and that’s a scar you should learn from.

Finally, resist the cultural pressure to ship fast and test later. That pressure always comes from people who won’t be on call when the system breaks. The time you “save” by skipping tests is borrowed against future debugging, firefighting, and eroded trust in the codebase. The interest rate is brutal.

Frequently Asked Questions

Isn’t it sometimes okay to defer tests for a simple hotfix?

In an emergency, yes—but define “emergency” narrowly. A production outage demands speed. But the hotfix should still include a test that reproduces the bug before the fix is applied. Merge that test first, watch it fail in CI, then merge the fix. That’s not deferring; that’s sequencing. If the hotfix is so urgent you can’t write a single test, you should be pair-programming it with a second engineer and documenting the gap for immediate follow-up within 24 hours. Anything less is just normalizing shortcuts.

What if management pressures us to ship features without tests?

This is a leadership problem, not a testing problem. Make the cost visible. Track the bugs and regressions that originate from untested code. Show the time spent debugging versus the time that would have been spent testing. Frame it as a trade-off: we can ship this feature today without tests, but we accept the risk of a multi-hour outage next month that will block all other work. Most reasonable stakeholders will choose the slower, safer path when the consequences are concrete. If they don’t, you have a deeper issue about engineering culture that won’t be solved by testing practices alone.

How do we handle legacy code that has no tests at all?

Don’t try to backfill everything. It’s a recipe for burnout. Instead, adopt a “test when you touch” policy. Any time you fix a bug or add a feature in a legacy module, write tests for the code you’re changing. Over time, the most active parts of the system accumulate coverage. For modules that are stable but critical, consider a focused effort: spend a week writing characterization tests that lock down current behavior. These aren’t ideal, but they give you a baseline for future changes. The key is to stop the bleeding first—no new untested code—before you start healing the old wounds.

Related Post