Testing & Code Quality

Adding Tests to Legacy Code: What to Do Before Refactoring

For legacy code, characterization tests that lock in current behavior come before refactoring. This guide lays out the practical sequence for using seams to isolate code that cannot be tested because of dependencies.

4 min read
Cover image for Adding Tests to Legacy Code: What to Do Before Refactoring

“I can’t fix this code because I don’t know what will break if I touch it.”

We’ve all said this at least once when facing legacy code.

Let’s get straight to the point. Before refactoring, the one thing you must do is create “a test that captures the current behavior exactly”.

Before making the code look better, pin down what it actually does today.

Today I’ll walk you through the exact sequence I followed when adding tests to real legacy code. Following the order alone dramatically reduces the risk of breaking something while fixing it.


Why Do Tests Come Before Refactoring?

Let’s start by clarifying what refactoring means.

Refactoring improves the internal structure without changing observable behavior.

The key promise is “do not change the behavior.”

How do we verify that promise? With tests.

Without tests, we deploy based on a feeling that “it seems unchanged.” Deploying payment code based on a feeling… it’s frightening just to imagine.

That’s why Michael Feathers defines it this way in Working Effectively with Legacy Code: “Legacy code is code without tests”.

The criterion isn’t whether the code is old; it’s whether it has a safety net.


Pre-Refactoring Checklist (Key Summary)

Here’s the sequence for those in a hurry.

  1. Define the scope (boundary) of the code you’ll touch
  2. Add characterization tests that record current behavior
  3. Verify that the tests show green (passing)
  4. Only then refactor in small units
  5. Run the tests again after every step

Following these five steps in order already gets you halfway there. Let’s unpack them one by one below.

Code editor showing a green PASSED test bar, with a monitor covered in sticky notes
The moment one green light appears, the relief is real

How Do You Add Characterization Tests?

This is the question I hear most: “I don’t know the behavior, so what test should I write?”

You need to turn the idea around. Instead of writing the expected answer, lock in the current result produced by the code as the answer.

This is called a Characterization Test.

The method is surprisingly simple. Start by putting in any rough value and run the test. It fails and tells you, “This was the actual value.” Paste that value in as-is, and you’re done.

// 1) Enter an intentionally incorrect value because the actual return value is unknown
@Test func Discount calculation_current behavior() {
    let result = calcDiscount(user: user, cart: cart)
    #expect(result == 0)   // Fail and reveal the actual value
}

// 2) Lock in the actual value shown in the failure message (e.g.: 1500))
//    #expect(result == 1500)

Now this test becomes a guard that protects the fact that “this code originally returns 1500.”

If a refactoring mistake later produces 1200, the test immediately catches it with a red light.

We are not judging whether the code is good or bad yet. The goal is simply to capture its current form.


What Should You Do When Tests Won’t Attach?

This is the real wall in legacy code. When untouchable things such as a DB, external API, or current time are embedded in the middle of a function, the test cannot run.

Feathers’ concept of “seam” helps here. Slightly interrupt the code flow to create a point where you can insert fake values.

The safest approach is to extract exactly the one required line into a function parameter.

For example, if the function directly calls 현재시간(), change it only so it receives that value as an argument. Then the test can supply any time you want.

One warning: even this minimal change made to add tests must be as mechanical and careful as possible. This area still has no safety net.


Frequently Asked Questions (Q&A)

Q. What coverage percentage should I reach before starting?

You don’t need to cover everything. Wrapping the exact part you’ll touch now—that boundary—is enough. 100% is often a trap, not a goal.

Q. What if I don’t have time to add tests?

I know that pressure well. In that case, wrap just the one function you need to fix with a characterization test before you begin. Five minutes can prevent the worst incident.

Q. Is it okay if the tests are messy?

Yes. Characterization tests are like temporary scaffolding. Once refactoring is complete and the code is clean, you can naturally clean up the tests too.

Hands typing on a mechanical keyboard in front of a screen showing a TEST OK check
Even wrapping one function makes your hands feel much lighter

It all comes down to one sequence: capture it (test) → change it (refactoring) → verify it.

Legacy code is scary not because the code is bad, but because it has no safety net. Pick just one function today and add a characterization test. From there, everything feels much easier. I’m rooting for you!

Continue reading