How to Test PDF Generation and Downloads
Short answer
PDF invoices, statements, and legal documents are high-value artifacts—clicking "Download" without reading content misses wrong totals and missing clauses. Use Playwright page.waitForEvent('download') registered before click, save to disk, parse text with pdf-parse, assert seeded order ids and amounts—not today's date string snapshots.
Part of Testing Guides by integrations.
Who this is for
Invoicing, fintech, healthcare, legal, and HR apps generating PDFs client-side (jsPDF), server-side (Puppeteer, wkhtmltopdf), or via third-party document APIs.
Why testing PDF downloads matters
- Billing disputes — PDF total ≠ charged amount
- Legal exposure — missing signature block or wrong jurisdiction text
- Silent failures — download event never fires; user sees spinner
- Dynamic content flake — snapshot includes
Generated on {today}
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Download event | Listener after click | Lost event | waitForEvent before click |
| Dynamic dates | Daily snapshot fail | Flake | Assert order id not date |
| Async generation | Job queue | Click too early | Poll probe then download |
| Pop-up window | New tab PDF | Wrong page | expect popup or direct URL |
| Large PDF | Parse timeout | CI fail | Increase timeout; assert key page |
| Password PDF | Encrypted | Parse fails | Test unencrypted test env |
| Multi-language | Font missing | Garbled text | Assert ASCII identifiers |
| Content-Disposition | Inline vs attach | No download event | goto URL with request |
| Zero-byte file | 500 masked as PDF | False pass | Assert file size > 0 |
| Template version | v2 clause missing | Compliance | Assert clause substring |
Playwright download pattern
From Playwright downloads docs:
import fs from 'fs';
import path from 'path';
import pdf from 'pdf-parse';
test('invoice PDF contains order total', async ({ page }) => {
await page.goto(`/orders/${orderId}/invoice`);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /download pdf/i }).click();
const download = await downloadPromise;
const filePath = path.join(test.info().outputDir, 'invoice.pdf');
await download.saveAs(filePath);
expect(fs.statSync(filePath).size).toBeGreaterThan(0);
const buffer = fs.readFileSync(filePath);
const { text } = await pdf(buffer);
expect(text).toContain('ORDER-98765');
expect(text).toContain('$1,234.56');
expect(text).not.toContain('{{'); // template leak
});
Critical: register waitForEvent('download') before the click that triggers download.
Async PDF generation
When PDF builds in background job:
await page.getByRole('button', { name: /generate invoice/i }).click();
await expect.poll(() => probeInvoiceStatus(orderId)).toBe('ready');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: /download/i }).click();
// ... parse as above
Assert stable fields only
| Assert | Avoid |
|---|---|
| Order id, SKU, totals | "Generated March 15, 2026" unless seeded |
| Legal clause id string | Full page layout pixel diff every PR |
| Customer name from seed | Random footer timestamps |
Direct URL download (no click)
const response = await page.request.get(`/api/invoices/${orderId}/pdf`);
expect(response.headers()['content-type']).toContain('application/pdf');
const buffer = await response.body();
const { text } = await pdf(buffer);
expect(text).toContain('TOTAL');
Faster for CI when UI click is not the subject under test.
Probe + PDF cross-check
const probe = await request.get(`/api/test/probe-invoice/${orderId}`).then(r => r.json());
// ... parse PDF ...
expect(text).toContain(String(probe.total_cents / 100));
Probe is authoritative when PDF generation bug suspected.
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Click then waitForEvent | Race lost | Register promise first |
| Assert download started only | Empty PDF | pdf-parse content |
| Snapshot PDF bytes | Dynamic gen time | Text substring asserts |
| Skip async job wait | Corrupt file | Poll probe ready |
| Visual diff every PR | Font/OS variance | Text asserts; visual nightly |
Example scenario
Situation: User downloads invoice PDF for seeded order with known line items.
Expected outcome: PDF text includes order id, line SKU, and total matching probe invoice row.
Why UI-only automation breaks: Download succeeds but PDF shows wrong tax line—probe and PDF disagree.
- Arrange: Seed order ORDER-98765 total $1234.56 via test API.
- Act: Open invoice page; waitForEvent download before click Download PDF.
- Assert: pdf-parse contains ORDER-98765 and amount; matches probe.total_cents.
TestChimp workflow: Track document_type in TrueCoverage when credit_note PDF added without test.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with scenario annotations for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Legal e-signatures — signed PDF workflows
- Fintech apps — statements
- File uploads — inbound PDF upload
- Webhooks async — async PDF job
External references
Frequently asked questions
How do I verify PDF content in Playwright?
Capture download with waitForEvent before click, saveAs to test output, parse with pdf-parse, assert text contains order id and totals. Cross-check probe invoice row when available.
Why does my download test flake?
Usually waitForEvent registered after click—register promise first. Or PDF not ready—poll probe job status before download.
How do I avoid failing on today's date in PDF?
Assert stable seeded identifiers (order id, SKU, amounts)—not generation timestamp strings unless you freeze clock in test env.
Can I test PDF without clicking Download?
Yes—page.request.get on PDF URL with application/pdf content-type and pdf-parse body. Useful when UI is out of scope.
Large PDFs timeout in CI—increase what?
pdf-parse timeout and test timeout; assert first-page key strings instead of parsing 200-page docs in default PR job.
Should I visual-diff PDFs?
Reserve for release or legal layout-critical docs. Default CI uses text asserts on financial fields.
New credit_note document type in prod—how to add coverage?
TrueCoverage document_type slice flags gap. Add seed + download spec via /testchimp evolve linked to markdown scenario.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.