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.

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.

How to Refactor Existing Code for Unit Testing

The advice above assumes you can reach the code. Often you cannot, because it reaches out to the world itself — it news up a database connection, calls a payment API, or reads the clock. You cannot test that in isolation without changing it, and changing it is what you were trying to avoid.

Here is the sequence that gets you out of that, using one small function as it typically appears in inherited code.

Step 1 — the hard-coded dependency. This is what you inherit. It cannot be tested without a live database and a real payment gateway.

class OrderService {
    public function charge(int $orderId): bool {
        $db      = new PDO(DB_DSN, DB_USER, DB_PASS);   // hard-coded
        $gateway = new StripeGateway(STRIPE_SECRET_KEY); // hard-coded
        $order   = $db->query("SELECT * FROM orders WHERE id = $orderId")->fetch();
        $result  = $gateway->charge($order['total'], $order['token']);
        return $result->ok;
    }
}

Step 2 — extract the dependency. Do not change behaviour yet. Just move construction out of the method body so there is a single place responsible for it. This alone is safe and reviewable.

class OrderService {
    private function db(): PDO {
        return new PDO(DB_DSN, DB_USER, DB_PASS);
    }
    private function gateway(): StripeGateway {
        return new StripeGateway(STRIPE_SECRET_KEY);
    }
    // charge() now calls $this->db() and $this->gateway()
}

Step 3 — inject the dependency. Now the caller decides what gets passed in. Production passes the real thing; the test passes a fake. Defaulting the constructor arguments keeps every existing call site working, which is what makes this safe to ship on its own.

class OrderService {
    public function __construct(
        private ?PDO $db = null,
        private ?PaymentGateway $gateway = null,
    ) {
        $this->db      ??= new PDO(DB_DSN, DB_USER, DB_PASS);
        $this->gateway ??= new StripeGateway(STRIPE_SECRET_KEY);
    }
}

Step 4 — write a characterization test. Before improving anything, pin down what the code currently does, including behaviour you suspect is wrong. This test is not asserting correctness — it is a tripwire that tells you if your refactor changed observable behaviour.

public function test_charge_currently_returns_true_for_valid_order(): void {
    $service = new OrderService($this->fakeDb(), $this->fakeGateway(ok: true));
    // Documents today's behaviour, whatever we think of it.
    $this->assertTrue($service->charge(42));
}

Step 5 — the focused unit test. With the dependency injectable, you can finally test the branch you actually care about, with no database and no network.

public function test_charge_returns_false_when_gateway_declines(): void {
    $service = new OrderService($this->fakeDb(), $this->fakeGateway(ok: false));
    $this->assertFalse($service->charge(42));
}

The order matters more than the technique. Extract, then inject, then characterize, then test. If you write the focused test first you have nothing to catch you when the extraction goes wrong — and step 2 is exactly where it usually does.

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
CallTextMessage