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 capture | How | Pitfalls |
|---|---|---|
| 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 services | Record 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 others | Verify.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 state | Reset 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. |
| Screens | Drive 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 needScrubInlineDateTimeswith the format. - Generated values. Sequence numbers and random tokens get
ScrubMemberorIgnoreMember. 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.
- The agent never edits tests or verified files. Enforce it with tool permissions and a CI check that fails if a
.verifiedfile changes without an approver. - 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.
- 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
- Pearson InformIT: Working Effectively with Legacy Code, Michael Feathers (2004)
- InfoQ: Characterization testing (term coined by Michael Feathers)
- VerifyTests: Verify snapshot testing library for .NET
- Verify docs: parameterised tests with xUnit v3
- Verify docs: Guid scrubbing
- Verify docs: date scrubbing
- Verify docs: ordering
- Verify docs: serializer settings (ScrubMember, IgnoreMember)
- ApprovalTests.Net repository (maintenance status)
- VerifyTests: Verify.Http
- VerifyTests: Verify.SqlServer
- VerifyTests: Verify.HeadlessBrowsers
- WireMock.Net: proxying and recording
- Playwright for .NET: mock APIs and replay from HAR
- Playwright for .NET: aria snapshots
- Playwright Test: visual comparisons
- Microsoft Learn: Database snapshots (SQL Server)
- Claude Code docs: Best practices (give Claude a way to verify its work)