QA How-To
How to Assert a downloaded PDF in Playwright (2026)
Learn Playwright how to assert a downloaded PDF using event-safe downloads, file checks, PDF.js text extraction, metadata, cleanup, and CI-ready tests.
24 min read | 2,968 words
TL;DR
Capture the download and click together with `Promise.all`, save it to a unique test output path, verify download success and the PDF signature, then parse bytes with PDF.js for page and text assertions. Use rendering or a dedicated visual comparison only when layout is the requirement.
Key Takeaways
- Start `waitForEvent('download')` before the click that triggers the file.
- Check `download.failure()` before treating the artifact as valid.
- Assert filename and MIME evidence, but verify the `%PDF-` signature from bytes.
- Parse the saved bytes with a maintained PDF library for page and text assertions.
- Normalize extracted text and assert stable business facts instead of visual line breaks.
- Use unique temporary directories and explicit cleanup for parallel-safe CI runs.
If you search for Playwright how to assert a downloaded PDF, the reliable answer has two layers. First, Playwright must capture the browser download without an event race. Second, a PDF-aware library must validate the saved artifact because Playwright does not expose PDF text or page assertions for arbitrary downloaded files.
This guide uses Playwright Test with TypeScript and pdfjs-dist. It separates transport checks, document-structure checks, business-content checks, and visual checks so failures explain what actually broke.
TL;DR
| Assertion layer | What to check | Tool |
|---|---|---|
| Download event | Event captured, no failure | Playwright Download |
| File identity | Suggested filename, size, %PDF- signature |
Node path and byte APIs |
| Document structure | Page count, metadata when stable | PDF.js |
| Business content | Required labels, IDs, totals, dates | PDF.js extracted text |
| Visual layout | Rendered pages or approved snapshots | Dedicated rendering workflow |
The core event pattern is const [download] = await Promise.all([page.waitForEvent('download'), button.click()]). Registering the wait after the click is a race, because the download can begin before Playwright starts listening.
1. Playwright How to Assert a Downloaded PDF: Define the Contract
A statement such as "the PDF downloads" hides several independent requirements. The click may need to start one download. The server must return complete PDF bytes. The filename might follow a naming rule. The document might require a certain type, page count, invoice number, customer name, total, and accessibility tags. Its visual layout might also matter.
Turn those into layered assertions. Transport checks prove an artifact arrived. File checks prove it is nonempty and identifies as PDF. Parser checks prove the bytes form a readable document. Content checks prove business data. Rendering checks prove placement, fonts, clipping, and pagination. Not every test needs every layer.
For an invoice, a high-value functional test usually checks successful download, a safe filename, PDF signature, parseability, invoice ID, line-item label, currency, and total. A separate visual test can cover the template with fixed data. This split keeps ordinary regression tests stable when a logo moves or text wrapping changes.
Never assert confidential production documents. Generate synthetic data and ensure CI artifacts do not expose names, addresses, account numbers, or access tokens. The Playwright download testing guide covers general download events beyond PDFs.
2. Install Playwright Test and a PDF Parser
Create or use a TypeScript Playwright Test project. Install the runner and browser through the versions approved by your repository, then add PDF.js as a development dependency.
npm install --save-dev @playwright/test pdfjs-dist typescript
npx playwright install chromium
Pin dependencies through the lock file and review upgrades. pdfjs-dist publishes builds for modern JavaScript environments, and its packaging details can change between major releases. The examples use the current ESM-style import path exposed by recent packages. Verify the package's official migration notes when upgrading.
A basic playwright.config.ts can set a base URL and retain traces on first retry:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'on-first-retry',
},
});
Node downloads and parsing happen in the test worker, not inside the browser page. Use Node modules such as node:fs/promises, node:path, and node:os. Do not call browser-only APIs to inspect a local path. CI must allow the worker to write to its output or temporary directory.
3. Capture the Download Without an Event Race
Playwright emits a download event when an attachment download begins. Start waiting before the action. Promise.all expresses that ordering clearly:
import { test, expect } from '@playwright/test';
test('downloads the invoice PDF', async ({ page }) => {
await page.goto('/invoices/INV-2026-0042');
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Download PDF' }).click(),
]);
expect(download.suggestedFilename()).toBe('invoice-INV-2026-0042.pdf');
expect(await download.failure()).toBeNull();
});
download.suggestedFilename() comes from the browser's download naming behavior, commonly influenced by Content-Disposition. It does not prove file content. download.failure() resolves to an error string when the download failed or null when no failure is reported. Check it before parsing.
Avoid using page.waitForTimeout() and then searching the operating system Downloads folder. Playwright manages downloads in a temporary location for the browser context, and that location is cleaned when the context closes. The event gives the test the exact artifact tied to the action.
If one click legitimately triggers multiple downloads, collect and identify them deliberately. Do not assume event order unless the product contract defines it. If a new tab triggers the download, capture the popup and then register the download expectation on the relevant page or context before the final action.
4. Save the Download to a Parallel-Safe Test Path
download.saveAs(path) copies or waits for the artifact and saves it at the chosen path. Use Playwright Test's testInfo.outputPath() so each test receives a unique output area.
import { test, expect } from '@playwright/test';
test('saves a downloaded invoice', async ({ page }, testInfo) => {
await page.goto('/invoices/INV-2026-0042');
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Download PDF' }).click(),
]);
expect(await download.failure()).toBeNull();
const pdfPath = testInfo.outputPath('invoice.pdf');
await download.saveAs(pdfPath);
});
This is safer than a shared downloads/invoice.pdf, which parallel workers can overwrite. Playwright owns the per-test output lifecycle according to reporter and retention configuration. If your team uses os.tmpdir() instead, create a unique directory with fs.mkdtemp() and remove it in finally.
download.path() can return the temporary artifact path in local execution, but saving to an explicit test path makes ownership and diagnostics clearer. In remote browser arrangements, path access can be restricted, while saveAs and createReadStream are designed around the download abstraction.
Use testInfo.attach() only when retaining the PDF is safe and useful. Attachments may be uploaded to long-lived CI storage. For sensitive documents, attach redacted diagnostic data or hashes rather than the original file.
5. Assert Filename, Size, and the PDF Signature
Extension and MIME headers can lie. A server error page can be named .pdf. Read the bytes and verify the standard PDF header begins with %PDF-. Also reject an empty or implausibly tiny artifact using a threshold derived from your fixture, not a universal number.
import { readFile, stat } from 'node:fs/promises';
import { extname } from 'node:path';
import { expect } from '@playwright/test';
const bytes = await readFile(pdfPath);
const fileInfo = await stat(pdfPath);
expect(extname(download.suggestedFilename()).toLowerCase()).toBe('.pdf');
expect(fileInfo.size).toBeGreaterThan(100);
expect(bytes.subarray(0, 5).toString('ascii')).toBe('%PDF-');
The illustrative 100-byte floor only catches obviously empty responses. It is not a PDF validity standard. Parser success provides a stronger structural check. Do not assert an exact byte size because metadata, compression, fonts, timestamps, and library upgrades can change it without changing business content.
A PDF header is normally at the start of the file. If your system adds transport wrappers or byte-order artifacts, treat that as a content-contract question rather than scanning arbitrarily for %PDF-, which could appear inside an HTML response.
The browser may expose response headers through a separate network event, but matching a download to a response can be awkward and browser-dependent. Filename, failure state, actual bytes, and parser validation are generally more robust for the UI download test. Cover precise Content-Type and Content-Disposition header contracts at the API layer.
6. Parse Page Count and Metadata with PDF.js
Load the saved bytes into PDF.js. Disable its worker in a Node test by using the legacy build when that is the supported Node entry for your installed version. A helper can isolate package-specific imports:
import { readFile } from 'node:fs/promises';
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
export async function openPdf(pdfPath: string) {
const buffer = await readFile(pdfPath);
return getDocument({ data: new Uint8Array(buffer) }).promise;
}
Use it in a test:
const pdf = await openPdf(pdfPath);
expect(pdf.numPages).toBe(2);
const metadata = await pdf.getMetadata();
expect(metadata.info).toEqual(expect.objectContaining({
Title: 'Invoice INV-2026-0042',
}));
await pdf.destroy();
Only assert metadata your product deliberately controls. PDF producers may add creation dates, producer strings, IDs, or timezone-dependent values. Exact metadata snapshots are brittle and can reveal internal library changes rather than customer-visible defects.
Always release parser resources with destroy(), preferably in finally if later assertions can throw. Page count is useful when pagination is a requirement, such as one summary page plus one terms page. If line-item growth legitimately changes page count, assert a range or verify required page content instead of fixing the number.
Encrypted PDFs require passwords and separate test cases. Decide whether encryption, permissions, and password handling are part of the contract. Never place a real document password in source control or failure output.
7. Extract and Normalize PDF Text
PDF text is stored as positioned glyph runs, not paragraphs. Extraction may split a word into several items or join neighboring columns. Build a helper that reads each page and combines string items, then normalize whitespace for stable content assertions.
import type { PDFDocumentProxy } from 'pdfjs-dist';
export async function extractPdfText(pdf: PDFDocumentProxy): Promise<string> {
const pages: string[] = [];
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const page = await pdf.getPage(pageNumber);
const content = await page.getTextContent();
const text = content.items
.filter((item): item is typeof item & { str: string } => 'str' in item)
.map(item => item.str)
.join(' ');
pages.push(text);
page.cleanup();
}
return pages.join('\n').replace(/\s+/g, ' ').trim();
}
Type shapes can evolve between PDF.js releases. If the type predicate needs adjustment after an upgrade, keep that adaptation inside the helper rather than weakening tests with any everywhere. At runtime, checking 'str' in item distinguishes text items from marked-content items.
Normalize whitespace because visual line breaks and item boundaries are not stable semantic contracts. Do not remove all punctuation or digits, because doing so can make incorrect monetary values pass. For complex tables, plain text order may not match visual reading order. Assert uniquely labeled facts or use coordinate-aware extraction and rendering when column relationships matter.
Scanned PDFs contain images without a text layer. PDF.js cannot recover words that are not encoded as text. OCR is a separate, probabilistic process and should be tested with tolerances and controlled fixtures.
8. Assert Stable Business Content
Assert exact identifiers and calculated values generated by the test fixture. Use labels to reduce false positives. If the UI creates invoice INV-2026-0042 with a known subtotal and tax, validate those facts in the PDF.
const text = await extractPdfText(pdf);
expect(text).toContain('Invoice INV-2026-0042');
expect(text).toContain('Customer: Test Customer');
expect(text).toMatch(/Subtotal\s+USD\s+120\.00/);
expect(text).toMatch(/Tax\s+USD\s+12\.00/);
expect(text).toMatch(/Total\s+USD\s+132\.00/);
Build expected money values with the same business rules but not by calling the exact production formatter or calculator under test. If both expected and actual use one faulty helper, the test can agree with the defect. Use fixed fixture values whose correct totals are independently known.
Dates and timezones deserve explicit design. Generate the document from a frozen or controlled business date when possible. If the server chooses the current date, compare against an allowed range using the product's timezone, not the CI worker's locale. Avoid regex patterns so loose that any date passes.
Negative assertions can matter, such as ensuring an internal account ID or deleted line item is absent. Use them cautiously because text extraction limitations can create false confidence. For privacy-sensitive fields, also verify the data source and template logic through lower-level tests.
The test data management guide covers deterministic records and cleanup strategies.
9. Build a Reusable PDF Assertion Helper
Centralize mechanics while keeping business expectations in the test. The helper can capture bytes, validate the signature, open PDF.js, and return normalized text and page count. It should not contain invoice-specific values.
import { readFile } from 'node:fs/promises';
import { expect } from '@playwright/test';
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
export async function inspectPdf(pdfPath: string) {
const buffer = await readFile(pdfPath);
expect(buffer.subarray(0, 5).toString('ascii')).toBe('%PDF-');
const document = await getDocument({
data: new Uint8Array(buffer),
}).promise;
try {
const text = await extractPdfText(document);
return { pageCount: document.numPages, text };
} finally {
await document.destroy();
}
}
Returning a small immutable result prevents tests from forgetting parser cleanup. Include page-level text when requirements refer to a specific page. Avoid returning raw parser objects unless the caller needs advanced inspection and accepts lifecycle ownership.
Make failure output actionable. A custom matcher can show missing expected text and a safe excerpt, but excerpts may leak sensitive content. In regulated contexts, report a hash, page number, or missing field label. Keep the original artifact only under approved retention and access controls.
Do not turn one helper into a universal PDF framework prematurely. Start with signature, page count, and text. Add metadata, annotations, forms, attachments, signatures, or accessibility structure only when requirements demand them.
10. Decide When Text Checks Are Not Enough
Text extraction can prove words and values exist, but not that they are visible, ordered correctly, unclipped, or using the right font. A footer could overlap a total while extracted text remains perfect. Visual requirements need rendered-page evidence.
| Requirement | Best test layer |
|---|---|
| Download starts | Playwright browser test |
| Correct headers | API or contract test |
| Valid PDF and pages | Parser assertion |
| Invoice values | Extracted text or PDF model |
| Pixel layout | Render pages and compare |
| Screen-reader structure | Tagged-PDF accessibility inspection |
| Cryptographic signature | Specialized PDF signature validator |
For visual checks, render each page at a fixed scale in a controlled environment, then compare against reviewed baselines with tolerances appropriate to font rasterization. Pin fonts and rendering dependencies. Mask intentionally dynamic fields or inject fixed data. Do not take a screenshot of the browser's built-in PDF viewer as the primary method, because viewer chrome, browser version, zoom, and loading behavior introduce unrelated variance.
A visual test complements, not replaces, semantic assertions. A baseline can approve a wrong total if the wrong total looks identical in shape. Conversely, text checks can miss clipping. Choose layers based on failure risk.
11. Handle Browser Behavior, Authentication, and API Alternatives
Some links navigate to a PDF inline rather than download it. Playwright's download event applies when the browser treats the response as a download. If the application opens an inline viewer, either assert the navigation and fetch the underlying document through an authenticated request context, or change the requirement to the actual browser behavior. Do not force a download event that the product does not produce.
Playwright's request fixture can validate a PDF endpoint directly when the UI trigger is already covered separately:
test('invoice endpoint returns PDF bytes', async ({ request }) => {
const response = await request.get('/api/invoices/INV-2026-0042/pdf');
expect(response.ok()).toBeTruthy();
expect(response.headers()['content-type']).toContain('application/pdf');
const body = await response.body();
expect(body.subarray(0, 5).toString('ascii')).toBe('%PDF-');
});
The request fixture may share configured headers or storage depending on how the context is created, but verify authentication explicitly. UI download tests protect wiring and user permissions, while API tests provide direct header and byte checks. Keeping both small is often clearer than one test that diagnoses every layer poorly.
For signed or expiring URLs, avoid asserting the entire URL. Assert that the authorized user receives the correct document and that an unauthorized request is rejected. Never log signed query strings into long-lived reports.
12. Make PDF Download Tests Reliable in CI
CI workers need installed browser binaries, Node dependencies, fonts for rendering tests, and writable output paths. Run the same lock-file installation used locally. Use synthetic accounts and unique document IDs per worker. Wait for backend document generation through an observable status, not a fixed delay.
A download can begin before server-side generation has completed if the response streams bytes. download.failure() and saveAs() wait for completion, but parser failure can still reveal truncated or invalid output. Retain a trace for browser events and safe server correlation IDs for backend diagnosis. A trace does not automatically make the PDF content understandable, so report the assertion layer that failed.
Parallel tests should not share filenames, records, or cleanup. testInfo.outputPath() solves local path collision, while unique fixture data solves server collision. If generation uses a queue, ensure one test cannot download another test's most recent report. Assert the expected ID inside the file.
Set timeouts based on documented generation behavior and environment capacity. Raising the entire test timeout to several minutes can hide a stuck job. Prefer a status polling helper with a clear deadline, then a bounded download expectation. The Playwright CI debugging guide explains traces, retries, and environment evidence.
13. Playwright How to Assert a Downloaded PDF End-to-End Example
The following test combines safe capture, save, signature, parse, and business assertions. It assumes the helper functions above are in pdf-helpers.ts.
import { test, expect } from '@playwright/test';
import { inspectPdf } from './support/pdf-helpers';
test('customer downloads a correct invoice PDF', async ({ page }, testInfo) => {
const invoiceId = 'INV-2026-0042';
await page.goto(`/invoices/${invoiceId}`);
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Download PDF' }).click(),
]);
expect(await download.failure()).toBeNull();
expect(download.suggestedFilename()).toBe(`invoice-${invoiceId}.pdf`);
const destination = testInfo.outputPath(`${invoiceId}.pdf`);
await download.saveAs(destination);
const pdf = await inspectPdf(destination);
expect(pdf.pageCount).toBe(1);
expect(pdf.text).toContain(`Invoice ${invoiceId}`);
expect(pdf.text).toContain('Customer: Test Customer');
expect(pdf.text).toMatch(/Total\s+USD\s+132\.00/);
});
The hard-coded ID is suitable only if the test fixture creates or reserves that deterministic record. In a parallel suite, create a unique invoice and pass its returned ID and independently calculated expected values into the assertions. Cleanup the invoice through an API fixture.
This test should fail distinctly for event absence, download failure, filename mismatch, invalid signature, parser error, page mismatch, and content mismatch. That diagnostic separation is the main advantage of layered assertions.
14. Playwright How to Assert a Downloaded PDF in Interviews
A strong interview answer begins with event ordering: register waitForEvent('download') before clicking, usually in Promise.all. Then check failure state, save to a test-specific path, validate the actual byte signature, parse with a PDF library, and assert stable business content. Mention cleanup, parallel isolation, and sensitive artifacts.
Explain that Playwright controls the browser and download lifecycle but is not a general PDF semantic parser. A companion library is the correct tool for page and text inspection. Distinguish semantic and visual validation. Interviewers often ask how you would avoid brittle exact-text assertions, how to handle scanned documents, and why checking .pdf extension is insufficient.
Be prepared to diagnose a CI-only failure. Check event ordering, generation status, download failure, saved byte size, signature, parser exception, worker resource pressure, fonts for rendering, and test-data collision. Do not start by adding a sleep.
Interview Questions and Answers
Q: How do you capture a Playwright download safely?
Create the download wait before the triggering click, commonly with Promise.all. Then inspect the returned Download, check failure, and save it to a unique path.
Q: Is a .pdf filename enough?
No. An HTML error response can use a PDF filename. Check the bytes begin with %PDF- and parse the document.
Q: How do you assert PDF text?
Save or read the download, load its bytes with a PDF parser such as PDF.js, extract page text, normalize whitespace, and assert stable business facts.
Q: When is visual comparison required?
Use rendering when placement, clipping, fonts, page breaks, or visual branding is the requirement. Text extraction cannot prove visual correctness.
Q: How do you avoid parallel collisions?
Use testInfo.outputPath() or a unique temporary directory, unique server-side records, and independent accounts. Never share one fixed output filename.
Q: Why not wait with a timeout and inspect Downloads?
It creates a race, wastes time, and disconnects the file from the triggering action. Playwright's download event gives deterministic ownership and completion APIs.
Q: How do you test a scanned PDF?
A scanned document may have no text layer, so ordinary extraction returns little or nothing. Use an OCR workflow with controlled fixtures and tolerances, plus rendering checks.
Common Mistakes
- Calling
waitForEvent('download')after the click. - Checking only the filename extension or suggested filename.
- Reading Playwright's temporary path after the context has closed.
- Using one shared destination path across parallel workers.
- Asserting exact byte size, hashes, whitespace, or line breaks for dynamic PDFs.
- Treating extracted text as proof of unclipped visual layout.
- Using screenshots of the built-in browser viewer as the only validation.
- Keeping customer PDFs in CI artifacts without a retention review.
- Forgetting to destroy parser resources or clean custom temp directories.
- Adding a fixed sleep for server-side document generation.
Conclusion
The practical answer to Playwright how to assert a downloaded PDF is to separate download handling from document validation. Capture the event before the click, save to an isolated path, verify failure state and bytes, parse the document, then assert page and business content that the product promises.
Add rendered visual checks only for layout risk, and keep sensitive documents out of casual artifacts. Start with one deterministic invoice fixture and make every failure identify its layer before expanding coverage.
Interview Questions and Answers
How would you assert a downloaded PDF in Playwright?
I register the download wait before the click, capture the `Download`, and check `failure()`. I save it to a unique test output path, verify the `%PDF-` signature, then parse it with a PDF library for page count and normalized text. I assert stable business values and use rendering only for visual requirements.
Why use Promise.all for a Playwright download?
The download may start immediately after the click. `Promise.all` lets the event wait exist before the action while both complete together. Waiting only after clicking creates a race and intermittent timeouts.
Why is the suggested filename not sufficient validation?
The browser derives it from response metadata, and incorrect HTML or JSON can still be named with a `.pdf` extension. Actual bytes and parser success prove more. Business assertions then prove it is the right document.
How do you validate PDF content reliably?
I extract text page by page with a PDF parser and normalize whitespace because PDF text items are position-based. I assert labeled identifiers and values, not exact line wrapping. Complex tables may require coordinate-aware extraction or rendering.
How do semantic and visual PDF checks differ?
Semantic checks validate parseability, pages, metadata, and business text. Visual checks render the page to find overlap, clipping, font, and pagination defects. A robust strategy uses each only where its risk applies.
How do you make PDF tests parallel-safe?
Each test gets a unique local output path, server-side document record, and user identity where needed. Cleanup owns only that test's resources. I also assert the unique document ID inside the PDF so cross-test mix-ups fail clearly.
What would you inspect for a CI-only PDF download failure?
I inspect the trace, `download.failure()`, byte size and signature, parser error, generation status, worker disk and memory, and data collisions. For rendering failures I also compare fonts and renderer versions. I avoid fixed sleeps because they conceal the failing layer.
Frequently Asked Questions
Can Playwright read PDF text directly?
Playwright manages the browser and download but does not provide general text assertions for arbitrary downloaded PDFs. Save or stream the bytes and use a maintained PDF parser such as PDF.js.
How do I wait for a PDF download in Playwright?
Start `page.waitForEvent('download')` before the click, normally in `Promise.all`. This prevents missing a fast download event.
How can I prove a downloaded file is a PDF?
Check download failure, inspect the `%PDF-` byte signature, and load the bytes with a PDF parser. A filename extension or MIME header alone is insufficient.
Where should Playwright save downloaded files?
Use a unique per-test path such as `testInfo.outputPath()`. Shared paths create collisions during parallel execution and make cleanup unclear.
Why does extracted PDF text have strange spaces?
PDFs store positioned text items rather than semantic paragraphs. Join text items and normalize whitespace, while using coordinate-aware parsing or rendering for complex table relationships.
Can Playwright validate PDF layout?
Not through text extraction alone. Render pages in a controlled environment and compare approved images when fonts, clipping, placement, or page breaks are requirements.
How should I test a scanned PDF?
Scanned PDFs may contain images without a searchable text layer. Use OCR with controlled expectations and combine it with rendered-page validation.
Related Guides
- How to Assert a downloaded PDF in Cypress (2026)
- How to Assert a downloaded PDF in Selenium (2026)
- How to Debug a failing test in VS Code in Playwright (2026)
- How to Download a file in Playwright (2026)
- How to Debug a failing test in VS Code in Cypress (2026)
- How to Debug a failing test in VS Code in Selenium (2026)