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:

  1. Test executes and captures output
  2. Output saved to snapshot file
  3. Developer reviews snapshot to approve it

Subsequent runs:

  1. Test executes and captures output
  2. Output compared to approved snapshot
  3. Test passes if they match, fails if different
  4. 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:

  1. Don't blindly update - Review the diff carefully
  2. Understand why it changed - Code change? Bug fix? Regression?
  3. Verify the new output is correct - Not just different
  4. 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

Sunsetting - Approval Testing | Sunsetting Learn