Safely Modifying a Legacy Business App That Has No Tests — Characterization Testing and Refactoring in Practice

· · Legacy Technology, Legacy Asset Reuse, Refactoring, Test Design, Characterization Testing, C#, .NET, Maintenance, Decision Table, Technical Consulting

“I know exactly what needs fixing. But the thought that touching it might break something else somewhere else keeps me from actually doing it” — this is something we hear constantly from people who have inherited a business application with no tests.

Many business applications written in VB6, .NET Framework, or Access have no automated tests at all. The specification document, if one ever existed, stopped being updated long ago, leaving “the code is the only specification” as the actual state of affairs. And yet the business keeps running, and requests to change the consumption tax rate, fix a report layout, or add a new business partner don’t wait for anyone.

On this blog, in “How Much Longer Will a VB6 App Keep Running? — A Realistic Path to .NET Migration,” we introduced the idea of treating the old system as a “living specification” and driving a migration forward by reconciling outputs against it. This article applies that same idea to a different situation: not migrating away, but modifying code that is running right now, in place. The central tool is the characterization test. Even for code with no tests at all, if you first pin down, in a test, the behavior you might be about to break, both refactoring and feature work become dramatically safer.

1. The Bottom Line First

  • Don’t fix it right away. First pin down the current behavior with a test. Even with no specification document, the output of the code as it currently runs is itself the specification.
  • The tool for that is the characterization test. Rather than “correct behavior,” it records “current behavior” — saving the output (reports, CSVs, calculation results, and so on) as the expected value as-is, and diffing before and after a change (the golden master method).
  • For structures you cannot slot a test into (logic written directly in a UI event handler, direct references to DateTime.Now or a file path), create a seam with the smallest possible change — method extraction plus interface injection. No major overhaul required.
  • Do not mix refactoring and feature work into the same commit. The pass criterion for refactoring is “zero diff”; for feature work it’s “only the intended diff.” Mix them, and you lose the ability to tell what a diff even means.
  • How much test infrastructure to build is decided by the scope of the change, times how many years the system has left, times the impact of a failure. Putting unit tests everywhere is not always the right answer — sometimes “characterization tests only” or “don’t touch it” is the correct call.
  • You can start without CI. A single test project and a folder of expected-value files are enough to fundamentally change your safety, even if you only ever run them locally.

2. Why Does Legacy Code “Break When You Touch It”?

Modifying legacy code is scary not because the code is old, but because there is no way to confirm whether the result of a change is correct.

Michael Feathers, in his book “Working Effectively with Legacy Code,” defined legacy code not as “code that is simply old” but as “code without tests.”1 His reasoning is that without tests, there’s no fast way to confirm, at every change, whether the code got better or worse. Under this definition, code written yesterday is legacy code too, if it has no tests.

Code with no tests sets a vicious cycle spinning:

  1. With no tests, you don’t know the blast radius of a change, so it’s scary
  2. Because it’s scary, you avoid touching the existing structure and settle for the smallest possible copy-paste plus an extra conditional branch
  3. These stopgap fixes pile up, and the code becomes even harder to read and even more fragile
  4. Being more fragile makes it even scarier (back to 1)

The way out of this cycle is not “summon the courage to do a big refactor.” The order is actually reversed: put a safety net (tests) in place first, remove the source of the fear, and only then fix things. But this runs into a chicken-and-egg problem. Writing a test requires a testable structure. But making the structure testable requires changing (refactoring) the code. That means changing code with no tests, without tests.

To resolve this contradiction, legacy-code modification proceeds in the following order:1

  1. Scoped to just the area around the change, pin down the current behavior from the outside (a characterization test)
  2. Inside that safety net, make the smallest possible change with an extremely low risk of breaking anything (such as method extraction), creating a slot where a test can be plugged in
  3. Once the structure allows fine-grained tests to be written, start on the change you actually wanted to make (refactoring or feature work)

The following chapters walk through steps 1 and 2 concretely.

3. Characterization Tests — Recording “Current Behavior”

3.1 How It Differs From a Normal Test

A normal test verifies correct behavior — “this is how it’s supposed to work according to the spec.” A characterization test is different. It records how the code actually behaves right now, deliberately withholding judgment on whether that’s correct.

Say, for instance, that the specification document doesn’t state whether rounding fractional amounts should round up, round down, or truncate. If the current code truncates, and the business has been running that way for ten years, then “it truncates” is, at minimum, the de facto specification. A characterization test pins this down exactly as-is, in the form “the current output is such-and-such.” Even if it turns out to be a bug, you pin it down first. Changing the behavior (fixing the bug) is done separately, as a deliberate change, only after the safety net is in place.

3.2 The Golden Master Method, Step by Step

For legacy code whose output operates at a coarse granularity, the golden master method is the most cost-effective form of characterization testing. The procedure is straightforward:

  1. Identify the output the target feature produces (report text, a CSV, a list of calculation results, and so on)
  2. Prepare representative input data, run the current code, and capture the output
  3. Save that output as-is as an expected-value file (the golden master) and check it into the repository
  4. From then on, run the test every time the code changes, confirming the diff between the output and the expected-value file is zero

A C# implementation doesn’t need any particular library — something as plain as this is enough:

[Fact]
public void MonthlyBillingSummary_GoldenMaster()
{
    // 1. Read a representative input (e.g. data pulled from production and masked)
    var input = File.ReadAllLines(TestDataPath("billing-input-202606.csv"));

    // 2. Call the existing logic as-is and capture the output string
    string actual = BillingReport.Generate(input);

    // 3. A missing expected-value file means either "the test environment is broken"
    //    or "this is the first run." Either way, don't silently let it pass --
    //    record it and fail unconditionally
    string expectedPath = TestDataPath("billing-expected-202606.txt");
    if (!File.Exists(expectedPath))
    {
        File.WriteAllText(expectedPath + ".candidate", actual);
        Assert.Fail("Expected-value file is missing. Review the contents of the " +
                    ".candidate file, and if it looks correct, commit it as the expected value.");
    }

    // 4. Verify an exact match against the saved behavior
    string expected = File.ReadAllText(expectedPath);
    Assert.Equal(expected, actual);
}

Avoid an implementation that, when the expected-value file is missing, saves the current output as the expected value on the spot and lets the test pass. If the expected-value file was never committed, or got placed in the wrong location, CI would then pass green without ever catching a regression. Have the first recording emit a candidate file (.candidate) as above and fail explicitly, so the flow is strictly one-way: a human reviews it, and only then is it committed as the expected value.

Because the Assert.Equal failure message alone is hard to work from once a diff shows up, in practice it speeds up investigation to also write the actual output to a separate file, like billing-actual-202606.txt, on failure, so you can compare it against the expected value with a diff tool such as WinMerge.

3.3 Choosing Inputs and Normalizing Output

Choose inputs as “representative plus boundary” cases. Take one or two ordinary cases, and add inputs that exercise whatever branches you find by reading the code — month-end closing, zero records, negative values, exception handling for a specific business partner, and so on. If you can use masked production data, that exercises the real-world branches most faithfully.

Normalize any non-deterministic values that leak into the output before comparing. A print timestamp, processing duration, a GUID, an auto-incremented number, and the like change on every run, so they’ll produce a diff every single time as-is. After generating the output, run a preprocessing step — for example, a regular-expression substitution turning Printed: 2026/07/17 16:00 into Printed: <DATE> — before comparing.

Here’s a rough guide to what kind of output is well suited to a golden master:

Type of output Suitability Notes
CSV / fixed-width files Excellent Can be saved and compared as-is. The first target you should go after
Reports (text, or the source data behind a print preview) Excellent Capture the string just before it becomes a PDF; avoid comparing PDF binaries directly
A list of calculation results (amounts, inventory counts, etc.) Excellent It’s fine to add a test-only method that dumps the results to a CSV
Content written to the database Good SELECT the table’s contents after the write, dump it as CSV, and compare
The screen display itself Limited Works if it can be reduced to a string. Automating screen operations requires a different toolset, covered in “Automated UI Testing for Windows Desktop Apps
Transmissions to an external system Limited Needs a seam (next chapter) to capture the data right before it’s sent

4. Building the “Seam” That Lets You Slot In a Test

Trying to write a golden master usually runs into a wall in a lot of legacy code: the logic is written directly inside a UI event handler, and you cannot run it without launching the screen. What you need here is a place where test code can substitute in and observe behavior — what Feathers calls a seam.1

4.1 Peeling Logic Off the UI With Method Extraction

The typical “before” state looks like this: calculation, database access, time dependence, and screen updates all living together in a single event handler.

// Before: everything written directly into the event handler
private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();                          // Direct DB access
    var now = DateTime.Now;                               // Depends on the current time
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)              // Only aggregate the current month
        {
            total += Math.Floor(row.Amount * 1.1m);        // A business rule for rounding
        }
    }
    lblTotal.Text = total.ToString("N0");                  // Reflected directly onto the screen
}

As-is, testing the current-month aggregation logic requires the screen, the database, and “today’s date.” The standard move to make this testable with a minimal change is to extract just the calculation into a method, turning the external dependencies (the DB results and the current time) into parameters. Visual Studio’s Extract Method refactoring (Ctrl+R, M) also cuts down on manual-rewrite mistakes.2

// After: only the calculation is extracted, taking "the DB results" and
// "the current time" as parameters
internal static decimal CalcMonthlyTotal(IEnumerable<SalesRow> rows, DateTime now)
{
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)
        {
            total += Math.Floor(row.Amount * 1.1m);
        }
    }
    return total;
}

private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();
    lblTotal.Text = CalcMonthlyTotal(rows, DateTime.Now).ToString("N0");
}

The event handler side is now three lines — load, calculate, display — and the extracted method can be tested with any row data and any date. Time-related boundaries, like month-end, month-start, or a leap year, can also be reproduced just by passing a date such as new DateTime(2028, 2, 29).

4.2 Making Dependencies Swappable With Interface Injection

For dependencies too pervasive to fix just by parameterizing (DateTime.Now referenced all over the place, a file path hard-coded, and so on), wrap the dependency behind an interface and inject it. Microsoft Learn’s best practices for .NET unit testing likewise cite a direct dependency on DateTime.Now as a classic example of something that can’t be controlled from a test, and describes wrapping it in an interface to introduce a seam as the fix.3

public interface IClock
{
    DateTime Now { get; }
}

public sealed class SystemClock : IClock
{
    public DateTime Now => DateTime.Now;
}

// The test side substitutes in an implementation that returns a fixed time
public sealed class FixedClock : IClock
{
    private readonly DateTime _fixed;
    public FixedClock(DateTime value) => _fixed = value;
    public DateTime Now => _fixed;
}

Adding IClock to an existing class’s constructor forces you to fix every call site, so during the transition period it’s realistic to add a parameterless constructor that defaults to SystemClock, alongside the new one, and fix call sites over time. Hard-coded file paths or DB connection strings can be wrapped the same way, behind a small interface that exposes only the “read/write” operations needed.

Note that Visual Studio also has a built-in refactoring for extracting an interface from an existing class (Extract Interface), which lets you carry out this kind of change mechanically.2

There is one principle to keep in creating a seam: the change that creates the seam must not alter behavior by a single millimeter. Both method extraction and interface injection are mechanical, highly behavior-preserving operations that the compiler and IDE can support directly. You’ll be tempted to “fix the logic while you’re at it” at this stage — resist that; it’s work for after the safety net is in place.

5. A Decision Table for How Far to Go

Both characterization tests and seam-building cost effort. Bringing every piece of legacy code up to the same testing standard is not realistic for a small or mid-sized team, and there’s no need to. There are three axes to the decision:

  • Scope of the change: a bug fix of a few lines, a feature addition, or something involving a structural change
  • The system’s remaining lifespan: due for migration or retirement in a year or two, or expected to keep running for five years or more
  • Impact if something breaks: cosmetic report layout damage, or a billing amount or inventory count that comes out wrong
Scope of change Remaining lifespan Impact if broken Recommended level
Minor (a few lines, a config value change) Short (up to ~2 years) Small (cosmetic display issues) Characterization tests only. Pin down the affected output, change it, confirm a zero diff, and stop there
Minor to moderate Short Large (handles money or inventory) Characterization tests only, but thicker. Widen the set of input patterns, including boundaries
Moderate (feature addition, logic change) Long (5+ years) Small to moderate Characterization tests plus unit tests scoped to just the area around the change (build a seam)
Moderate to large Long Large Characterization tests plus unit-test infrastructure, plus splitting the release into smaller units
Large (a structural overhaul is needed) Short Don’t touch it. Work around it operationally instead of modifying it, and put the effort into migration/replacement instead
– (there’s no change request at all) Don’t touch it. Don’t preemptively refactor code that’s already working

The bottom two rows of “don’t touch it” are not a passive default — they’re an active decision. Investing in the internal quality of a system with a short remaining lifespan doesn’t pay off. Put that effort instead into the migration decision laid out in “A Decision Table for Extending or Migrating a VB6 / Access Business App,” and into the design of whatever it’s migrating to.

Also, if you do go as far as building out unit-test infrastructure, you’ll need to draw a line between what belongs in a unit test and what should be left to an integration test (one that touches a real database or real files). We work through that line as a decision table in “Where to Draw the Line Between Unit Tests and Integration Tests” — worth reading alongside this article. Given that unit tests are supposed to be fast, isolated, and repeatable,3 it’s a good idea to keep characterization tests that touch a database or the filesystem in a separate project or separate execution unit from your unit tests.

6. Operational Rules — How Not to Break the Safety Net

A characterization test loses its purpose easily if you get the operational rules around it wrong afterward. Here are the three essential ones.

6.1 Don’t Mix Refactoring and Feature Work Into the Same Commit

Refactoring is a change that makes code easier to understand and maintain without changing its behavior.4 That means its pass criterion is zero diff against the golden master. Feature addition or bug fixing, on the other hand, has a pass criterion of only the intended diff showing up. Mix the two into a single commit, and once a diff appears, you can no longer tell whether it’s “the intended change” or “something broke.”

Type of change Handling of the golden master Pass criterion
Refactoring (structural change) Do not update Zero diff
Bug fix / feature addition (behavior change) Update after a diff review Only the intended diff
Seam-building (method extraction, interface injection) Do not update Zero diff
Changing the normalization rule for expected values Regenerate State the reason for the change in the commit message

The same applies at the release level. A “refactoring-only release” is supposed to leave behavior unchanged, so if an incident happens, you can immediately suspect the refactoring. Mix the two together, and that ability to isolate the cause stops working.

6.2 Update Expected Values in the Order “Review the Diff, Then Overwrite”

When you deliberately change behavior, update the golden master too. Fix the procedure in place:

  1. Generate the post-change output and visually review the diff against the current expected value
  2. Confirm the diff consists only of the intended change (investigate if even a single unintended line changed)
  3. Overwrite the expected-value file with the new output, and include it in the same commit as the code, so it lands in history together

The dangerous operating pattern is “the test went red, so overwrite the expected value to make it green.” Do that, and a regression gets recorded as “correct” as-is, and the safety net stops being a safety net.

6.3 Build the Smallest Setup That Runs Locally, Even Without CI

Even in a shop with no CI server, you can start today with a setup this minimal:

  • Add one test project to the solution (MSTest, NUnit, and xUnit all work, even staying on .NET Framework)
  • Put expected-value files and input data in a TestData folder, and version them alongside the code
  • Make it a team rule to manually run dotnet test (or Visual Studio’s Test Explorer) before every commit
  • Add one line to the release runbook — “run the tests and confirm a zero diff” — so nobody forgets to check the result

The better your logging is, the faster you can investigate when reconciling test results against the application’s own output. What to log is covered in “Minimum Requirements for a Custom Logger and an Integration-Test Checklist.”

7. Summary

  • Legacy code is “code without tests,”1 and the real reason it breaks when touched is that there’s no way to confirm the result of a change. Pin down the current behavior with a test before you fix anything.
  • A characterization test records “current behavior,” not “correct behavior.” With the golden master method — saving reports, CSVs, or calculation results as-is into expected-value files and diffing them — you can get started with nothing more than plain C# code.
  • For structures you can’t slot a test into, build a seam using method extraction and interface injection. Wrapping a dependency like DateTime.Now is a standard technique Microsoft’s own unit-testing guidance also describes.32
  • How much infrastructure to build is decided by scope of change times remaining lifespan times impact if broken. “Characterization tests only” or “don’t touch it” are both perfectly legitimate calls.
  • Operationally, don’t mix refactoring (zero diff is the pass condition) with feature work (only the intended diff is the pass condition),4 and always run an expected-value update through a diff review. Even without CI, just committing as a team to running tests locally changes your safety enormously.

At Komura Software LLC, we handle introducing characterization tests into existing business applications that have no tests, phased refactoring toward a testable structure, and helping sort out whether to invest in modification versus migration.

References

  1. Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004). On defining legacy code as “code without tests,” on the procedure of recording current behavior with a characterization test before starting a change, and on the concept of a seam for slotting in a test.  2 3 4

  2. Microsoft Learn, Extract and inline refactorings (Visual Studio). On the operating steps for Visual Studio’s Extract Method (Ctrl+R, M) and Extract Interface refactorings for C# / Visual Basic.  2 3

  3. Microsoft Learn, Unit testing best practices for .NET. On the properties of a good unit test (fast / isolated / repeatable / self-checking / timely), on the technique of wrapping an uncontrollable dependency like DateTime.Now in an interface to introduce a seam, and on keeping infrastructure dependencies out of unit tests and into integration tests instead.  2 3

  4. Microsoft Learn, Refactor code (Visual Studio). On the definition of refactoring as the process of changing code to make it easier to maintain, understand, and extend, without changing its behavior.  2

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

What is a characterization test?
It is a test that records the 'current behavior' as-is, rather than the 'correct behavior.' In legacy code where the specification document is long gone, there is often no way to check what the correct behavior even is, so you first save the output of the code as it currently runs (a report, a CSV, a calculation result, and so on) as the expected value, and mechanically confirm that the output hasn't changed before and after a modification. The basic pattern is to put this safety net of pinned-down behavior in place first, and only then move on to refactoring or adding features.
Where should I start with legacy code that has absolutely no tests?
The realistic approach is to write characterization tests scoped narrowly to just the area you're about to change. Covering the entire system with tests almost never pencils out in terms of effort, and there's no need to. First identify the output the target feature produces (a report, a CSV, what gets written to the database, and so on), save the output for representative inputs to a file, and pin it down. Inside that safety net, perform small, behavior-preserving refactorings such as method extraction to carve the logic into a testable shape, and only then start on the change you actually wanted to make.
Why shouldn't refactoring and feature work be mixed into the same commit?
Because once a diff shows up in the output, you lose the ability to tell what caused it. Refactoring is verified by confirming 'behavior did not change,' while feature work is verified by confirming 'only the intended spot changed behavior' — the pass criteria are opposites. If you mix the two, you can no longer tell whether a difference from the golden master is 'the intended change' or 'something broke.' It's safer to keep them separate: zero diff for a refactoring commit, only the intended diff for a feature-work commit.
When should I update the golden master (the expected-value file)?
Only at the moment you deliberately change behavior — that is, at a feature-addition or bug-fix commit. When updating it, visually review the diff between the old and new output, confirm that it contains only the intended change, and only then replace the expected value with the new output. If you mechanically overwrite the expected value just because the test turned red, you end up quietly absorbing a regression (an unintended behavior change) as 'correct,' which defeats the entire point of the safety net.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog