Golden-master tests for legacy web apps

Characterization testing has a reputation for being abstract, so this is the concrete version. By the end of this tutorial you will have an xUnit project that replays HTTP requests against a running legacy web application, WebForms or Classic ASP, and fails the moment any recorded behavior changes. That harness is the safety net a strangler migration hangs from: run it against the old system to record the truth, then run the same tests against the new implementation and make them agree.

You need the legacy application running somewhere you can reach over HTTP, and the .NET 8 SDK or later on your machine. Nothing in the legacy application changes.

Set up the test project

Create the project

dotnet new xunit -o LegacyCharacterization
cd LegacyCharacterization

If you have not used xUnit with dotnet test before, Microsoft's unit testing tutorial covers the mechanics; here we only need [Fact] and the test runner.

Point it at the legacy app

Put the base URL in an environment variable rather than in code, because the whole point is to run the same tests against two systems:

public static class Target
{
    public static readonly Uri BaseUrl =
        new(Environment.GetEnvironmentVariable("CHARACTERIZATION_BASE_URL")
            ?? "http://localhost:8080/");

    public static readonly HttpClient Client = new(new HttpClientHandler
    {
        AllowAutoRedirect = false, // redirects are behavior; record them
        UseCookies = true,
    })
    { BaseAddress = BaseUrl };
}

AllowAutoRedirect = false matters. A legacy app that answers a request with a 302 to an error page "passes" under default settings, because the client follows the redirect and gets a 200. The redirect is the behavior you want to record.

Record what the system does today

Capture a page

A characterization test does not assert what should happen. It asserts what did happen last time:

[Fact]
public async Task CustomerList_RendersAsRecorded()
{
    var response = await Target.Client.GetAsync("customers/list.aspx?status=active");
    var body = await response.Content.ReadAsStringAsync();

    var snapshot = $"{(int)response.StatusCode}\n{Normalize(body)}";
    await GoldenMaster.Verify("customer-list-active", snapshot);
}

GoldenMaster.Verify compares the snapshot against a committed approved file, and writes a received file next to it when they differ:

public static class GoldenMaster
{
    public static async Task Verify(string name, string actual)
    {
        var approvedPath = Path.Combine("Approved", $"{name}.approved.txt");
        if (!File.Exists(approvedPath))
        {
            Directory.CreateDirectory("Approved");
            await File.WriteAllTextAsync(approvedPath, actual);
            return; // first run records the master
        }

        var approved = await File.ReadAllTextAsync(approvedPath);
        if (approved != actual)
        {
            await File.WriteAllTextAsync(
                Path.ChangeExtension(approvedPath, ".received.txt"), actual);
        }
        Assert.Equal(approved, actual);
    }
}

Commit the Approved directory to source control. It is not test data; it is the recorded spec.

Normalize the noise

Run that first test twice and it will fail, because legacy pages are full of values that change on every request. Normalization is where the engineering lives:

static string Normalize(string html)
{
    html = Regex.Replace(html,
        @"name=""__(VIEWSTATE|EVENTVALIDATION)""[^>]*value=""[^""]*""",
        @"name=""__$1"" value=""(elided)""");
    html = Regex.Replace(html,
        @"\b\d{1,2}/\d{1,2}/\d{4}( \d{1,2}:\d{2}(:\d{2})? ?(AM|PM)?)?\b",
        "(timestamp)");
    html = Regex.Replace(html,
        @"sessionid=[0-9a-f]+", "sessionid=(elided)",
        RegexOptions.IgnoreCase);
    return html;
}

Typical candidates to elide: ViewState and event-validation fields, timestamps, session identifiers in URLs or cookies, cache-busting query strings, and anything the page renders from DateTime.Now. Elide as little as you can. Every elision is behavior you have chosen not to protect.

Record a write, not just a read

Reads are the easy half. For a form post, record the response and the database effect together:

[Fact]
public async Task SaveCustomer_WritesWhatItAlwaysWrote()
{
    var form = new FormUrlEncodedContent(new Dictionary<string, string>
    {
        ["txtName"] = "NORTHWIND SUPPLY",
        ["txtTerms"] = "NET30",
        ["btnSave"] = "Save",
    });

    var response = await Target.Client.PostAsync("customers/edit.asp?id=1042", form);
    var rows = await Db.Query(
        "SELECT name, terms, updated_by FROM customers WHERE id = 1042");

    await GoldenMaster.Verify("save-customer-1042",
        $"{(int)response.StatusCode} {response.Headers.Location}\n{rows}");
}

For WebForms specifically, you will need to fetch the page first and echo back the __VIEWSTATE and __EVENTVALIDATION fields in the post. Wrap that in a helper once and forget it.

Run write tests against a restorable copy of the database, and reset it between runs. A characterization suite that mutates shared state orders its own failures.

Run the same tests against the new system

This is the payoff. When a slice is rebuilt, point the harness at it:

CHARACTERIZATION_BASE_URL=https://new-app.internal/ dotnet test

Every green test is a recorded behavior the new implementation preserves. Every red one is a decision: a bug you are deliberately fixing (update the approved file and note it in the migration log) or a regression (fix the code). There is no third category, and that is the point.

What to watch for

  • Culture and formatting. Legacy apps often render dates and currency with the server's regional settings. If the new system runs with a different culture, the diffs will tell you immediately; decide which is correct rather than normalizing it away.
  • HTML tidying temptations. The new stack will emit cleaner markup. For pages you intend to re-render, compare extracted text and data rather than byte-identical HTML. Keep byte-level comparison for file exports, reports and EDI output, where the bytes are the contract.
  • Suite size. A few dozen well-chosen recordings of the flows that touch money beat a thousand generated ones nobody reads. Keep the suite small enough that a red test is an event.