Automating File Downloads and Verifying Contents
A test that clicks "Export CSV" and stops there proves nothing — the button might trigger an empty file, the wrong columns, or a stale report. The valuable assertion is on the bytes that land on disk. Playwright surfaces every download as a Download object delivered through the download event, and that object exposes the temporary file via download.path() and a copy helper via saveAs(). The recurring mistake is registering the listener after the click has already fired, so the event is missed and the test hangs until timeout. This page shows how to capture a download deterministically, choose the right way to read it, and then verify its size, name, and parsed contents.
Root cause: the download event is missed if you wait too late
A download begins the instant the browser commits to saving a response rather than rendering it — usually when it sees a Content-Disposition: attachment header or a MIME type it will not display inline. Playwright emits a download event at that moment, but events are not buffered for listeners attached afterward: if you click() first and then call page.waitForEvent('download'), the event has already fired and your wait blocks until it times out. The fix is to start waiting and trigger the action together so the listener is armed before the event arrives. Once captured, the Download object points to a temporary file that Playwright deletes when the browser context closes, so you read it immediately via download.path() or copy it elsewhere with saveAs(). This sits within File Uploads & Downloads, under Advanced Interactions & Test Assertions.
There are two separate clocks running here, and conflating them causes most of the confusion. The download event fires early — as soon as the browser decides to save the response, long before the last byte arrives. Awaiting download.path() is what blocks until the transfer completes. A test that captures the event and asserts on the file without awaiting path() is reading a partially written file, which is why intermittently truncated CSV comparisons show up on large exports but never on a 200-byte fixture. The timeline below contrasts an armed listener with a late one.
Minimal reproducible example
The test exports a report, captures the download, and asserts on both the suggested filename and the parsed CSV contents read from the temporary path.
import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';
test('CSV export contains the expected rows', async ({ page }) => {
await page.goto('/reports');
// Arm the listener and fire the click in the SAME await so the
// download event cannot arrive before we are waiting for it.
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Export CSV' }).click(),
]);
// The server-suggested name, independent of where the file is stored.
expect(download.suggestedFilename()).toBe('orders.csv');
// path() resolves to the temp file once the download completes.
const tempPath = await download.path();
const csv = await readFile(tempPath, 'utf-8');
// Assert on real contents: header plus a known data row.
expect(csv).toContain('id,customer,total');
expect(csv).toContain('1,Acme,120');
expect(csv.trim().split('\n')).toHaveLength(3); // header + 2 rows
});
Step-by-step fix
- Arm
waitForEvent('download')before the trigger. Wrap the wait and the click in a singlePromise.all([...])so the listener is attached before the click fires the download. This is the one ordering rule that prevents the timeout. - Trigger the real download action. Click the export button or link inside the same
Promise.all. Destructure the resolved array to get theDownloadobject:const [download] = await Promise.all(...). - Read the temporary file with
download.path(). Awaitingpath()blocks until the download finishes and returns the temp file location. Read it withfs/promisesto get the bytes for assertions; this temp file is cleaned up when the context closes, so never hold the path across a context boundary. - Persist a copy with
saveAs()when needed. Callawait download.saveAs('/abs/path/orders.csv')to keep the file beyond the test — useful for artifacts attached to a report or for debugging a failure.saveAs()creates any missing parent directories and overwrites an existing target. - Assert on contents, not just existence. Compare the suggested filename with
suggestedFilename(), check the byte length or a hash for binary files, and parse text formats (CSV/JSON) to assert specific values. A non-empty file is the minimum bar, not the goal. - Handle failed or cancelled downloads. Use
download.failure()to surface an error string when a download did not complete, and raise the action timeout for large files sopath()is not cut off mid-transfer, following the budgets described in Configuring Retries and Timeouts for Stable CI. - Pin the data behind the export. A content assertion is only stable if the export is generated from fixed data — seed the backing rows, or stub the report endpoint with Mocking API Responses with Playwright so the CSV is byte-identical on every run.
Choosing between path(), saveAs(), and a stream
Three APIs read the same download, and they are not interchangeable. download.path() gives you Playwright's temporary copy — the shortest route to an in-test assertion, but the file disappears with the context, which matters when you use Browser Contexts & Isolation to create and dispose contexts per test. download.saveAs() copies to a location you own, which is what you want for anything a human or a later job will open. download.createReadStream() hands you a Node readable stream, so a 500 MB export can be hashed or line-counted without ever being held in memory. There is also a fourth route that skips the browser entirely: request the export URL through the API request context and assert on the response body, which is faster but tests the endpoint rather than the button.
Verifying binary and spreadsheet downloads
Text formats let you assert on substrings, but a PDF, ZIP or XLSX has to be checked structurally. Two cheap checks cover most regressions: the magic bytes at the head of the file, which prove the format is what the endpoint claims, and a digest of the whole file compared against a known value when the export is deterministic. When the report is not byte-stable — most are not, because they embed a generation timestamp — assert on a size floor and on the container structure instead. The example below streams the download so memory stays flat regardless of export size.
import { test, expect } from '@playwright/test';
import { createHash } from 'node:crypto';
import { open } from 'node:fs/promises';
test('PDF export is a real PDF of plausible size', async ({ page }) => {
await page.goto('/reports/quarterly');
const downloadPromise = page.waitForEvent('download'); // armed first
await page.getByRole('link', { name: 'Download PDF' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^q[1-4]-\d{4}\.pdf$/);
// Stream the file through a hash so a 500 MB export never lands in memory.
const stream = await download.createReadStream();
const hash = createHash('sha256');
let bytes = 0;
for await (const chunk of stream) {
bytes += chunk.length; // running byte count, no buffering
hash.update(chunk);
}
// Size floor catches the classic "empty report" regression.
expect(bytes).toBeGreaterThan(10_000);
// Magic bytes: every PDF starts with the five ASCII characters %PDF-.
const savedPath = test.info().outputPath('quarterly.pdf');
await download.saveAs(savedPath); // keep it as a test artifact
const handle = await open(savedPath, 'r');
const header = Buffer.alloc(5);
await handle.read(header, 0, 5, 0);
await handle.close();
expect(header.toString('ascii')).toBe('%PDF-');
// Attach to the HTML report so a reviewer can open the exact bytes.
await test.info().attach('quarterly.pdf', { path: savedPath, contentType: 'application/pdf' });
console.log(`digest ${hash.digest('hex')}`); // record, compare when stable
});
Attaching the file through test.info().attach() puts it in the HTML report next to the trace, which is the same artifact channel described in Capturing Screenshots and Video on Test Failure. On CI, write into test.info().outputPath() rather than a hard-coded directory so parallel workers never collide on the same filename.
The download lifecycle and its failure states
A Download object is a small state machine, and knowing which state you are in tells you which method to call. Between the event firing and the transfer completing the download is in flight: path() and createReadStream() are pending, and failure() has not resolved. On success the temp file is complete and both readers return immediately. On failure — a dropped connection, a 500 from the export endpoint, a disk-full worker — failure() resolves to an error string instead of null, while path() resolves to null. Calling download.cancel() moves an in-flight download to the cancelled state and deletes the partial file, which is worth doing in a fixture teardown when a test only needed the event and not the payload.
Troubleshooting variants
The test hangs and times out on waitForEvent
The listener was attached after the click, so the download event was missed. Move the wait into a Promise.all alongside the click, or call page.waitForEvent('download') and store the promise before triggering the action. Never await the click first and then wait for the event. If the ordering is already correct and the wait still expires, the click may not be reaching the control at all — check that the button is not covered by an overlay, since a no-op click produces exactly the same symptom as a missed event.
download.path() returns null or the file is empty
The download was still in flight, or the browser blocked it. Confirm the context allows downloads (it does by default with acceptDownloads enabled) and that you await download.path() so Playwright blocks until completion. If the file is genuinely empty, check download.failure() for an error and verify the server returned a body with a Content-Disposition: attachment header. Inspecting the export response with Intercepting and Modifying Network Requests shows whether the endpoint returned zero bytes or the browser discarded them.
A new tab opens instead of downloading
The link targets _blank and the browser renders the response (a PDF, for example) rather than saving it. Either force the download attribute server-side, or capture the popup and read its response. For programmatic content checks you can also request the resource directly with the API request context and assert the bytes, bypassing the UI entirely. The inverse direction — pushing files into the page — is covered in Handling Multiple File Uploads in Playwright.
It passes locally but fails in the container
Headless Chromium in a slim image often lacks a writable temp directory for the browser's download folder, so path() resolves to null even though the response arrived. Give the container a writable /tmp and run as a user that owns it; the image and permission details are covered in Dockerizing Playwright for Headless CI. The other common cause is a slower network in CI pushing the transfer past the action timeout, which surfaces as a truncated file rather than a missing one.
Verification
Confirm the download is captured and correct three ways. First, the content assertions pass across repeated runs (npx playwright test --repeat-each=10), proving the capture is not racing the transfer. Second, call saveAs() to a known path and open the artifact manually, or attach it to the report so a reviewer can inspect the exact bytes. Third, inspect the run in the Playwright Trace Viewer, where the download action and its completion appear on the timeline, confirming the event fired rather than timed out. As a fourth check on data-heavy exports, parse the file into objects and assert on the shape as well as the values, the same discipline applied in Extracting Tables and Lists to JSON with Playwright.
Frequently Asked Questions
Why does waitForEvent('download') time out?
Because the listener was attached after the click, so the download event had already fired and was not buffered. Wrap page.waitForEvent('download') and the click in a single Promise.all so the wait is armed before the action triggers the download.
What is the difference between download.path() and saveAs()?
download.path() returns the location of Playwright's temporary copy, which exists only until the context closes — read it immediately for assertions. saveAs(targetPath) copies the file to a permanent location you choose, which is what you want for artifacts or files that must outlive the test.
How do I verify the contents of a binary download?
Read the temp file as a Buffer with fs/promises, then assert on its length or a hash (for example a SHA-256 digest) against a known value, since byte-for-byte text comparison is meaningless for binary formats. For text formats like CSV or JSON, parse the file and assert specific values instead.
Where should downloaded files be written on CI?
Write into test.info().outputPath('name.ext') so every worker gets an isolated directory under the test results folder and parallel runs cannot overwrite each other. That folder is also what most CI providers already upload as a build artifact, so the file survives the job without extra configuration.
Can I assert on a download without clicking through the UI?
Yes — issue the export request directly through the API request context and assert on the response body and headers. That is faster and avoids the event-timing problem entirely, but it verifies the endpoint rather than the button, so keep at least one UI-driven test that proves the control is wired to the right URL.