← Back to blog

Characterisation Tests: How to Prove a Migrated System Still Behaves the Same

The safety net for AI-assisted migrations: how golden-master tests record what your legacy system does today, and how to use them to accept the new one.

A characterisation test records what a system does today so you can prove that a migrated version does the same. You run the legacy system with real inputs, save its outputs as an approved “golden master”, then run the new system with the same inputs and compare. Every difference is either a migration bug or a change someone agreed to in writing.

Every migration I run starts here. It turns “it still works” from an opinion into a test result, and it is what lets AI coding agents do most of the rewriting without me losing sleep over hidden business rules.

What is a characterisation test?

Michael Feathers named the idea in Working Effectively with Legacy Code, published by Pearson in September 2004. He describes characterisation tests as tests that characterise the actual behaviour of a piece of code. The word “actual” matters. A specification test asks whether the code is right. A characterisation test only records what it does, so if the old system rounds VAT in an odd way, the test records the odd rounding.

You will also see the names golden master testing, approval testing and snapshot testing. The mechanics are the same: capture an output, store an approved copy, compare on every run, and make a human approve any change. For a migration this is exactly the right tool. The legacy system is the only complete specification you have, and the tests read it for you.

How does golden master testing work in .NET?

In .NET the standard library is Verify. It serialises the result of a test to a file named after the test, such as InvoiceTotalsTests.Totals_match_legacy.verified.txt. A failing run writes a .received. file next to it, a diff tool shows the difference, and you accept a change by approving the received file. Verified files go into source control; received files do not. It works with xUnit v3, NUnit, MSTest, TUnit and others. The older ApprovalTests.Net library now states on its repository that it is not actively maintained and points to Verify.

Here is the shape of a characterisation test for a hypothetical invoice calculator. It assumes the Verify.XunitV3 package and implicit usings, as in the Verify documentation. Each case is a real order captured from production and anonymised.

public class InvoiceTotalsTests
{
    [Theory]
    [InlineData("standard-order")]
    [InlineData("vat-exempt-customer")]
    [InlineData("discount-over-threshold")]
    public Task Totals_match_legacy(string caseName)
    {
        var order = TestCases.LoadOrder(caseName);

        // Run once against the legacy build to create the .verified.txt files,
        // then against the migrated build. Any difference fails the test.
        var invoice = InvoiceCalculator.Calculate(order);

        return Verify(invoice)
            .UseParameters(caseName)
            .ScrubMember("InvoiceNumber"); // comes from a sequence, differs per run
    }
}

This works directly when the legacy code is .NET Framework and a test project can call it. For VB6, classic ASP or a system you can only reach over the network, capture behaviour at the boundary instead: HTTP responses, database changes and generated files. The VB6 migration guide and the WebForms migration guide cover where those boundaries usually sit.

What should you capture, and how?

Capture behaviour wherever the outside world can observe it. Internal structure is going to change, so tests tied to it will fight the migration.

What to captureHowPitfalls
Business calculations (prices, tax, totals)Call the code or service with recorded inputs and verify the whole result object.Rounding and floating point. Culture-specific number and date formats.
HTTP APIs and SOAP servicesRecord real traffic through a proxy (WireMock.Net can proxy and save mappings), then replay the requests against the new service and verify the responses.Tokens and timestamps in payloads. SOAP namespace prefixes and whitespace that differ without changing meaning, so normalise XML before comparing.
Calls the system makes to othersVerify.Http records the HttpClient calls made during a test and adds them to the snapshot.Retries and parallel calls change the order. Decide whether order matters.
Database stateReset to a known start (a SQL Server database snapshot can be reverted between runs), run the scenario, query the affected tables and verify the rows. Verify.SqlServer can record the SQL commands executed.Identity values, queries without ORDER BY, triggers and defaults that read the clock.
ScreensDrive both versions with Playwright. Compare structure with aria snapshots, or pixels with screenshot comparison. Record backend responses to a HAR file and replay them so both UIs see the same data.Rendering differs by OS, hardware and headless mode. A migrated UI rarely matches pixel for pixel, so compare structure and content first.
Reports and exports (PDF, CSV, Excel)Generate from fixed inputs and verify the file or its extracted text.Embedded creation dates, fonts and file metadata.

On screens, a note on tooling. Pixel comparison with toHaveScreenshot() belongs to Playwright Test, the Node.js runner. Playwright for .NET has aria snapshots through ToMatchAriaSnapshotAsync, and Verify.HeadlessBrowsers can snapshot pages driven by Playwright from a .NET test.

How do you handle timestamps, GUIDs and other non-determinism?

A characterisation test that fails at random is worse than none, because people learn to ignore it. Deal with each source of noise deliberately.

  • GUIDs and dates. Verify scrubs Guid, DateTime, DateTimeOffset, DateOnly and TimeOnly values by default. It replaces each distinct value with a numbered placeholder such as Guid_1, so the test still shows when two fields hold the same value. Dates inside strings need ScrubInlineDateTimes with the format.
  • Generated values. Sequence numbers and random tokens get ScrubMember or IgnoreMember. Scrubbing keeps the field visible; ignoring removes it.
  • Ordering. Verify sorts dictionaries by key. For lists whose order the old system never guaranteed, use OrderEnumerableBy. If users see the order on screen, the order is behaviour and should stay in the test.
  • Culture and time zone. Legacy servers often run with different regional settings from a CI container. Fix the culture and time zone in the test run, and inject a clock (TimeProvider in modern .NET) into the new code.

What does “the same” mean in a migration?

Decide this per output before you start. Money should match exactly. XML and JSON can match after normalisation. Screens usually need to match in content and structure while the styling changes.

Then decide what to do with bugs. My default is bug-for-bug compatibility, because downstream reports and users often depend on the odd behaviour, and a migration diff is easier to review when it contains no fixes. Each known bug gets one of three outcomes: keep it, fix it now, or fix it after cut-over. Fixing after cut-over is usually the cheapest, because the comparison stays clean until the switch.

Every intentional difference goes into a short register: the test name, the old behaviour, the new behaviour, who approved it and when. The updated .verified file is committed with a reference to that entry. Six months later, nobody has to guess whether a change was a decision or an accident.

How do characterisation tests become acceptance criteria?

Once the tests exist, a milestone can be accepted on evidence. A slice of the system is done when every characterisation test for that slice passes against the new code, and every difference is in the register with a signature. That is how I run fixed-price migrations: each milestone is accepted when the new code passes the tests recorded against the current system.

The quality of the suite depends on the inputs. Start with anonymised real cases sampled from production, add edge cases from the bug tracker, then run the legacy code under a coverage tool to find branches no case reaches. Testing and sign-off still take human time, which is why the calendar shrinks less than the coding effort, as I explain in the AI migration reality check.

How do AI coding agents use characterisation tests?

This is where the method pays for itself. An agent is given one slice of the system and its characterisation tests, which fail because the new code does not exist yet. It writes code, runs the tests, reads the diff between the received and verified files, and tries again until they pass. Anthropic’s own guidance for Claude Code makes the same point: give the agent a check it can run, including a script that diffs output against a fixture, and it can iterate without a human in the loop.

Three rules keep that loop honest.

  1. The agent never edits tests or verified files. Enforce it with tool permissions and a CI check that fails if a .verified file changes without an approver.
  2. Hold back some cases. Keep a set of recorded cases the agent never sees and run them in CI. An agent that special-cases test inputs will fail those.
  3. A human reviews every pull request. Passing tests show the behaviour matches. Review checks security, design and the things no snapshot captures.

The agent writes the code, the legacy system writes the expected answers, and a human approves every change to those answers.

Sources

Frequently asked questions

What is the difference between a characterisation test and a unit test?

A unit test checks that code does what a specification says it should. A characterisation test records what the code actually does today, without judging whether that is correct. In a migration that is the useful question, because the old system's behaviour is the specification nobody wrote down. Both can use the same test framework; the difference is where the expected result comes from.

Is golden master testing the same as snapshot testing?

They use the same mechanics. You capture an output, store an approved copy, and compare every later run against it, with a human approving any change. Golden master usually means a recording of a whole legacy system's output. Snapshot or approval testing usually describes the library pattern, such as Verify in .NET, that stores those approved files next to the tests.

Should a migration preserve bugs in the legacy system?

By default, yes. Users and downstream systems often depend on odd behaviour, and fixing it mid-migration makes the comparison noisy. Record each known bug, then decide with the business owner whether to keep it, fix it now or fix it after cut-over. Every intentional difference goes into a register with an approver and a date, and the approved snapshot is updated to match.

Which .NET library should I use for characterisation tests?

Verify is the actively maintained snapshot library for .NET and works with xUnit, NUnit, MSTest and TUnit. The older ApprovalTests.Net library says on its repository that it is not actively maintained and recommends Verify. Extensions such as Verify.Http and Verify.SqlServer record HTTP calls and SQL commands, which helps when you are capturing behaviour at system boundaries.

Can AI coding agents write characterisation tests?

They can generate the test scaffolding and suggest inputs quickly, but the expected results must come from running the legacy system, never from the agent's idea of what the code should do. Once the snapshots exist, agents are very effective at writing new code until the tests pass. A human should own every change to an approved snapshot file.

See characterisation tests on your own system

The Modernisation Blueprint migrates one real screen or service with passing characterisation tests, so you see the method working on your code before you commit to the rest.

See the modernisation packages