Skip to main content

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

ScenarioEdge caseWhy tests breakApproach
Download eventListener after clickLost eventwaitForEvent before click
Dynamic datesDaily snapshot failFlakeAssert order id not date
Async generationJob queueClick too earlyPoll probe then download
Pop-up windowNew tab PDFWrong pageexpect popup or direct URL
Large PDFParse timeoutCI failIncrease timeout; assert key page
Password PDFEncryptedParse failsTest unencrypted test env
Multi-languageFont missingGarbled textAssert ASCII identifiers
Content-DispositionInline vs attachNo download eventgoto URL with request
Zero-byte file500 masked as PDFFalse passAssert file size > 0
Template versionv2 clause missingComplianceAssert 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

AssertAvoid
Order id, SKU, totals"Generated March 15, 2026" unless seeded
Legal clause id stringFull page layout pixel diff every PR
Customer name from seedRandom 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-patternWhy it failsBetter approach
Click then waitForEventRace lostRegister promise first
Assert download started onlyEmpty PDFpdf-parse content
Snapshot PDF bytesDynamic gen timeText substring asserts
Skip async job waitCorrupt filePoll probe ready
Visual diff every PRFont/OS varianceText 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.

  1. Arrange: Seed order ORDER-98765 total $1234.56 via test API.
  2. Act: Open invoice page; waitForEvent download before click Download PDF.
  3. 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).

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.

Start free on TestChimp · Book a demo