HelpWithWebGet Help Now
← Back to Blog
Testing5 min read

Writing Unit Tests for Code You Didn't Write

You inherited a codebase with no tests. Now there's a bug, and you need to fix it without breaking everything else. Here's the pragmatic strategy for adding tests to legacy code without rewriting everything.

ByDino Bartolome
Close-up of source code on a screen
Photo by Veronica on Unsplash

You inherit a codebase. There are zero tests. Now the client wants you to fix three bugs and "make sure existing features still work." If you've been doing this long, you know the trap: rewrite the whole thing into a testable state and you'll blow the budget. Skip tests entirely and your fixes will break things you didn't even know existed.

There's a pragmatic middle path. Here's how I approach it.

Don't Aim for 100% Coverage

The goal of adding tests to legacy code is not "thoroughly tested codebase." That's a multi-month project nobody is paying you for. The goal is catching regressions on the specific code paths you're touching.

That changes the math entirely. Instead of testing everything, you test:

  1. The code path that contains the bug you're fixing
  2. The code paths that share state, dependencies, or modules with the bug

Everything else stays untested for now. That's fine.

Start at the Bug, Work Outward

When I take over an untested codebase to fix a bug, the first test I write is a characterization test for the buggy behavior — yes, the broken behavior.

Why? Because:

  1. It proves I can actually invoke the code in isolation
  2. It documents what's broken before the fix
  3. It gives me a baseline that should flip from "fail" → "pass" when I fix the bug

So if the bug is "the discount calculation returns the wrong total when a coupon is stacked with a sale price," my first test asserts the current (wrong) output. Then I fix the bug. The test fails (because the output changed). I update the assertion to the correct value. Now the test pins the correct behavior forever.

Use the Real Dependencies First

In greenfield TDD, you mock everything. In legacy code, that's often impossible without a refactor — the code wasn't designed for it.

Start with the real database, real network calls, real file system. Yes, it's slow. Yes, it's brittle. But it lets you actually pin behavior. You can refactor toward mocked dependencies later if performance matters.

The exceptions are external APIs that cost money or send real emails. Those need mocking immediately.

Snapshot Tests Are Useful for "Don't Change Anything"

When the client says "make sure existing features still work," the most pragmatic test is often a snapshot test — record the current output, then assert it doesn't change after your fix.

``js test("checkout flow returns same response shape", () => { const result = processCheckout(testCart); expect(result).toMatchSnapshot(); }); ``

Snapshot tests aren't elegant but they're cheap to write and effective at catching "I accidentally broke this." Use them for areas you're not actively changing but want to protect.

Write Tests From the Outside In

The hardest tests to write in legacy code are for deeply nested internal logic. The easiest are for inputs and outputs at the system boundary — API endpoints, form submissions, scheduled jobs.

Start at the outside:

  1. Integration test — send a real HTTP request, assert the response
  2. End-to-end test — drive the UI through the real flow, assert what the user sees
  3. Unit test — only after you have the integration test passing, drop down to test the specific function that has the bug

Integration tests at the boundary catch the most regressions for the least effort.

The "Three Tests Per Bug" Rule

For every bug I fix, I aim for three tests:

  1. The exact bug — pin the input that previously misbehaved
  2. The boundary case — what happens at zero, null, empty, max
  3. The happy path — confirm the normal flow still works

Three tests per bug isn't comprehensive coverage. But it's enough to catch regressions and prove the fix is real.

When the Code Is Untestable

Sometimes the code itself prevents testing — God objects, tightly coupled modules, side effects everywhere. In those cases you have three options:

  1. Refactor just enough to test the specific code path (extract one function, inject one dependency)
  2. Test at a higher level (integration test instead of unit test)
  3. Skip the test, document the risk in the commit message and PR description

The third option is sometimes correct. Refactoring untestable code without a safety net is itself risky.

What Tests Buy the Client

A client paying for "bug fix + tests" gets two things:

  • The bug is fixed
  • A regression in the same area will be caught automatically next time

That second part is the long-term value. A bug fix without tests is a one-time service. A bug fix with tests is a permanent guard.

---

Inherited a codebase with no tests and a list of bugs? This is the exact engagement I take on most often. For a single bug + 2-3 regression tests, that's typically a $1,500 Quick Win. For a broader codebase audit and a prioritized bug list, $500 Discovery is the right starting point. Send me what you're inheriting and I'll tell you what's realistic.

Need Help With Your Website?

I fix these problems every day. Send me a message and I'll take a look.

Get Help Now