A characterization test does not assert what a system should do. It records what the system does do, so that when you change it you find out immediately if the behavior moved. Michael Feathers named the technique in Working Effectively with Legacy Code twenty years ago, and it is still the single most useful thing you can do to a legacy system before touching it.
On old Microsoft stacks it is also the hardest, because the code was never written to be tested and the runtimes do not cooperate. Here is how we do it.
Pick the boundary
Unit tests want a function with inputs and outputs. VB6 forms, Classic ASP pages, Access VBA modules, and FoxPro PRG files do not have those; they have event handlers that mutate global state, read from the database, and write to the screen. Trying to unit-test them means refactoring first, and refactoring untested code is the thing you are trying to avoid.
So characterize at a boundary the code already has:
- The database. For most of these systems, the database is the real interface. Capture the state before an operation, drive the operation, capture the state after. The diff is the behavior.
- Files and printouts. FoxPro and VB6 systems generate reports, exports, EDI files, and label printouts. Those outputs are contracts with customers. Capture them byte for byte.
- HTTP. For WebForms and Classic ASP, record request and response pairs, including the ViewState and the redirect chain. A recorded session of "log in, open order 1042, change the quantity, save" is a test.
- COM interfaces. Some VB6 systems expose COM servers used by other applications. Those method signatures and results are a boundary worth locking down.
Drive the old system
You need to execute the legacy code on demand. Options, roughly from least to most invasive:
- Replay recorded HTTP against the running application (WebForms, Classic ASP). No code changes.
- UI automation against the running desktop app (VB6, Access, FoxPro). Slow, brittle, but zero code changes; useful for the five flows that matter most.
- A thin test host that loads the legacy modules and calls their entry points. For VB6 this means a small harness EXE referencing the same modules; for FoxPro, a PRG that calls the existing procedures; for Access, a VBA test module that calls the existing functions. Requires a build environment for the old stack, which is often the first real obstacle.
- Stored-procedure tests where the business logic lives in SQL Server. Often the cheapest win, because the database is modern even when the application is not.
In a two-week assessment we typically use options 1, 2 and 4, and stand up option 3 only if a phase will need it.
Where AI helps
AI tooling changes the economics of this work in two specific places.
Reading the code. A model can read a 400,000-line VB6 codebase and produce a map of what calls what, which globals are read where, and which tables each form touches, in hours rather than weeks. It is wrong in places. It is still the fastest way to know where to point the tests.
Generating test scaffolding in bulk. Given a recorded database diff or HTTP session, a model can write the harness code, the fixtures, and the assertions quickly and without complaint. Writing two hundred of those by hand is what stopped teams doing characterization testing in the past.
Where AI misleads
The generated tests will assert everything, including things that are not contracts. A test that locks in the exact ordering of an unordered query result, or the timestamp in a log line, or a floating-point value to fifteen digits, will fail the moment the new implementation does anything reasonable. Worse, a test suite full of those trains everyone to ignore failures.
The engineering work is curation: deciding which recorded behaviors are contracts the business depends on and which are accidents. That requires someone who understands what the system is for, and it is the one step we do not let a model do unsupervised. Expect to keep about a third of what is generated.
The other trap is the bug that is a feature. Every system this old has behavior that is wrong by any reasonable reading and that some customer or downstream process depends on. A characterization test locks it in, which is correct, but the migration plan needs a list of them and a decision for each: preserve, fix with the customer's agreement, or fix silently because nobody actually depends on it. Generated tests will not tell you which; a conversation with the person who has run the month-end for twelve years will.
Run it in CI
A harness that only runs on one engineer's laptop dies with the engagement. From the first week, put it in your CI, against a restored copy of a masked database, on a schedule. When it goes red because someone changed the old system (they will), that is the harness doing its job. And when the new implementation of a slice goes green against the same harness, that is the only evidence you should accept that it is ready to cut over.
We have written up how we choose the first slice to migrate once the harness exists in sequencing a strangler migration.