Approval Testing
Approval testing (also called snapshot testing or golden master testing) is a powerful technique for testing legacy code with complex outputs. Instead of writing detailed assertions about every aspect of the output, you capture the entire output once, approve it, and then verify future runs produce the same result.
This approach is particularly valuable for characterizing legacy systems, testing data transformations, and verifying complex rendered outputs like HTML, JSON, or reports.
What is Approval Testing?
The Core Concept
Traditional testing:
it('generates user report', () => {
const report = generateUserReport(user);
expect(report.title).toBe('User Report');
expect(report.sections).toHaveLength(5);
expect(report.sections[0].name).toBe('Profile');
expect(report.sections[0].data.email).toBe('user@example.com');
// ... 50 more assertions
});
Approval testing:
it('generates user report', () => {
const report = generateUserReport(user);
// Entire output saved and compared
expect(report).toMatchSnapshot();
});
How It Works
First run:
- Test executes and captures output
- Output saved to snapshot file
- Developer reviews snapshot to approve it
Subsequent runs:
- Test executes and captures output
- Output compared to approved snapshot
- Test passes if they match, fails if different
- On failure, developer reviews diff to see if change is intentional
When to Use Approval Testing
Ideal Scenarios
Complex, structured outputs:
- HTML templates
- JSON API responses
- XML documents
- PDF reports
- Database query results
- Complex data structures
Legacy code characterization:
- Don't know what "correct" output is
- Need to preserve current behavior during refactoring
- Want to detect any change in behavior
Regression testing:
- Ensure future changes don't break existing behavior
- Catch unintended side effects
- Verify complex business logic
When NOT to Use Approval Testing
Outputs with randomness or timestamps:
// Bad: Will fail every time due to timestamp
{
userId: '123',
generatedAt: '2024-01-15T14:23:45Z', // Changes every run!
data: { /* ... */ }
}
Solution: Normalize before snapshotting:
const normalized = {
...output,
generatedAt: '[TIMESTAMP]',
};
expect(normalized).toMatchSnapshot();
Simple outputs better tested with assertions:
// Don't use snapshot for this
expect(add(2, 3)).toMatchSnapshot(); // Overkill
// Just assert the value
expect(add(2, 3)).toBe(5); // Better
Implementation Examples
Example 1: Testing HTML Template Output
// Legacy template function
function renderInvoice(invoice: Invoice): string {
return `
<!DOCTYPE html>
<html>
<head><title>Invoice ${invoice.number}</title></head>
<body>
<h1>Invoice ${invoice.number}</h1>
<p>Date: ${invoice.date}</p>
<table>
${invoice.lineItems.map(item => `
<tr>
<td>${item.description}</td>
<td>${item.quantity}</td>
<td>$${item.price.toFixed(2)}</td>
<td>$${(item.quantity * item.price).toFixed(2)}</td>
</tr>
`).join('')}
</table>
<p><strong>Total: $${invoice.total.toFixed(2)}</strong></p>
</body>
</html>
`;
}
// Approval test
describe('renderInvoice', () => {
it('renders invoice HTML correctly', () => {
const invoice = {
number: 'INV-001',
date: '2024-01-15',
lineItems: [
{ description: 'Widget A', quantity: 2, price: 10.00 },
{ description: 'Widget B', quantity: 1, price: 25.50 },
],
total: 45.50,
};
const html = renderInvoice(invoice);
expect(html).toMatchSnapshot();
});
it('handles empty line items', () => {
const invoice = {
number: 'INV-002',
date: '2024-01-15',
lineItems: [],
total: 0,
};
const html = renderInvoice(invoice);
expect(html).toMatchSnapshot();
});
});
Snapshot file (__snapshots__/invoice.test.ts.snap):
// Jest Snapshot v1
exports[`renderInvoice renders invoice HTML correctly 1`] = `
"
<!DOCTYPE html>
<html>
<head><title>Invoice INV-001</title></head>
<body>
<h1>Invoice INV-001</h1>
<p>Date: 2024-01-15</p>
<table>
<tr>
<td>Widget A</td>
<td>2</td>
<td>$10.00</td>
<td>$20.00</td>
</tr>
<tr>
<td>Widget B</td>
<td>1</td>
<td>$25.50</td>
<td>$25.50</td>
</tr>
</table>
<p><strong>Total: $45.50</strong></p>
</body>
</html>
"
`;
Example 2: Testing JSON API Responses
// Legacy API endpoint
function getUserProfileData(userId: string): UserProfile {
const user = database.findUser(userId);
const orders = database.findOrdersByUser(userId);
const recommendations = recommendationEngine.getRecommendations(userId);
return {
user: {
id: user.id,
name: user.name,
email: user.email,
memberSince: user.createdAt,
tier: calculateTier(user, orders),
},
stats: {
totalOrders: orders.length,
lifetimeValue: orders.reduce((sum, o) => sum + o.total, 0),
averageOrderValue: orders.reduce((sum, o) => sum + o.total, 0) / orders.length,
},
recentOrders: orders.slice(0, 5).map(o => ({
id: o.id,
date: o.createdAt,
total: o.total,
status: o.status,
})),
recommendations: recommendations.slice(0, 10),
};
}
// Approval test with normalized data
describe('getUserProfileData', () => {
beforeEach(() => {
// Use fixed test data
mockDatabase();
});
it('returns complete profile for premium user', () => {
const profile = getUserProfileData('premium-user-123');
// Normalize timestamps before snapshotting
const normalized = normalizeTimestamps(profile);
expect(normalized).toMatchSnapshot();
});
it('returns profile for new user with no orders', () => {
const profile = getUserProfileData('new-user-456');
const normalized = normalizeTimestamps(profile);
expect(normalized).toMatchSnapshot();
});
});
function normalizeTimestamps(obj: any): any {
return JSON.parse(JSON.stringify(obj, (key, value) => {
if (key === 'memberSince' || key === 'date' || key === 'createdAt') {
return '[TIMESTAMP]';
}
return value;
}));
}
Example 3: Testing Database Query Results
// Legacy data transformation
function transformCustomerData(rawData: any[]): CustomerReport[] {
return rawData.map(row => ({
customerId: row.customer_id,
name: `${row.first_name} ${row.last_name}`,
email: row.email.toLowerCase(),
tier: row.total_spent > 10000 ? 'platinum' :
row.total_spent > 5000 ? 'gold' :
row.total_spent > 1000 ? 'silver' : 'bronze',
stats: {
totalOrders: row.order_count,
totalSpent: row.total_spent,
averageOrderValue: row.total_spent / row.order_count,
firstOrderDate: row.first_order_date,
lastOrderDate: row.last_order_date,
daysSinceLastOrder: calculateDaysSince(row.last_order_date),
},
preferences: JSON.parse(row.preferences || '{}'),
}));
}
// Approval test
describe('transformCustomerData', () => {
it('transforms raw customer data correctly', () => {
const rawData = [
{
customer_id: '1',
first_name: 'John',
last_name: 'Doe',
email: 'John.Doe@Example.com',
total_spent: 15000,
order_count: 25,
first_order_date: '2020-01-15',
last_order_date: '2024-01-10',
preferences: '{"newsletter": true, "sms": false}',
},
{
customer_id: '2',
first_name: 'Jane',
last_name: 'Smith',
email: 'JANE@EXAMPLE.COM',
total_spent: 750,
order_count: 3,
first_order_date: '2023-11-01',
last_order_date: '2024-01-05',
preferences: null,
},
];
const transformed = transformCustomerData(rawData);
// Normalize date calculations
const normalized = transformed.map(c => ({
...c,
stats: {
...c.stats,
daysSinceLastOrder: '[CALCULATED]',
},
}));
expect(normalized).toMatchSnapshot();
});
});
Example 4: Testing Complex Business Logic
// Legacy order calculation with many rules
function calculateOrderSummary(order: Order, customer: Customer): OrderSummary {
let subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// Apply discounts
let discount = 0;
if (customer.tier === 'vip') discount += subtotal * 0.15;
if (order.items.length > 10) discount += subtotal * 0.05;
if (customer.firstOrderDate < new Date('2020-01-01')) discount += subtotal * 0.02;
// Calculate tax
const taxableAmount = subtotal - discount;
const taxRate = getTaxRate(order.shippingAddress.state);
const tax = taxableAmount * taxRate;
// Calculate shipping
const weight = order.items.reduce((sum, item) => sum + item.weight * item.quantity, 0);
let shipping = calculateShipping(weight, order.shippingAddress);
// Free shipping threshold
if (subtotal > 100) shipping = 0;
const total = subtotal - discount + tax + shipping;
return {
subtotal,
discount,
taxRate,
tax,
shipping,
total,
breakdown: {
itemCount: order.items.length,
totalWeight: weight,
appliedDiscounts: [
customer.tier === 'vip' && 'VIP Discount (15%)',
order.items.length > 10 && 'Bulk Order Discount (5%)',
customer.firstOrderDate < new Date('2020-01-01') && 'Loyalty Discount (2%)',
].filter(Boolean),
},
};
}
// Approval test covering different scenarios
describe('calculateOrderSummary', () => {
it('calculates summary for VIP customer with bulk order', () => {
const order = {
items: Array(15).fill({ price: 10, quantity: 1, weight: 1 }),
shippingAddress: { state: 'CA' },
};
const customer = {
tier: 'vip',
firstOrderDate: new Date('2019-01-01'),
};
const summary = calculateOrderSummary(order, customer);
expect(summary).toMatchSnapshot();
});
it('calculates summary for regular customer, small order', () => {
const order = {
items: [
{ price: 25, quantity: 1, weight: 2 },
{ price: 15, quantity: 2, weight: 1 },
],
shippingAddress: { state: 'NY' },
};
const customer = {
tier: 'regular',
firstOrderDate: new Date('2023-01-01'),
};
const summary = calculateOrderSummary(order, customer);
expect(summary).toMatchSnapshot();
});
it('applies free shipping for orders over $100', () => {
const order = {
items: [{ price: 120, quantity: 1, weight: 10 }],
shippingAddress: { state: 'TX' },
};
const customer = {
tier: 'regular',
firstOrderDate: new Date('2023-01-01'),
};
const summary = calculateOrderSummary(order, customer);
expect(summary.shipping).toBe(0);
expect(summary).toMatchSnapshot();
});
});
Best Practices
1. Keep Snapshots Readable
Bad: Minified or obfuscated output
expect(JSON.stringify(data)).toMatchSnapshot();
// Snapshot: "{"a":1,"b":2,"c":[1,2,3]}"
Good: Formatted output
expect(data).toMatchSnapshot();
// Snapshot: Pretty-printed, easy to review
2. Normalize Non-Deterministic Data
function normalizeSnapshot(data: any) {
return JSON.parse(JSON.stringify(data, (key, value) => {
// Normalize timestamps
if (key.includes('Date') || key.includes('Time')) {
return '[TIMESTAMP]';
}
// Normalize IDs
if (key.includes('Id') && typeof value === 'string') {
return '[UUID]';
}
// Normalize order
if (Array.isArray(value)) {
return value.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
}
return value;
}));
}
3. Review Snapshot Changes Carefully
When snapshots change:
- Don't blindly update - Review the diff carefully
- Understand why it changed - Code change? Bug fix? Regression?
- Verify the new output is correct - Not just different
- Update or fix - Update snapshot if intentional, fix code if bug
4. Keep Snapshots Small and Focused
Bad: One huge snapshot
it('tests everything', () => {
const result = complexFunction();
expect(result).toMatchSnapshot(); // 1000 lines of output
});
Good: Multiple focused snapshots
it('generates header correctly', () => {
expect(result.header).toMatchSnapshot();
});
it('generates body correctly', () => {
expect(result.body).toMatchSnapshot();
});
it('generates footer correctly', () => {
expect(result.footer).toMatchSnapshot();
});
Tools and Frameworks
Jest Snapshots
Built into Jest, most popular choice for JavaScript/TypeScript:
expect(value).toMatchSnapshot();
expect(value).toMatchInlineSnapshot(`...`);
Approval Tests Libraries
For various languages:
- JavaScript/TypeScript:
jest,@applitools/eyes-storybook - C#:
ApprovalTests.Net - Java:
ApprovalTests.Java - Python:
approvaltests - Ruby:
approval_tests
Visual Regression Testing
For UI components:
- Percy: Visual diff for web UIs
- Chromatic: Storybook visual testing
- BackstopJS: CSS regression testing
- Applitools: AI-powered visual testing
Common Pitfalls
1. Snapshot Fatigue
Problem: Too many snapshots, reviewers stop checking them.
Solution: Use snapshots judiciously, only for complex outputs where manual assertions would be tedious.
2. Brittle Snapshots
Problem: Snapshots break on irrelevant changes.
// Breaks if server port changes
expect(url).toMatchSnapshot(); // "http://localhost:3000/api/users"
Solution: Normalize irrelevant details:
const normalizedUrl = url.replace(/localhost:\d+/, 'localhost:[PORT]');
expect(normalizedUrl).toMatchSnapshot();
3. Snapshot Drift
Problem: Snapshots updated without understanding changes, diverging from correct behavior.
Solution: Treat snapshot updates like code review. Ask "Is this change intentional and correct?"
Key Takeaways
- Approval testing captures and compares entire outputs, ideal for complex structures
- Particularly valuable for characterizing legacy code and preventing regressions
- Normalize non-deterministic data (timestamps, IDs) before snapshotting
- Review snapshot changes carefully—don't blindly update
- Keep snapshots readable and focused on specific aspects
- Use snapshot testing for complex outputs, traditional assertions for simple values
- Available in most testing frameworks and languages
- Combine with visual regression tools for UI testing
Further Reading
- Approval Testing - Official site with guides for multiple languages
- Jest Snapshot Testing - Jest's snapshot documentation
- Working Effectively with Legacy Code - Chapter on Golden Master testing
- Testing JavaScript Applications - Comprehensive testing strategies