reportgen
Back to all posts

Testing PDF Generation Pipelines: Because Broken PDFs in Production Suck 🧪

May 23, 2025

Your PDF looked perfect in development. Perfect.

Then it hit production and:

  • Tables broke across page boundaries
  • Images didn't load
  • Fonts rendered as boxes
  • The entire layout imploded

How did this happen? You didn't test it properly.

This guide shows you how to test PDF generation like you mean it - so broken PDFs never reach your users.


Why PDF Testing Is Different

The Challenge

PDFs aren't like web pages. You can't just open them in a browser and click around.

What makes PDF testing hard:

No DOM to query - Can't use standard web testing tools ❌ Visual medium - Layout issues aren't catchable with unit tests ❌ Binary format - Can't easily diff changes ❌ Rendering varies - Different PDF viewers render slightly differently ❌ External dependencies - Images, fonts, CSS must load correctly

The solution: Multi-layered testing strategy.


Layer 1: Unit Tests for Template Logic

Test your template rendering before generating PDFs.

Test Data Binding

import { renderTemplate } from '../lib/templates';
 
describe('Invoice Template', () => {
  it('should render customer name correctly', () => {
    const data = {
      customer: { name: 'Acme Corp' },
      invoice_number: 'INV-001',
      total: 1500
    };
 
    const html = renderTemplate('invoice', data);
 
    expect(html).toContain('Acme Corp');
    expect(html).toContain('INV-001');
    expect(html).toContain('$1,500');
  });
 
  it('should handle missing data gracefully', () => {
    const data = {
      customer: { name: null },
      invoice_number: 'INV-002'
    };
 
    const html = renderTemplate('invoice', data);
 
    // Should not crash
    expect(html).toBeDefined();
    expect(html).not.toContain('undefined');
    expect(html).not.toContain('null');
  });
 
  it('should escape HTML in user input', () => {
    const data = {
      customer: { name: '<script>alert("XSS")</script>' },
      invoice_number: 'INV-003'
    };
 
    const html = renderTemplate('invoice', data);
 
    expect(html).not.toContain('<script>');
    expect(html).toContain('&lt;script&gt;');
  });
});

Test Conditional Logic

describe('Invoice Template - Payment Status', () => {
  it('should show "PAID" badge when paid', () => {
    const data = {
      invoice_number: 'INV-001',
      status: 'paid'
    };
 
    const html = renderTemplate('invoice', data);
 
    expect(html).toContain('PAID');
    expect(html).toContain('badge-success');
  });
 
  it('should show "OVERDUE" badge when overdue', () => {
    const data = {
      invoice_number: 'INV-002',
      status: 'overdue'
    };
 
    const html = renderTemplate('invoice', data);
 
    expect(html).toContain('OVERDUE');
    expect(html).toContain('badge-danger');
  });
});

Test Loops and Iterations

describe('Invoice Template - Line Items', () => {
  it('should render all line items', () => {
    const data = {
      items: [
        { description: 'Widget A', quantity: 2, price: 10 },
        { description: 'Widget B', quantity: 1, price: 25 },
        { description: 'Widget C', quantity: 5, price: 5 }
      ]
    };
 
    const html = renderTemplate('invoice', data);
 
    expect(html).toContain('Widget A');
    expect(html).toContain('Widget B');
    expect(html).toContain('Widget C');
    expect(html.match(/Widget/g).length).toBe(3);
  });
 
  it('should handle empty items array', () => {
    const data = { items: [] };
 
    const html = renderTemplate('invoice', data);
 
    expect(html).toBeDefined();
    expect(html).toContain('No items');
  });
});

Layer 2: Integration Tests for PDF Generation

Test the actual PDF generation process.

Test PDF Generation API

import { generatePDF } from '../lib/pdf-generator';
 
describe('PDF Generation', () => {
  it('should generate a valid PDF', async () => {
    const data = {
      customer: { name: 'Test Corp' },
      invoice_number: 'INV-TEST-001',
      total: 100
    };
 
    const pdf = await generatePDF('invoice', data);
 
    // Check PDF is valid
    expect(pdf).toBeInstanceOf(Buffer);
    expect(pdf.length).toBeGreaterThan(0);
 
    // Check PDF magic bytes
    const header = pdf.slice(0, 4).toString();
    expect(header).toBe('%PDF');
  });
 
  it('should throw error for invalid template', async () => {
    await expect(
      generatePDF('nonexistent-template', {})
    ).rejects.toThrow('Template not found');
  });
 
  it('should handle large datasets without timeout', async () => {
    const data = {
      items: Array(1000).fill(null).map((_, i) => ({
        description: `Item ${i}`,
        quantity: 1,
        price: 10
      }))
    };
 
    const pdf = await generatePDF('invoice', data);
 
    expect(pdf).toBeInstanceOf(Buffer);
  }, 30000); // 30 second timeout
});

Test PDF Metadata

import { PDFDocument } from 'pdf-lib';
 
describe('PDF Metadata', () => {
  it('should include correct metadata', async () => {
    const pdf = await generatePDF('invoice', testData);
 
    const pdfDoc = await PDFDocument.load(pdf);
 
    expect(pdfDoc.getTitle()).toBe('Invoice INV-001');
    expect(pdfDoc.getAuthor()).toBe('Your Company Name');
    expect(pdfDoc.getCreationDate()).toBeInstanceOf(Date);
  });
});

Layer 3: Visual Regression Testing

Catch layout breaks, font issues, and rendering problems.

Setup Visual Testing with Puppeteer

import puppeteer from 'puppeteer';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
 
expect.extend({ toMatchImageSnapshot });
 
describe('Invoice PDF Visual Regression', () => {
  let browser;
  let page;
 
  beforeAll(async () => {
    browser = await puppeteer.launch();
  });
 
  afterAll(async () => {
    await browser.close();
  });
 
  beforeEach(async () => {
    page = await browser.newPage();
  });
 
  it('should render invoice correctly', async () => {
    const html = renderTemplate('invoice', testData);
 
    await page.setContent(html);
    await page.setViewport({ width: 800, height: 1200 });
 
    const screenshot = await page.screenshot({ fullPage: true });
 
    // Compare against baseline
    expect(screenshot).toMatchImageSnapshot({
      failureThreshold: 0.01, // Allow 1% difference
      failureThresholdType: 'percent'
    });
  });
});

Test Responsive Layouts

describe('PDF Layout at Different Sizes', () => {
  const sizes = [
    { width: 595, height: 842, name: 'A4' },
    { width: 612, height: 792, name: 'Letter' },
    { width: 842, height: 1191, name: 'A3' }
  ];
 
  sizes.forEach(({ width, height, name }) => {
    it(`should render correctly at ${name} size`, async () => {
      await page.setViewport({ width, height });
 
      const html = renderTemplate('invoice', testData);
      await page.setContent(html);
 
      const screenshot = await page.screenshot({ fullPage: true });
 
      expect(screenshot).toMatchImageSnapshot({
        customSnapshotIdentifier: `invoice-${name}`
      });
    });
  });
});

Test Multi-Page PDFs

describe('Multi-Page Invoice', () => {
  it('should handle page breaks correctly', async () => {
    const data = {
      items: Array(50).fill(null).map((_, i) => ({
        description: `Item ${i}`,
        quantity: Math.floor(Math.random() * 10) + 1,
        price: Math.floor(Math.random() * 100) + 1
      }))
    };
 
    const html = renderTemplate('invoice', data);
    await page.setContent(html);
 
    // Check multiple pages rendered
    const pages = await page.$$('body > .page');
    expect(pages.length).toBeGreaterThan(1);
 
    // Visual snapshot
    const screenshot = await page.screenshot({ fullPage: true });
    expect(screenshot).toMatchImageSnapshot();
  });
});

Layer 4: Content Validation

Extract and validate PDF content.

Extract Text from PDF

import pdf from 'pdf-parse';
 
describe('PDF Content Validation', () => {
  it('should contain correct invoice data', async () => {
    const pdfBuffer = await generatePDF('invoice', {
      customer: { name: 'Acme Corp' },
      invoice_number: 'INV-001',
      total: 1500,
      items: [
        { description: 'Consulting Services', quantity: 10, price: 150 }
      ]
    });
 
    const data = await pdf(pdfBuffer);
 
    // Check text content
    expect(data.text).toContain('Acme Corp');
    expect(data.text).toContain('INV-001');
    expect(data.text).toContain('$1,500');
    expect(data.text).toContain('Consulting Services');
  });
 
  it('should not contain sensitive debug info', async () => {
    const pdfBuffer = await generatePDF('invoice', testData);
    const data = await pdf(pdfBuffer);
 
    // Make sure no secrets leaked
    expect(data.text).not.toContain('API_KEY');
    expect(data.text).not.toContain('SECRET');
    expect(data.text).not.toContain('PASSWORD');
  });
});

Validate PDF Structure

import { PDFDocument } from 'pdf-lib';
 
describe('PDF Structure', () => {
  it('should have correct number of pages', async () => {
    const pdfBuffer = await generatePDF('invoice', testData);
    const pdfDoc = await PDFDocument.load(pdfBuffer);
 
    expect(pdfDoc.getPageCount()).toBe(1);
  });
 
  it('should have correct page size (A4)', async () => {
    const pdfBuffer = await generatePDF('invoice', testData);
    const pdfDoc = await PDFDocument.load(pdfBuffer);
 
    const page = pdfDoc.getPage(0);
    const { width, height } = page.getSize();
 
    expect(width).toBeCloseTo(595, 1); // A4 width in points
    expect(height).toBeCloseTo(842, 1); // A4 height in points
  });
});

Layer 5: Performance Testing

Ensure PDFs generate fast enough for production.

Test Generation Speed

describe('PDF Generation Performance', () => {
  it('should generate simple invoice in < 2 seconds', async () => {
    const start = Date.now();
 
    await generatePDF('invoice', testData);
 
    const duration = Date.now() - start;
 
    expect(duration).toBeLessThan(2000);
  });
 
  it('should generate complex report in < 5 seconds', async () => {
    const complexData = {
      items: Array(100).fill(null).map((_, i) => ({
        description: `Item ${i}`,
        quantity: i,
        price: i * 10
      }))
    };
 
    const start = Date.now();
 
    await generatePDF('invoice', complexData);
 
    const duration = Date.now() - start;
 
    expect(duration).toBeLessThan(5000);
  });
});

Load Testing

import autocannon from 'autocannon';
 
describe('PDF Generation Load Test', () => {
  it('should handle 100 concurrent requests', async () => {
    const result = await autocannon({
      url: 'http://localhost:3000/generate-pdf',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': process.env.REPORTGEN_API_KEY
      },
      body: JSON.stringify({
        template: 'invoice',
        data: testData
      }),
      connections: 100, // 100 concurrent connections
      duration: 10 // 10 seconds
    });
 
    expect(result.errors).toBe(0);
    expect(result.timeouts).toBe(0);
    expect(result.non2xx).toBe(0);
    expect(result.latency.mean).toBeLessThan(3000); // < 3s average
  });
});

Layer 6: End-to-End Testing

Test the complete user flow.

E2E Test with Playwright

import { test, expect } from '@playwright/test';
 
test.describe('Invoice Generation Flow', () => {
  test('user can generate and download invoice', async ({ page }) => {
    // Login
    await page.goto('https://app.example.com/login');
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');
 
    // Navigate to invoices
    await page.goto('https://app.example.com/invoices');
 
    // Click "Generate PDF" button
    const downloadPromise = page.waitForEvent('download');
    await page.click('button:has-text("Download PDF")');
 
    // Wait for download
    const download = await downloadPromise;
 
    // Verify download
    expect(download.suggestedFilename()).toMatch(/invoice-.*\.pdf/);
 
    // Save and validate PDF
    const path = await download.path();
    const pdfBuffer = require('fs').readFileSync(path);
 
    expect(pdfBuffer.length).toBeGreaterThan(0);
    expect(pdfBuffer.slice(0, 4).toString()).toBe('%PDF');
  });
});

Layer 7: Accessibility Testing

Ensure PDFs are accessible.

Test PDF Accessibility

import { PDFDocument } from 'pdf-lib';
 
describe('PDF Accessibility', () => {
  it('should have document language set', async () => {
    const pdfBuffer = await generatePDF('invoice', testData);
    const pdfDoc = await PDFDocument.load(pdfBuffer);
 
    // Check language is set
    const lang = pdfDoc.catalog.get('Lang');
    expect(lang).toBeDefined();
    expect(lang.toString()).toContain('en'); // Or appropriate language
  });
 
  it('should have proper heading structure', async () => {
    const pdfBuffer = await generatePDF('invoice', testData);
    const data = await pdf(pdfBuffer);
 
    // Check for heading hierarchy
    expect(data.text).toMatch(/INVOICE/);
  });
});

Test Data Management

Create Reusable Test Fixtures

// tests/fixtures/invoice-data.js
export const minimalInvoice = {
  customer: { name: 'Test Customer' },
  invoice_number: 'INV-TEST-001',
  date: new Date('2025-05-01'),
  items: [
    { description: 'Service', quantity: 1, price: 100 }
  ],
  total: 100
};
 
export const complexInvoice = {
  customer: {
    name: 'Large Corp Inc.',
    address: '123 Main St',
    city: 'New York',
    country: 'USA'
  },
  invoice_number: 'INV-TEST-002',
  date: new Date('2025-05-01'),
  items: Array(50).fill(null).map((_, i) => ({
    description: `Line item ${i + 1}`,
    quantity: Math.floor(Math.random() * 10) + 1,
    price: Math.floor(Math.random() * 1000) + 1
  })),
  total: 25000
};
 
export const invoiceWithSpecialChars = {
  customer: { name: 'Café & Bär GmbH' },
  invoice_number: 'INV-ÜNICODE-001',
  items: [
    { description: 'Crème brûlée kit', quantity: 1, price: 50 }
  ],
  total: 50
};

Use Fixtures in Tests

import { minimalInvoice, complexInvoice, invoiceWithSpecialChars } from './fixtures/invoice-data';
 
describe('Invoice PDF Generation', () => {
  it('should handle minimal invoice', async () => {
    const pdf = await generatePDF('invoice', minimalInvoice);
    expect(pdf).toBeDefined();
  });
 
  it('should handle complex invoice', async () => {
    const pdf = await generatePDF('invoice', complexInvoice);
    expect(pdf).toBeDefined();
  });
 
  it('should handle special characters', async () => {
    const pdf = await generatePDF('invoice', invoiceWithSpecialChars);
    const data = await pdf(pdf);
    expect(data.text).toContain('Café & Bär');
  });
});

Continuous Integration Setup

GitHub Actions Workflow

# .github/workflows/pdf-tests.yml
name: PDF Tests
 
on: [push, pull_request]
 
jobs:
  test:
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v3
 
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
 
      - name: Install dependencies
        run: npm ci
 
      - name: Install system dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y fonts-liberation fonts-noto
 
      - name: Run unit tests
        run: npm run test:unit
 
      - name: Run integration tests
        run: npm run test:integration
        env:
          REPORTGEN_API_KEY: ${{ secrets.REPORTGEN_API_KEY }}
 
      - name: Run visual regression tests
        run: npm run test:visual
 
      - name: Upload visual diffs
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: visual-diffs
          path: __image_snapshots__/__diff_output__/
 
      - name: Run performance tests
        run: npm run test:performance
 
      - name: Upload coverage
        uses: codecov/codecov-action@v3

Testing Checklist

Before shipping to production:

Unit Tests

  • Template data binding works
  • Conditional logic renders correctly
  • Loops handle empty/large datasets
  • User input is sanitized
  • Edge cases handled (null, undefined, etc.)

Integration Tests

  • PDF generation succeeds
  • Generated PDFs are valid
  • Metadata is correct
  • Large datasets don't timeout

Visual Tests

  • Layout matches baseline
  • Multi-page PDFs render correctly
  • Images load and display
  • Fonts render properly

Content Tests

  • PDF contains expected text
  • No sensitive data leaked
  • Special characters render
  • Numbers formatted correctly

Performance Tests

  • Generation completes in < 5s
  • Handles concurrent requests
  • No memory leaks

E2E Tests

  • User can generate and download PDFs
  • Error states handled gracefully

Final Thoughts

Testing PDFs is not optional. One broken invoice in production can cost you customers.

Build a comprehensive test suite:

  • ✅ Unit tests for logic
  • ✅ Integration tests for generation
  • ✅ Visual tests for layout
  • ✅ Content validation
  • ✅ Performance benchmarks
  • ✅ E2E user flows

Test early. Test often. Ship with confidence.

Ready for reliable PDF generation? Try reportgen.io - built to handle your most demanding test cases.