Short answer: In a migration, automated tests are the only practical proof that the new system does what the old one did. Build them before you change anything:

  • Record what the system does today as a golden master.
  • Compare old and new on the same inputs for every slice.
  • Add unit tests as business rules leave the forms.

The same suite then runs on every release for years. In our illustrative model, a team releasing quarterly spends 78 person-days a year on manual regression testing; with the suite in place it spends 30, and the suite pays for itself after about five releases. The tests are also the one piece of documentation that cannot go out of date.

Key points

  • Legacy code is code without tests. That is Michael Feathers’ definition, and it explains why old systems are slow and risky to change.
  • In a legacy Delphi system, testing starts from the outside: database rows, reports and files. Unit tests follow as rules are pulled out of the forms.
  • The old system is the specification. Every difference between old and new output is explained and classified before users switch.
  • The hard parts are known: time, generated keys, row order, rounding, locale settings, personal data in test data, and flaky UI tests. Each has a standard fix.
  • The saving grows with release frequency. Monthly releases would cost 234 person-days a year of manual regression in the model, more than one full-time person; automated, 70.
  • Tests are executable documentation. A rule written as a test fails the build when it stops being true; a Word document just goes stale.

Why are tests the centre of a migration?

Because “the new system works” means “it behaves like the old one where it should”, and only a comparison of outputs can show that. The old system is the only complete specification you have. Its documentation is years out of date, and the people who knew why each rule exists are retiring (see the Delphi talent pipeline).

Michael Feathers put it bluntly in Working Effectively with Legacy Code: “To me, legacy code is simply code without tests.” And: “Without them, we really don’t know if our code is getting better or worse.” A migration without tests ends one of two ways. Either people test by hand, release after release, for as long as the system lives. Or the users find the differences in production.

The tests do three jobs over the life of the system:

  1. Proof, during the migration. Each new slice must produce the same results as the Delphi code it replaces.
  2. Safety net, after it. Every change, every release and every platform upgrade runs against the same suite.
  3. Documentation, always. The tests state what the system does, in a form that is checked every day.

What kinds of test does a migration need?

Several, built in a specific order, because in a legacy system most of the code cannot be tested from the inside yet.

Test What it checks Built when Speed Typical tools
Golden master (characterisation) Database rows, reports and files after real business scenarios First, before any change Minutes A harness of your own, SQL, approval files
Parallel-run comparison Old and new system on the same inputs For each slice Minutes to hours Same harness, run against both
Unit tests of business rules One rule, called directly As rules leave the forms Milliseconds DUnitX or DUnit; xUnit.net, NUnit, MSTest; JUnit
API and contract tests Each endpoint against its contract As APIs are built Seconds OpenAPI-based tests, Postman collections
UI scenario tests A few critical end-to-end journeys Sparingly Slow TestComplete for Delphi desktop applications; FlaUI for .NET desktop; Playwright on the web

In a typical Delphi system the business rules live inside button-click handlers (see why legacy systems change slowly). No unit test can call them. So the suite grows from the top down: the golden master first, then unit tests as each rule is extracted. By the end it has the usual shape, with many fast unit tests and a small system level, and the golden master has become the regression suite.

How the test suite changes shape during a migration: upside down at first, a normal pyramid at the end

How do you build a golden master for a Delphi system?

Run real business scenarios on a fixed copy of the data, write the results out as text, approve the first run, and compare every later run against it. A characterisation test, in Feathers’ terms, records what the code actually does, not what it should do. Bugs included.

  1. Choose the scenarios. Start with what the business would notice first: month-end invoicing, pricing, stock movements, tax. Add the hotspots, the files that change most (article 10 shows how to find them with git log).
  2. Prepare the data. A copy of production, masked inside the client’s environment, restored to the same state before every run. Under the GDPR, masked data can still be personal data; our method article covers how we handle it.
  3. Drive the scenario. Through a batch job, a scripted UI scenario, or a call to code you have already extracted.
  4. Write the outputs as stable text. Sorted rows, fixed number and date formats, no generated keys.
  5. Approve the first run. It becomes the baseline. From then on, any difference fails the test and leaves a “received” file next to the “approved” one, so a developer can see exactly what changed.

A month-end invoicing scenario as a DUnitX test:

procedure TInvoicingGoldenMaster.MonthEndInvoicing;
begin
  RestoreTestDatabase('month-end-2026-03');     // masked copy, fixed state
  RunMonthEndInvoicing(EncodeDate(2026, 3, 31)); // the date is a parameter, not Now
  AssertMatchesApproved('MonthEndInvoicing',
    DumpRows('select INVOICE_NO, CUST_ID, NET, VAT, TOTAL from INVOICE ' +
             'where PERIOD = ''2026-03'' order by INVOICE_NO'));
end;

The helpers are short. FieldText writes each value with fixed format settings, so the output is the same on every PC, and AssertMatchesApproved does the comparison:

function FieldText(F: TField; const FS: TFormatSettings): string;
begin
  if F.IsNull then
    Result := '<null>'
  else if F.DataType in [ftCurrency, ftBCD, ftFMTBcd] then
    Result := CurrToStr(F.AsCurrency, FS)
  else if F.DataType = ftFloat then
    Result := FloatToStr(F.AsFloat, FS)
  else if F.DataType in [ftDate, ftDateTime, ftTimeStamp] then
    Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', F.AsDateTime, FS)
  else
    Result := F.AsString;
end;

procedure AssertMatchesApproved(const Name, Actual: string);
var
  Approved, Received: string;
begin
  Approved := TPath.Combine('approved', Name + '.approved.txt');
  Received := TPath.Combine('approved', Name + '.received.txt');
  if TFile.Exists(Approved) and
     (TFile.ReadAllText(Approved, TEncoding.UTF8) = Actual) then
  begin
    if TFile.Exists(Received) then
      TFile.Delete(Received);
    Exit;
  end;
  TFile.WriteAllText(Received, Actual, TEncoding.UTF8);
  Assert.Fail('Output differs from ' + Approved + '; see ' + Received);
end;

DumpRows opens the query and joins FieldText for each field with tabs, one line per row, using TFormatSettings.Create('en-US'). The fixed format settings matter: without a TFormatSettings parameter, Delphi’s CurrToStr, FloatToStr and even the : in FormatDateTime use the global FormatSettings, which start from the PC’s regional settings. A Hungarian PC writes 1234,50; an English one 1234.50.

We know of no maintained approval-testing library for Delphi, which is why the helpers are your own; they rarely grow beyond a page. DUnitX needs Delphi XE2 or later and has shipped with RAD Studio since XE8; Embarcadero recommends it over the older DUnit. On Delphi 7, DUnit and TStringList.LoadFromFile do the same job. TestInsight, a free IDE plug-in for Delphi XE and later, runs either framework inside the IDE.

How do you prove that old and new behave the same?

Run the same recorded inputs through both systems, each on its own copy of the database, normalise the outputs, and compare them record by record. Every difference falls into one of three classes, and only the business owner can accept a change.

Golden master flow: recorded inputs through the old and the new system, normalise, compare, classify each difference

At database level, plain SQL finds the differences. Run it both ways round:

-- Rows the old system wrote that the new one did not (SQL Server, PostgreSQL;
-- use MINUS on Oracle). Swap the two queries for the other direction.
SELECT INVOICE_NO, CUST_ID, NET, VAT, TOTAL FROM old_run.INVOICE
EXCEPT
SELECT INVOICE_NO, CUST_ID, NET, VAT, TOTAL FROM new_run.INVOICE;

For rules, export cases from the Delphi system once, with its results, and let the new code prove it gives the same answer. In Java with JUnit:

@ParameterizedTest(name = "case {0}: tier {1}, gross {2}")
@CsvFileSource(resources = "/golden/net-price.csv", numLinesToSkip = 1)
void matchesDelphi(String caseId, String tier, BigDecimal gross, BigDecimal delphiNet) {
    BigDecimal net = pricing.netPrice(tier, gross);
    assertEquals(0, net.compareTo(delphiNet),       // compareTo: 2.0 equals 2.00
        () -> "Case " + caseId + ": Delphi " + delphiNet + ", new " + net);
}

In .NET, snapshot testing does the approval-file work for you. The Verify library writes *.received.* and *.verified.* files, and by default replaces GUIDs and dates with placeholders, which removes much of the noise (switch that off where dates are business data). With xUnit.net v3 and a fixed clock:

using Microsoft.Extensions.Time.Testing;
using static VerifyXunit.Verifier;

public class MonthEndInvoicingTests
{
    [Fact]
    public Task MatchesApprovedOutput()
    {
        var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 31, 18, 0, 0, TimeSpan.Zero));
        var invoices = new MonthEndInvoicing(TestDb.Connection, clock).Run("2026-03");
        return Verify(invoices.OrderBy(i => i.InvoiceNo));
    }
}

TimeProvider is part of .NET since version 8, and FakeTimeProvider comes from the Microsoft.Extensions.TimeProvider.Testing package. Code that asks the provider for the time, instead of calling DateTime.Now, gives the same answer on every run. Where Delphi and Java disagree lists the type differences (Currency, Round, TDateTime) that cause most of the differences these tests find.

What makes testing a legacy system hard?

The same handful of problems on almost every project, and each has a standard fix.

Problem What it looks like What to do
Rules inside the forms Nothing can call the pricing rule without opening the screen Test from the outside first; extract the rule, then unit-test it
Time Code calls Now; month-end logic behaves differently on the 31st Pass the date in, or inject a clock: a function variable in Delphi, TimeProvider in .NET, java.time.Clock in Java
Generated keys and sequences Invoice IDs differ between runs Compare on business keys; normalise IDs away
Row order Queries without ORDER BY return rows in any order Sort every output before comparing
Rounding Delphi’s Round is banker’s rounding; Currency has four decimals Agree the rounding rule per value; the golden master shows what the business has been getting
Locale Number and date formats follow each PC’s regional settings Fixed format settings in tests; build and test machines set like production
Hidden outputs E-mails, files in shared folders, Excel via COM, printers Redirect them in the test environment and add them to the golden master
Personal data Test data copied from production Mask inside the client’s environment, consistently, and treat the result as personal data
Old bugs the business relies on A report has summed a column wrongly since 2011, and finance corrects for it Reproduce it, document it as legacy behaviour, and fix it later as a separate, visible change
UI automation Custom-drawn controls, such as data grids, expose little to generic UI-automation tools; tests break when a button moves Few UI tests, only on critical journeys; test the logic below the screen
Flaky tests A test fails one run in fifty for no reason Quarantine it and fix the cause: shared data, timing, order
Slow suites Restoring the database takes longer than the tests Database snapshots or containers; unit tests on every commit, the full golden master nightly

Flakiness is not a small-team problem. At Google, almost 16% of tests showed some flakiness, and about 1.5% of all test runs reported a flaky result (Google Testing Blog, 2016). A suite that cries wolf gets ignored, so deterministic data and a fixed clock are worth their cost from day one.

Brittle tests are the other trap. Software Engineering at Google defines a brittle test as one “that fails in the face of an unrelated change to production code that does not introduce any real bugs”, and warns that otherwise “test maintenance will inevitably consume a larger and larger proportion of the team’s time”. The fix is to test behaviour through public interfaces, not implementation details: the invoice totals, not the order in which methods are called.

UI automation needs particular care in Delphi. Delphi 13 added UI Automation support to the VCL, but older applications expose only what the underlying Windows controls do. Tools with Delphi-specific support, such as TestComplete, see more of the application when it is compiled with debug information. Keep UI tests to the few journeys that matter most.

How do you automate it?

Put the suite in the build pipeline and decide what runs when, so fast tests guard every commit and slow ones run every night.

When What runs Time
Every commit Build, unit tests of extracted rules, API contract tests Minutes
Every night Full golden master on a freshly restored database, coverage report, a few UI scenarios An hour or two
Every release Parallel-run comparison for slices in flight; short exploratory session by people Hours
Every platform upgrade The whole suite on the new Delphi, .NET, Java or database version As nightly

The last row is easy to miss. The same suite that proves the migration also proves the next upgrade: Delphi 7 to Delphi 13, .NET 8 to .NET 10, or a new database version. Without it, each upgrade needs its own round of manual testing.

Delphi builds from the command line with MSBuild, so a Windows build agent with RAD Studio installed can run the tests on every commit. A GitHub Actions job, for example:

jobs:
  delphi-tests:
    runs-on: [self-hosted, windows, rad-studio]   # RAD_STUDIO_BIN: RAD Studio's bin folder on the agent
    steps:
      - uses: actions/checkout@v4
      - name: Build and run the tests
        shell: cmd
        run: |
          call "%RAD_STUDIO_BIN%\rsvars.bat"
          msbuild tests\RulesTests.dproj /t:Build /p:Config=Release /p:Platform=Win64
          tests\Win64\Release\RulesTests.exe --exitbehavior:Continue --xmlfile:test-results.xml

--xmlfile and --exitbehavior are DUnitX’s own command-line options. The test project adds DUnitX’s NUnit XML logger (TDUnitXXMLNUnitFileLogger) and sets a non-zero exit code when a test fails, as DUnitX’s example runner does, so the build stops and the CI server can display the results. On the new stack, dotnet test or mvn verify does the same, and Testcontainers starts a real SQL Server, Oracle, PostgreSQL or Firebird database for the tests in a container, in both .NET and Java.

DORA’s research is clear about who should own all this: “When developers are primarily responsible for creating and maintaining suites of automated tests, this drives improved performance.” When another group owns them, it warns, “test suites are frequently in a broken state”. Testers and business analysts choose the scenarios; developers keep the suite green.

How much work does it save?

In our illustrative reference case, automation saves 48 person-days a year at quarterly releases, pays back after about five releases, and makes monthly releases affordable. The model is model/test_effort_model.py; every input is an assumption you can replace with your own:

  • Manual regression round: 15 person-days, for example three people for a week, plus 30% rerun after fixes. 19.5 person-days per release.
  • Building the automated suite: 60 person-days, once.
  • Still manual after automation: 20% of the old round, for exploratory testing and a UI smoke test.
  • Upkeep: 2 person-days per release to review and approve intended changes and fix broken tests, plus 10 a year for test data and the pipeline.
Illustrative reference case, 4 releases a year Manual Automated
Effort per release, person-days 19.5 7.5 (including upkeep)
Effort per year 78 30
One-off build 60
Break-even after about 5 releases, 15 months
Total over 5 years 390 210
Saved over 5 years 180 person-days, about 0.8 of a full-time year

Cumulative regression testing effort over five years, manual versus automated, illustrative reference case: break-even at about release 5

The bigger effect is on release frequency. Manual regression costs the same every release, so it caps how often you can release. Releasing monthly would take 234 person-days a year of manual testing in the reference case, more than one full-time person. Automated, it takes 70. That is why legacy teams batch changes into a few risky releases, and why DORA’s low performers release monthly to six-monthly with a 40% change failure rate.

Yearly regression testing effort by number of releases a year, manual versus automated, illustrative reference case

During a migration, releases are more frequent anyway: Delphi releases continue while new slices go live. At 12 releases a year, the suite pays back after about four or five releases.

When it does not pay back: the break-even depends mostly on how big the manual round is and what the suite costs to build. Break-even, in releases:

Manual round, person-days Build 30 Build 60 Build 120 Build 180
5 30 60 120 180
10 4.6 9.2 18.5 27.7
15 2.5 5.0 10.0 15.0
25 1.3 2.6 5.2 7.8

A system whose whole regression check takes one person a week, released a few times a year, will not repay a large automated suite. A few golden-master scenarios are still worth having for the migration itself.

What the model leaves out is mostly on the benefit side:

  • Fewer defects. In a study of four industrial teams at Microsoft and IBM, pre-release defect density fell by 40–90% compared with similar projects that did not write tests first, while the teams estimated that initial development took 15–35% longer (Nagappan et al., 2008).
  • Less debugging. In Stripe’s 2018 survey, developers estimated that 17.3 hours of a 41.1-hour week go on maintenance work such as debugging and dealing with bad code. A failing test points at the change that broke something within minutes; a user report points at a symptom weeks later.
  • Faster feedback. A manual round takes a week; the unit tests take minutes and the golden master runs overnight.
  • Upgrades. Each platform upgrade reuses the suite instead of needing a new round of manual tests.

Why are tests the best documentation of a legacy system?

Because they are checked every day. A specification document drifts from the code the first time someone is in a hurry. A test that stops matching the code fails the build. Software Engineering at Google puts it this way: “Clear, focused tests that exercise one behavior at a time function as executable documentation.”

  • Test names read as business rules. A list such as TierAOver1000GetsFivePercentOff, TierAAtExactly1000GetsNoDiscount, CreditHoldBlocksConfirmation tells a new developer more than a page of prose, and every line is known to be true.
  • Approved outputs are readable by the business. The approved month-end invoice run is a document an accountant can check. When it changes, the business owner signs the change off.
  • Knowledge stays when people leave. The reasons behind a rule can be written next to the test that guards it. That is how we turn an expert’s memory into something that outlives their retirement (see the talent pipeline article).
  • Evidence for auditors. A dated record that every release passed the same suite is the kind of evidence change-management rules expect; see legacy systems and EU law.

Gojko Adzic calls this “living documentation” in Specification by Example. It stays alive only if the tests stay clear: one behaviour per test, named for the rule, with no clever set-up hiding what is checked.

When not to invest this much

Match the suite to the system’s future.

  • The system retires within a year: a short manual checklist, plus golden masters for the data you migrate.
  • It rarely changes and the regression round is small: see the break-even table above.
  • Do not chase a line-coverage percentage. Cover the 20 to 30 rules the business cares about most. Line coverage, from DelphiCodeCoverage or coverlet, shows what no test touches; mutation testing (Stryker.NET for .NET, PIT for Java) shows whether the tests would notice a change.
  • Do not automate every screen through the UI. UI tests are the slowest and most brittle layer. Test the logic below the screen.

How we approach it

PannonRex has re-engineered business-critical systems since 1994. Tests come before code in every migration we run:

  1. The audit lists the scenarios and rules the business cannot afford to get wrong, and the hotspots where change is most frequent.
  2. The golden master comes first, on masked data, before the first new line of code.
  3. Each slice passes a parallel run before users switch (see how we migrate Delphi to Java and the web).
  4. Developers own the suite; our business analysts and testers define the scenarios with your key users.
  5. The suite and the pipeline are handed over with the system, so your team keeps the safety net.

On the refinery platform in our oil and gas case study, the same Postman collections served as API tests and response-time checks, and regression testing ran before anything reached the stable build.

Frequently asked questions

How much test coverage do we need?

Enough to cover the rules and scenarios the business would notice first. Start with 20 to 30 and grow with every change. A coverage percentage is a guide to gaps, not a target.

Who should write the tests, developers or testers?

Both, with different jobs. Testers, business analysts and key users decide what must be checked; developers write and maintain the automated tests. DORA’s research links developer ownership to better delivery performance.

Can AI write the tests for us?

AI assistants can draft unit tests and test data quickly, and we use them. But a characterisation test is only as good as the recorded output of the real system, and every generated test needs a person to check that it asserts something the business cares about.

Can we add tests to a Delphi 7 system without upgrading it?

Yes. A golden master drives the system from outside, so it needs no change to the code. Unit tests can use DUnit, which works on Delphi 7.

Do tests slow development down?

At first, a little: the teams in the Nagappan study estimated 15–35% more initial development time. They also shipped 40–90% fewer pre-release defects. In maintenance, the time comes back with every release that does not need a manual round.

What happens to the tests after the migration?

They stay. The golden master becomes the new system’s regression suite, and it runs again on every future upgrade.

Want to know what your system does before you change it? Talk to an engineer about a technical audit, or see how our re-engineering practice works.

Sources

Checked 6 October 2026.