for safely refactoring legacy code
Anyone who wants to refactor legacy code without tests faces a chicken-and-egg problem: writing tests requires understanding the code, understanding requires reading the code, but confidence when changing it requires tests. Golden Master tests untangle this knot, they test the behavior before it is understood.
Table of Contents
- 1. The Golden Master concept: documenting behavior, not understanding it
- 2. Building Golden Master tests in PHPUnit
- 3. Creating, versioning and updating snapshots
- 4. Refactoring workflow with Golden Master tests
- 5. Golden Master tests in Magento 2: price calculations and templates
- 6. Limits of the Golden Master approach
- 7. From Golden Master to classic unit tests
- 8. Testing approaches compared
- 9. Summary
- 10. FAQ
1. The Golden Master concept: documenting behavior, not understanding it
A Golden Master test (also called a characterization test or approval test) captures the actual output of a system at a given point in time and stores it as a reference, the "Golden Master". On every future test run, the current output is compared against this reference. Any deviation is a failure. That is the whole concept, and its elegance lies in its simplicity.
The decisive difference from classic unit tests: you do not need to know what the system should do, only what it actually does. For legacy code whose behavior has grown over years and whose specification has been lost, that is exactly the advantage. The Golden Master documents the status quo, including every bug and quirk. Only once the code is protected by tests can bugs be fixed in a targeted way without damaging other behavior.
The name "Golden Master" comes from mass production: the original print from which all copies are made. In the software world it is the reference output against which all future versions of the system are checked. Other names for the same concept are characterization test (Michael Feathers, "Working Effectively with Legacy Code"), approval test or snapshot test.
2. Building Golden Master tests in PHPUnit
A Golden Master test in PHPUnit consists of three parts: invoking the code under test, serializing the output and comparing it to the stored reference. The first challenge is serialization, the output must be serialized so that it is stable and comparable. JSON with ordered keys is ideal for structured data. For HTML output, a normalization that ignores unimportant whitespace differences is recommended.
On the first run, no snapshot yet exists, you have to generate it. A simple strategy: the test checks whether the snapshot file exists. If not, it writes the current output as a snapshot and marks the test as skipped with a message: "Snapshot created, please review and commit." On the next run it compares the output against the snapshot. This logic can be encapsulated in a trait or a base class, so that every Golden Master test only needs a single line of boilerplate.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\GoldenMaster;
use PHPUnit\Framework\TestCase;
/**
* Trait providing Golden Master / snapshot assertion helpers.
* Snapshots are stored in __snapshots__/ relative to the test file.
*/
trait GoldenMasterTrait
{
/**
* Asserts that serialized output matches stored snapshot.
* Creates the snapshot on first run (test is skipped with notice).
*/
protected function assertMatchesSnapshot(mixed $actual, string $snapshotName = ''): void
{
$snapshotName = $snapshotName ?: $this->getName();
$snapshotDir = dirname((new \ReflectionClass($this))->getFileName()) . '/__snapshots__';
$snapshotFile = $snapshotDir . '/' . $snapshotName . '.json';
$serialized = json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (!file_exists($snapshotFile)) {
if (!is_dir($snapshotDir)) {
mkdir($snapshotDir, 0755, true);
}
file_put_contents($snapshotFile, $serialized);
$this->markTestSkipped("Snapshot created at {$snapshotFile}. Review and commit.");
}
$expected = file_get_contents($snapshotFile);
$this->assertSame(
$expected,
$serialized,
"Output does not match snapshot: {$snapshotFile}"
);
}
/**
* Updates (overwrites) an existing snapshot, call when intentional change.
*/
protected function updateSnapshot(mixed $actual, string $snapshotName = ''): void
{
$snapshotName = $snapshotName ?: $this->getName();
$snapshotDir = dirname((new \ReflectionClass($this))->getFileName()) . '/__snapshots__';
$snapshotFile = $snapshotDir . '/' . $snapshotName . '.json';
$serialized = json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
file_put_contents($snapshotFile, $serialized);
}
}
3. Creating, versioning and updating snapshots
Snapshots must be checked into version control, this is one of the most important rules for Golden Master tests. Without versioning you lose the ability to review snapshot changes in code review and to see when and through which commit behavior changed. Keeping snapshots in .gitignore is an anti-pattern that undermines the entire value of the Golden Master approach.
Updating a snapshot is a deliberate decision, not an automatic step. When a refactoring breaks the snapshot comparison, there are two possibilities: either the refactoring accidentally changed behavior (a bug), or the behavior changed intentionally and the snapshot needs to be updated. The developer decides by reviewing the snapshot diff. A --update-snapshots option in the test, or a separate method, enables targeted updates.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\GoldenMaster;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Mironsoft\Pricing\PriceEngine;
/**
* Golden Master tests for PriceEngine.
* These tests capture the current pricing behaviour without asserting
* correctness, they guard against unintended changes during refactoring.
*/
#[CoversClass(PriceEngine::class)]
final class PriceEngineGoldenMasterTest extends TestCase
{
use GoldenMasterTrait;
private PriceEngine $engine;
protected function setUp(): void
{
// Inject any required fakes/stubs for deterministic output
$taxConfig = TaxConfigStub::germany();
$currencyFx = FxRatesStub::frozen();
$this->engine = new PriceEngine($taxConfig, $currencyFx);
}
#[Test]
public function priceCalculationForStandardProduct(): void
{
$product = ProductMother::standard();
$result = $this->engine->calculate($product, 'DE', 'EUR');
$this->assertMatchesSnapshot([
'net' => $result->net(),
'gross' => $result->gross(),
'tax' => $result->tax(),
'currency' => $result->currency(),
]);
}
#[Test]
public function priceCalculationForBundleProduct(): void
{
$bundle = ProductMother::bundle([
ProductMother::standard(),
ProductMother::reducedVat(),
]);
$result = $this->engine->calculate($bundle, 'DE', 'EUR');
$this->assertMatchesSnapshot([
'items' => array_map(fn($i) => ['sku' => $i->sku(), 'gross' => $i->gross()], $result->items()),
'subtotal' => $result->subtotal(),
'total' => $result->total(),
]);
}
}
4. Refactoring workflow with Golden Master tests
The refactoring workflow with Golden Master tests follows a clear sequence. First: write Golden Master tests for the code to be refactored and generate snapshots. Second: make sure all tests are green. Third: perform the refactoring, splitting classes, extracting methods, making dependencies injectable. Fourth: run the test suite. If all tests are green, the refactoring did not change the external behavior.
The decisive psychological advantage: the developer can perform the refactoring with confidence, because they get immediate feedback about behavior changes. Without tests, every refactoring is an article of faith ("I did not break anything"). With Golden Master tests it is a measurable statement ("the tests are green"). This significantly reduces the fear of legacy code and makes refactorings more likely to happen.
5. Golden Master tests in Magento 2: price calculations and templates
Magento 2 contains complex business logic in areas such as price calculation, tax logic and checkout rules that have grown historically and whose behavior changes can have far-reaching consequences. Golden Master tests are particularly valuable here: you define a set of product configurations, customer segments and discount rules and capture the calculated prices as snapshots. After that you can refactor the price calculation logic without accidentally changing tax amounts, rounding errors or discount calculations.
For phtml templates the Golden Master approach is equally applicable: you render a template with defined test data and store the HTML output as a snapshot. After refactoring the template you check whether the HTML output has remained semantically identical. For HTML comparisons, a normalization that ignores whitespace and comments but reveals structural changes is recommended.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\GoldenMaster\Magento;
use Magento\TestFramework\Helper\Bootstrap;
use Magento\TestFramework\Fixture\DataFixture;
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
use PHPUnit\Framework\Attributes\Test;
/**
* Golden Master integration test for Magento 2 tax calculation.
* Captures current tax calculation behaviour to guard against regression
* during refactoring of custom tax rule modules.
*/
#[DataFixture(ProductFixture::class, ['price' => 100.00, 'sku' => 'gm-test-001'], 'prod')]
final class TaxCalculationGoldenMasterTest extends \Magento\TestFramework\TestCase\AbstractController
{
use GoldenMasterTrait;
#[Test]
public function taxCalculationForGermanCustomer(): void
{
$objectManager = Bootstrap::getObjectManager();
$product = $this->getFixtureProduct('prod');
/** @var \Magento\Tax\Api\TaxCalculationInterface $calculator */
$calculator = $objectManager->get(\Magento\Tax\Api\TaxCalculationInterface::class);
$quoteItem = $this->buildQuoteItem($product, qty: 2, customerTaxClass: 'Retail Customer');
$result = $calculator->calculateTax($this->buildTaxDetails($quoteItem, 'DE'));
$this->assertMatchesSnapshot([
'row_tax' => round((float)$result->getRowTax(), 4),
'row_total' => round((float)$result->getRowTotal(), 4),
'row_total_incl_tax' => round((float)$result->getRowTotalInclTax(), 4),
'tax_percent' => $result->getAppliedTaxes()[0]['percent'] ?? null,
]);
}
}
6. Limits of the Golden Master approach
Golden Master tests have clear limits that you need to know. First, they only document what the system currently does, including every bug. A snapshot of buggy behavior is not a test for correct behavior, it is a test for unchanged behavior. Fixing a bug requires deliberately updating the snapshot.
Second, Golden Master tests scale poorly for non-deterministic output: timestamps, UUIDs, orderings from database queries without an explicit ORDER BY, and randomly generated tokens must be normalized or replaced with controllable fakes before a meaningful snapshot can be created. Third, Golden Master tests can create false confidence: a green Golden Master test only means the behavior is unchanged, not that it is correct. They are a safety net for refactorings, not a replacement for specification tests.
7. From Golden Master to classic unit tests
Golden Master tests are a tool for a transition phase, not a permanent state. The goal is to write classic unit tests that specify the desired behavior once the code has been refactored, and to remove the Golden Master tests afterward. The path there: Golden Master tests provide the confidence for the refactoring that makes the code understandable and testable. Once the code is cleanly structured, classic tests can be written that actually have specification character.
In practice, a mix often remains in Magento projects: for stable legacy code that is rarely changed, Golden Master tests remain useful as a regression net. For actively developed components, you replace them with classic unit and integration tests. This distinction should be made and documented deliberately, so the team knows which tests have which character.
8. Testing approaches compared
Golden Master tests, classic unit tests and integration tests solve different problems. Understanding these differences prevents Golden Master tests from being used as a permanent replacement for specification tests.
| Property | Golden Master | Unit Test | Integration Test |
|---|---|---|---|
| Specification character | None, documents as-is state | High, tests desired behavior | Medium, tests interaction |
| Prerequisite | No understanding needed | Full understanding needed | System knowledge needed |
| Bugs documented | Yes, including existing bugs | No, tests correct behavior | No |
| Refactoring safety | High, immediate feedback | High with good coverage | Medium, slower feedback loop |
| Long-term use | For stable legacy code | Always | For critical paths |
The table makes it clear: Golden Master tests are indispensable for getting started with legacy code refactorings, but they are not an end in themselves. The goal is always to bring the code into a state where classic unit tests are possible. Golden Master tests are the safety net that allows this transformation.
9. Summary
Golden Master tests solve the chicken-and-egg problem of legacy code refactorings: no safe refactoring without tests, no tests without understanding. By capturing the current behavior as a snapshot and checking against it on future runs, you get immediate feedback about behavior changes, without having to understand the behavior beforehand. That gives developers the confidence to touch and improve legacy code.
In Magento projects, Golden Master tests are particularly valuable for complex price calculations, tax logic and template rendering, where even small behavior changes can have significant consequences. Snapshots are checked into version control so that behavior changes are visible in code review. The long-term goal is the transition to classic unit tests that specify the desired behavior, Golden Master tests are the path there, not the destination.
Golden Master Tests: The essentials at a glance
Concept
Capture current behavior as a snapshot, compare on future runs. No understanding of the code needed, only executability.
Version the snapshots
Always check into version control. Review snapshot changes in code review. Never keep them in .gitignore.
Know the limits
Documents the as-is state including bugs. No replacement for specification tests. Normalize non-deterministic output.
Plan the transition
Write classic unit tests after the refactoring. Keep Golden Master tests for stable legacy code, replace them for active development.
10. FAQ: Golden Master Tests in PHPUnit
1What is a Golden Master test?
2Why for legacy code?
3Check snapshots into version control?
4Non-deterministic output?
5When a Golden Master test fails?
6Replacement for unit tests?
7Update a snapshot after an intentional change?
updateSnapshot() method in the trait or a --update-snapshots option. Overwrite deliberately and commit with an explanatory commit message.8How long should you keep Golden Master tests?
9Best snapshot format?
JSON_PRETTY_PRINT for structured data, easy to read in a diff. HTML for templates. Normalize timestamps and IDs.