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 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. 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 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.
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. - 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. - 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 increaseactionTimeoutfor large files sopath()is not cut off mid-transfer.
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.
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.
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.
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.
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.