Perl Scripts ·
Testing Legacy Perl Code Before You Change It
By Manu Mathew, Technical Lead Engineer · Last updated 5 October 2026

A lot of businesses still run on legacy Perl code that nobody wants to touch. It works, it earns money, and the person who wrote it may have moved on years ago. Then a server upgrade, a new requirement or a security fix arrives, and someone has to change it.
The hard part is rarely the change itself. It’s not knowing what else might break. A small set of tests, written before you change anything, turns that guesswork into something you can check.
Quick answer
To test legacy Perl code with no tests, first record what it does today. Write characterisation tests that pin down current outputs, even odd ones, and for whole scripts save real inputs and outputs to compare after every change. Start with the code you’re about to touch, run everything with prove, and add tests as you go.
Why test legacy Perl code before changing it?
Because tests tell you what you broke before your users do. Old code often has behaviour that other systems quietly depend on: a date format, a column order, a rounding rule.
Without tests, you only find those dependencies when a report looks wrong or an invoice total is off by a paisa. With tests, you find them on your own machine, in seconds.
There’s a second benefit. Writing the tests forces you to read the code properly, which is usually where the real understanding of an old system begins.
What is a characterisation test?
A characterisation test records what the code does now, not what it should do. You run the code, note the result, and write a test that expects exactly that result.
It feels backwards at first. If the current behaviour is a bug, you still record it, and you add a comment. Fixing it becomes a separate, deliberate change rather than an accident.
Here’s a short example using Test::More, which ships with Perl. It checks a billing function that already exists:
use strict;
use warnings;
use Test::More;
use lib 'lib';
use Legacy::Billing;
my @items = ( { qty => 3, price => 19.99 }, { qty => 1, price => 5 } );
# Characterisation tests: record what the code does today,
# even where the behaviour looks odd.
is Legacy::Billing::invoice_total( \@items ), '64.97', 'no discount';
is Legacy::Billing::invoice_total( \@items, 10 ), '58.47', '10% discount';
is Legacy::Billing::invoice_total( [] ), '0.00', 'empty invoice';
done_testing;
Save it as t/billing.t and run prove -l t/ from the project folder. The Test::More documentation covers the other checks you can use, such as is_deeply for nested data.
How do you test an old Perl script you can’t easily change?
Treat the script as a black box: feed it fixed inputs and compare its output with a saved copy. This is often called a golden master test, and it needs no changes to the script at all.

First, run a small recording script once, before any change, to save today’s output as the baseline:
use strict;
use warnings;
for my $in ( glob 't/cases/*.in' ) {
( my $out = $in ) =~ s/\.in\z/.out/;
system("$^X bin/report.pl < $in > $out") == 0
or die "report.pl failed on $in\n";
}
Then a test compares every new run with that baseline:
use strict;
use warnings;
use Test::More;
for my $in ( glob 't/cases/*.in' ) {
( my $expected_file = $in ) =~ s/\.in\z/.out/;
my $got = `$^X bin/report.pl < $in`;
is $?, 0, "$in: exits cleanly";
open my $fh, '<', $expected_file or die "$expected_file: $!";
my $expected = do { local $/; <$fh> };
is $got, $expected, "$in: output unchanged";
}
done_testing;
$^X is the path of the Perl that’s running the test, so the script runs under the same version. Use anonymised copies of real inputs, plus a few edge cases such as an empty file. Never put live customer data in a test folder.
Where should you start when there are no tests at all?
Start with the code you’re about to change, not the whole system. Trying to cover everything first is how test projects stall.
A practical order:
- Check it compiles. Run
perl -con each script and module, and note any warnings. - Record the risky paths. Add golden master tests for the reports, exports and pages people rely on most.
- Pin down the function you’ll edit. Write characterisation tests around it, including odd inputs.
- Make the change. Run
proveafter every small step, not only at the end. - Keep the tests. Commit them with the code, so the next change starts with a safety net.
If the code talks to a database or sends email, point it at a test database or a stub during testing. Those are often the first places where a small refactor helps.
Which tests matter most before a Perl upgrade?
The ones that cover behaviour a new Perl version or module version could quietly change. Upgrades rarely break everything; they break a few specific things.
| Change | What to test first |
|---|---|
| Newer Perl version | Warnings, deprecated syntax, sorting and hash order assumptions |
| Updated modules | Functions you call from each module, and their return values |
| New server or OS | File paths, permissions, locale, time zone and character encoding |
| Database upgrade | Queries, date handling and text with non-English characters |
Hash order is a common surprise. Perl has randomised hash key order for years, so code that printed keys without sorting them may change output between runs. A golden master test catches this quickly.
How do I approach this with a team?
I agree the scope in writing before writing any tests. That means which scripts matter, what “unchanged” means, and which known bugs we deliberately keep for now.
Then I work in small, reviewable steps:
- A short written summary of what the code does, the risky areas and what I plan to test first.
- Tests before changes, shared for review, so the team can see what’s covered.
- Small changes, each with passing tests, rather than one large rewrite.
- A plain-language note for each step: what changed, what stayed the same and anything still uncertain.
This is the same habit I use at work: make risk visible early, and keep each change small enough to undo. If you’d like to see the kinds of Perl work I take on, the Perl development page lists them.
Frequently asked questions
Do I need to refactor legacy Perl code before I can test it?
Usually not at first, because golden master tests run whole scripts from the outside and need no code changes. Once those pass, small refactors, such as moving logic into a module, make finer tests possible. Refactoring before any tests exist is exactly the risk you’re trying to avoid.
What if the old code has bugs?
Record the current behaviour anyway, and add a comment that marks it as a suspected bug. Then fix it as a separate change, with the test updated on purpose. This keeps accidental changes and intentional fixes apart, which makes reviews and later debugging much easier.
How many tests are enough for an old Perl system?
There’s no fixed number, but aim to cover the code you’re changing and the outputs people depend on, such as invoices, reports and exports. Coverage can grow with each change. A few tests that run on every change are more useful than a large suite that nobody runs.
Can I test an old CGI script without a web server?
Often, yes. A CGI script reads its input from environment variables and standard input, so a test can set those and capture the printed output. That lets you record current pages before moving the app to a modern setup, and compare them after each step of the move.
Should tests run automatically?
Yes, once they’re reliable. Running prove on every commit, in whatever build system the team already uses, means nobody has to remember. Start by running them by hand, fix any flaky tests, then automate them so every change is checked the same way.
Need help with an old Perl system?
If you have Perl code that needs fixing, extending or upgrading, I’m happy to take a look, including adding tests before anything changes. I work remotely with teams across India and beyond from Kozhikode, Kerala.
You can reach me through the contact section or email me at manu@manu.co.in. A few lines about the system and what you need is a good start.