Verifying PDF Downloads and Content in Playwright
Clicking an “Export to PDF” button and seeing a file appear in the downloads bar proves almost nothing. The bytes could be a corrupt zero-byte file, the wrong document, or a perfectly valid PDF whose invoice total is flat-out wrong. In this guide you will learn end-to-end Playwright PDF download verification in TypeScript: how to deterministically catch the download event, persist the file, read its actual text content, and assert on page count, headers, and dynamic values so your tests fail when the document is wrong, not just when it is missing.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why verifying PDF downloads is harder than it looks
A PDF download is a multi-stage process, and each stage can silently break. The link or button has to trigger a navigation or a blob response, the browser has to commit that response as a download, the file has to finish streaming to disk, and the resulting binary has to be a structurally valid PDF that contains the correct text. Most flaky PDF tests fail because they only check the first stage: that a click happened.
Playwright gives you a clean primitive for the download itself through the download event and the Download object. But Playwright deliberately does not parse PDFs for you, so once you have the file on disk you need a parsing library such as pdf-parse to read the text layer. The combination of Playwright for the transport and a parser for the content is what turns a smoke test into a real verification.
Capturing the download event correctly
The single most common bug in PDF tests is a race condition: the test clicks the button and only afterwards starts waiting for the download, so the event is missed. The fix is to register the waiter before the action that triggers it. Playwright’s page.waitForEvent('download') returns a promise you set up first, then resolve alongside the click.
import { test, expect } from '@playwright/test';
test('downloads the report PDF', async ({ page }) => {
await page.goto('https://example.com/reports');
// Register the waiter BEFORE clicking so the event is never missed.
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export to PDF' }).click();
const download = await downloadPromise;
// The suggested filename comes from the Content-Disposition header
// or the download attribute on the anchor.
expect(download.suggestedFilename()).toMatch(/\.pdf$/);
});
Some apps open the PDF in a new browser tab instead of forcing a download. In that case there may be no download event at all. You either configure the context to accept downloads, or capture the new page and read its URL. The acceptDownloads option defaults to true in modern Playwright, but being explicit documents intent.
import { test, expect } from '@playwright/test';
test('forces a PDF that would otherwise open in a tab', async ({ browser }) => {
const context = await browser.newContext({ acceptDownloads: true });
const page = await context.newPage();
await page.goto('https://example.com/invoice/123');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download invoice' }).click();
const download = await downloadPromise;
// Wait for the stream to finish and get the temp path Playwright used.
const tempPath = await download.path();
expect(tempPath).toBeTruthy();
await context.close();
});
Saving the file and checking it is a real PDF
Playwright streams downloads to a temporary location and deletes them when the context closes. To keep a copy for parsing or for CI artifacts, call download.saveAs() with a stable path. Before parsing, two cheap structural checks catch the majority of broken downloads: a non-zero file size and the %PDF- magic-number header that every valid PDF starts with.
import { test, expect } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
test('saves the PDF and validates its structure', async ({ page }, testInfo) => {
await page.goto('https://example.com/reports');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export to PDF' }).click();
const download = await downloadPromise;
// Save into the test's output dir so it shows up as a CI artifact.
const filePath = testInfo.outputPath('report.pdf');
await download.saveAs(filePath);
const buffer = await fs.readFile(filePath);
// 1. Non-empty file.
expect(buffer.byteLength).toBeGreaterThan(1000);
// 2. Correct magic number: every PDF starts with the ASCII bytes "%PDF-".
expect(buffer.subarray(0, 5).toString('latin1')).toBe('%PDF-');
// 3. Well-formed files end with an EOF marker.
const tail = buffer.subarray(-1024).toString('latin1');
expect(tail).toContain('%%EOF');
expect(path.basename(filePath)).toBe('report.pdf');
});
Using testInfo.outputPath() instead of a hard-coded folder means each test run and each parallel worker gets an isolated directory, and the file is easy to attach to the HTML report on failure. That alone removes a whole class of “works on my machine” flakiness.
Reading and asserting on PDF text content
Structural checks confirm you have a PDF; they do not confirm you have the right PDF. To assert that an invoice shows the correct customer name, total, or date, you have to extract the text layer. The pdf-parse package reads a buffer and returns the concatenated text plus metadata like numpages. Install it with npm i -D pdf-parse.
import { test, expect } from '@playwright/test';
import fs from 'node:fs/promises';
import pdfParse from 'pdf-parse';
test('verifies invoice content inside the PDF', async ({ page }, testInfo) => {
await page.goto('https://example.com/invoice/INV-2026-0042');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download invoice' }).click();
const download = await downloadPromise;
const filePath = testInfo.outputPath('invoice.pdf');
await download.saveAs(filePath);
const buffer = await fs.readFile(filePath);
const parsed = await pdfParse(buffer);
// numpages comes straight from the PDF catalog.
expect(parsed.numpages).toBe(1);
// Normalize whitespace because PDF text can contain odd spacing/newlines.
const text = parsed.text.replace(/\s+/g, ' ').trim();
expect(text).toContain('Invoice #: INV-2026-0042');
expect(text).toContain('Bill To: Acme Corporation');
expect(text).toContain('Total Due: $1,250.00');
expect(text).toMatch(/Due Date:\s*27 Jun 2026/);
});
A few practical notes on parsing. PDF text extraction is not a perfect inverse of layout, so spacing between words can be inconsistent. Normalizing whitespace with a single replace(/\s+/g, ' ') before matching makes assertions far more stable than comparing raw strings. For values you cannot control, prefer toContain or a regular expression over an exact full-document equality check.
Asserting on a dynamic value pulled from the page
The strongest verification compares the PDF against the data that produced it, rather than a hard-coded string. Read the value from the page first, then assert the same value appears in the downloaded file. This catches data-mapping bugs where the UI is right but the export logic is wrong.
import { test, expect } from '@playwright/test';
import fs from 'node:fs/promises';
import pdfParse from 'pdf-parse';
test('PDF total matches the on-screen total', async ({ page }, testInfo) => {
await page.goto('https://example.com/invoice/INV-2026-0042');
// Capture the source of truth from the rendered page.
const onScreenTotal = (await page.getByTestId('grand-total').innerText()).trim();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download invoice' }).click();
const download = await downloadPromise;
const filePath = testInfo.outputPath('invoice.pdf');
await download.saveAs(filePath);
const { text } = await pdfParse(await fs.readFile(filePath));
const normalized = text.replace(/\s+/g, ' ');
// The exported document must agree with what the user saw.
expect(normalized).toContain(onScreenTotal);
});
Verifying PDFs generated by the browser itself
Not every PDF comes from a server. Sometimes you want to verify a print-to-PDF rendering of a page. In Chromium-based projects, Playwright exposes page.pdf(), which returns a buffer you can assert on directly without a download event. This only works in headless Chromium, and the print stylesheet matters, so combine it with page.emulateMedia() to render the print view.
import { test, expect } from '@playwright/test';
import pdfParse from 'pdf-parse';
test('renders the page to PDF and checks the print layout', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'page.pdf() is Chromium-only');
await page.goto('https://example.com/receipt/9001');
await page.emulateMedia({ media: 'print' });
const buffer = await page.pdf({ format: 'A4', printBackground: true });
expect(buffer.subarray(0, 5).toString('latin1')).toBe('%PDF-');
const parsed = await pdfParse(buffer);
expect(parsed.numpages).toBeGreaterThanOrEqual(1);
expect(parsed.text.replace(/\s+/g, ' ')).toContain('Receipt #9001');
});
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Choosing the right verification strategy
The depth of verification you need depends on the risk. A marketing brochure rarely needs content assertions; a financial statement always does. Use the table below to pick the lightest approach that still catches the failures you care about.
| Goal | Technique | Catches | Cost |
|---|---|---|---|
| Download happened | waitForEvent('download') + suggestedFilename() | Broken button, wrong file type | Very low |
| File is a valid PDF | %PDF- header + size + %%EOF | Corrupt, truncated, zero-byte files | Low |
| Correct page count | pdf-parse → numpages | Missing pages, blank exports | Medium |
| Correct content | pdf-parse → text assertions | Wrong data, mapping bugs | Medium |
| Matches on-screen data | Read page value, assert in PDF text | UI/export drift | Medium-high |
| Print layout fidelity | page.emulateMedia('print') + page.pdf() | Broken print CSS (Chromium only) | Medium |
Handling tricky and flaky cases
- Image-only PDFs. Scanned or fully rasterized PDFs have no text layer, so
pdf-parsereturns empty text. For those you need OCR (for example Tesseract) or you fall back to structural and page-count checks plus a visual comparison of a rendered page image. - New-tab previews. If the app opens a PDF viewer tab instead of downloading, set
acceptDownloads: trueon the context, or capture the new page viacontext.waitForEvent('page')and validate itsurl(). - Slow generation. Server-rendered PDFs may take seconds. The
downloadpromise already waits for the stream to complete, but bumptest.setTimeout()for heavy reports rather than adding fixed sleeps. - Multiple soft assertions. When checking many fields in one document, use
expect.soft()so a single mismatched line does not hide the other failures, then let the test report all of them at once.
import { test, expect } from '@playwright/test';
import fs from 'node:fs/promises';
import pdfParse from 'pdf-parse';
test('checks many invoice fields with soft assertions', async ({ page }, testInfo) => {
await page.goto('https://example.com/invoice/INV-2026-0042');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download invoice' }).click();
const download = await downloadPromise;
const filePath = testInfo.outputPath('invoice.pdf');
await download.saveAs(filePath);
const { text, numpages } = await pdfParse(await fs.readFile(filePath));
const t = text.replace(/\s+/g, ' ');
// soft() lets every field report independently instead of stopping at the first miss.
expect.soft(numpages).toBe(1);
expect.soft(t).toContain('Invoice #: INV-2026-0042');
expect.soft(t).toContain('Acme Corporation');
expect.soft(t).toContain('Total Due: $1,250.00');
});
Conclusion
Reliable Playwright PDF download verification comes down to a clear pipeline: register the download waiter before you click, save the file to an isolated output path, confirm it is structurally a real PDF with the %PDF- header and a sensible size, and then parse the text layer with pdf-parse to assert on page count and the values that actually matter. Layer the checks according to risk, prefer normalized toContain and regex assertions over brittle exact matches, and compare the document against on-screen data whenever you can. Do that and your PDF tests stop being smoke tests and start being a genuine guarantee that users receive the correct document.
FAQ
Can Playwright read PDF content without an external library?
No. Playwright handles the download transport and can save the file or return a buffer via page.pdf(), but it does not parse PDF text. To assert on content you read the saved buffer and pass it to a parser such as pdf-parse or pdfjs-dist, which returns the extracted text and metadata like the page count.
Why is my PDF download test flaky or timing out?
The usual cause is starting waitForEvent('download') after the click instead of before, so the event is missed. Register the download promise first, then trigger the action. For slow server-side generation, increase the test timeout with test.setTimeout() rather than adding fixed sleeps, since the download promise already waits for the stream to finish.
How do I verify a PDF that opens in a new browser tab instead of downloading?
Set acceptDownloads: true when creating the context so Playwright treats it as a download, or capture the new tab with context.waitForEvent('page') and assert on its url(). If you control rendering, you can also load the URL and call page.pdf() in Chromium to get a buffer you can parse directly.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
