Approval Testing & Golden Master Patterns Explained
Some outputs are too complex to specify with traditional assertions. A PDF report with hundreds of fields, a JSON API response with nested structures, a rendered HTML email, a generated CSV — writing assertions for every field is tedious and brittle. Change one field name and a hundred assertions break.
Approval testing (also called golden master testing) solves this by comparing actual output against a known-good snapshot, called the approved file or golden master. The workflow: generate output, review it once, save it as the approval. Future test runs compare against that approval. If anything changes, the test fails.
The Core Workflow
1. Run the test for the first time → output is generated
2. Review the output → human verifies it's correct
3. Approve it → save as the golden master file
4. Future runs → compare output against golden master
5. If they match → test passes
6. If they differ → test fails; human reviews the diff
7. If the change is intentional → approve the new version
8. If the change is a regression → fix the codeThe key insight: approval testing moves the oracle from "what do I expect to be true?" to "is the output the same as it was when I last verified it was correct?"
When to Use Approval Testing
Good candidates:
- Generated reports (PDF, CSV, Excel)
- API responses with complex nested structures
- Email templates
- Code generators
- Serialized objects
- Rendered HTML
- Log output format
- Legacy code without a specification
Poor candidates:
- Output containing timestamps, random IDs, or other non-deterministic data (without normalization)
- Simple calculations with known expected values
- Real-time or streaming data
ApprovalTests (Java, .NET, C++, Python)
ApprovalTests is the original library for approval testing. It supports multiple approval reporters (diff tools) and serializers.
Java Setup
<!-- pom.xml -->
<dependency>
<groupId>com.approvaltests</groupId>
<artifactId>approvaltests</artifactId>
<version>22.3.3</version>
<scope>test</scope>
</dependency>import org.approvaltests.Approvals;
import org.junit.jupiter.api.Test;
public class InvoiceTest {
@Test
void invoiceReport() {
Invoice invoice = new Invoice(
Customer.of("Acme Corp"),
List.of(
LineItem.of("Widget", 10, 9.99),
LineItem.of("Gadget", 2, 24.99)
),
LocalDate.of(2026, 5, 1)
);
String report = invoice.generateReport();
Approvals.verify(report);
}
}On first run, the test fails and opens your configured diff tool with the actual output. Review it, click "approve," and the file is saved as InvoiceTest.invoiceReport.approved.txt. Subsequent runs compare against it.
Approval files live alongside tests and are committed to version control:
src/test/java/InvoiceTest.invoiceReport.approved.txtHandling Complex Objects
@Test
void orderJson() {
Order order = createTestOrder();
Approvals.verifyJson(objectMapper.writeValueAsString(order));
}
@Test
void customerList() {
List<Customer> customers = customerRepository.findAll();
Approvals.verifyAll("customers", customers, Customer::getSummary);
}Normalizing Non-Deterministic Output
IDs, timestamps, and other variable data must be normalized before approval:
@Test
void invoiceWithNormalizedDates() {
Invoice invoice = generateInvoice();
String report = invoice.generateReport()
.replaceAll("\\d{4}-\\d{2}-\\d{2}", "<DATE>") // normalize dates
.replaceAll("INV-\\d+", "INV-<ID>"); // normalize IDs
Approvals.verify(report);
}Jest Snapshot Testing
Jest's built-in snapshot testing is approval testing for JavaScript. It's widely used for React component testing but works for any serializable output.
// Basic snapshot
test('formats invoice correctly', () => {
const invoice = formatInvoice({
customer: 'Acme Corp',
items: [
{ name: 'Widget', qty: 10, price: 9.99 }
]
});
expect(invoice).toMatchSnapshot();
});On first run, Jest creates ./__snapshots__/invoice.test.js.snap:
exports[`formats invoice correctly 1`] = `
"Acme Corp
==========
Widget × 10 $99.90
-----------
Total: $99.90"
`;To update snapshots after an intentional change:
jest --updateSnapshot
# or
jest -uInline Snapshots
For shorter outputs, inline snapshots keep the expected value in the test file:
test('user summary', () => {
const user = { name: 'Alice', role: 'admin', active: true };
expect(formatUser(user)).toMatchInlineSnapshot(`
"Alice (admin) — Active"
`);
});Component Snapshots
import { render } from '@testing-library/react';
import UserCard from './UserCard';
test('renders user card', () => {
const { container } = render(
<UserCard name="Alice" role="Admin" avatar="alice.jpg" />
);
expect(container).toMatchSnapshot();
});The DOM snapshot problem: Full DOM snapshots are brittle. A className change anywhere in the component tree fails the snapshot. Prefer testing rendered text and key attributes rather than full DOM:
test('renders user card content', () => {
const { getByText, getByAltText } = render(
<UserCard name="Alice" role="Admin" avatar="alice.jpg" />
);
expect(getByText('Alice')).toMatchSnapshot();
expect(getByText('Admin')).toMatchSnapshot();
});Verify (C#)
Verify is a modern .NET approval testing library with better tooling than the original ApprovalTests for .NET.
// nuget: Verify.Xunit or Verify.NUnit
public class InvoiceTests
{
[Fact]
public async Task GenerateInvoiceReport()
{
var invoice = new Invoice
{
Customer = "Acme Corp",
Items = new[]
{
new LineItem("Widget", 10, 9.99m),
new LineItem("Gadget", 2, 24.99m)
}
};
await Verify(invoice.GenerateReport());
}
}Verify automatically handles:
- JSON serialization with stable field ordering
- Date and GUID scrubbing (configurable)
- Diff tool integration on failure
- Per-test approval files
Scrubbing Non-Deterministic Data
[ModuleInitializer]
public static void Initialize()
{
VerifierSettings.ScrubInlineGuids();
VerifierSettings.ScrubInlineDateTimes("yyyy-MM-dd");
}Approval Testing for Legacy Code
Approval testing is particularly powerful when working with legacy code that has no tests. The process:
- Characterize current behavior: Run the code with representative inputs, approve the output as the golden master. This documents current behavior, even if it includes bugs.
- Refactor safely: Change the internal structure without changing behavior. If the approval test fails, you've introduced a regression.
- Fix bugs incrementally: When you need to fix a bug, first write an approval test that captures the buggy behavior. Then fix the bug. The approval test fails (expected). Update the approval to the correct behavior. Now the test documents the fixed behavior.
This is Michael Feathers' "Characterization Test" pattern from Working Effectively with Legacy Code.
# pytest with approvaltests
from approvaltests import verify, verify_all
def test_legacy_price_calculation_characterization():
"""Characterizes current (possibly buggy) behavior."""
test_cases = [
(10, 'basic'),
(100, 'premium'),
(0, 'basic'),
(-5, 'basic'), # what does it do with negatives?
]
results = [(qty, tier, calculate_price(qty, tier)) for qty, tier in test_cases]
verify_all("price calculations", results, lambda r: f"{r[0]} x {r[1]} = {r[2]}")Approval Files in Version Control
Approved files must be committed to version control. They're the specification:
.gitignore additions:
# Never ignore approved files
!**/*.approved.txt
!**/*.approved.jsonWhen a team member changes behavior intentionally:
- They run the tests → approval tests fail
- They review the diff → verify the change is correct
- They approve the new output → update the approval file
- They commit both the code change and the updated approval file
The PR diff shows both the code change and what the output changed. This is visible, reviewable, and tracked.
Approval Testing with HelpMeTest
Approval testing works well for static outputs (reports, documents, serialized data). For dynamic application behavior — what a user sees in their browser, how a workflow behaves across multiple steps — HelpMeTest's screenshot capture and test recording provides a complementary layer.
Where approval testing verifies that a report template hasn't changed, HelpMeTest verifies that the report generation workflow (upload data → generate report → download) works end-to-end against the live system.
Combining both: approval tests catch unexpected changes to output format; HelpMeTest's continuous monitoring catches when the output generation process breaks.
Common Pitfalls
Approving without reviewing. Automatically approving all failures defeats the purpose. The review step is where the human oracle applies. Approving a file you haven't read is approving bugs.
Over-approving. Approval testing works well for outputs that are expensive to specify. Simple functions with clear expected values should use traditional assertions.
Non-deterministic output without normalization. Timestamps, random IDs, and system-dependent paths in approved files cause failures on different machines or at different times. Always normalize these before approval.
Huge approval files. A 10,000-line approved file is hard to review meaningfully. Consider splitting into multiple focused tests with smaller output scopes.
Stale approvals. Approval files that haven't been reviewed in years may be documenting incorrect behavior that has persisted through lazy approval updates. Periodically re-examine what you've approved.
Approval testing is the right tool when traditional assertion-based testing becomes impractical due to output complexity. Used correctly, it turns "I can't easily specify the expected output" into "I've reviewed and approved this output, and any deviation will be caught."