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: 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.