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('<script>');
});
});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@v3Testing 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.