Resource library

QA How-To

How to Download a file in Playwright (2026)

Learn playwright how to download a file with the download event, suggested filenames, saveAs, stream checks, cleanup, CI handling, and robust assertions.

25 min read | 3,338 words

TL;DR

The best How to download a file in Playwright examples arm the event before the trigger, then save or stream and parse the artifact. Use page-level ownership, isolated output paths, format-aware validation, and scoped listeners for multiple files.

Key Takeaways

  • Keep each download event promise beside and before the action that triggers it.
  • Validate file structure and stable business data, not only names and byte counts.
  • Use a compliant parser for CSV and a format-aware library for PDF semantics.
  • Capture batch downloads with a scoped listener and an awaited expected-count promise.
  • Listen on the popup page when that page owns the download.
  • Treat suggested filenames as untrusted input before building destination paths.
  • Save CI artifacts under testInfo.outputPath and retain them according to data policy.

playwright how to download a file is a practical Playwright workflow that depends on choosing the right event, locator, assertion, and diagnostic evidence for the scenario.

How to download a file in Playwright examples are most useful when they show a complete assertion chain, not only how to catch the event. A production-quality download test should identify the user action, capture the correct Download object without a race, preserve the artifact when needed, and verify content that matters to the business.

This guide is organized as a practical pattern catalog. Each example addresses a different failure mode: wrong report type, misleading extension, dynamic filename, multiple files, popup ownership, stream validation, canceled transfer, and parallel CI execution. The code uses Playwright Test, TypeScript, and Node.js standard APIs available in a current 2026 project.

Use the smallest pattern that proves your requirement. A smoke test may need a filename and format signature. A financial report may need parsed totals, selected filters, row counts, and safe artifact retention.

TL;DR

Example Synchronization Strongest assertion
PDF invoice page.waitForEvent('download') PDF signature and invoice identifier
CSV report Download plus saveAs Parsed headers and business row
JSON export createReadStream Parsed schema and stable values
Dynamic name suggestedFilename Exact naming convention
Batch export scoped page.on listener Expected file set and contents
Popup export popup.waitForEvent('download') Correct owner and saved payload
Cancellation cancel and failure failure() returns canceled
CI artifact testInfo.outputPath and attach Isolated path and retained evidence

The core pattern is always armed before the trigger:

const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export' }).click();
const download = await downloadPromise;

1. playwright how to download a file: Understand the Core Contract

import { test, expect } from '@playwright/test';

test('downloads and verifies an invoice', async ({ page }) => {
  await page.goto('/invoices/42');
  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download PDF' }).click();
  const download = await downloadPromise;
  expect(download.suggestedFilename()).toBe('invoice-42.pdf');
  expect(await download.failure()).toBeNull();
  await download.saveAs('test-results/invoice-42.pdf');
});

A small function can standardize race-free capture while keeping the trigger visible. Accept a callback that performs the user action, arm the event immediately before invoking it, and return the Download.

// tests/support/captureDownload.ts
import type { Download, Page } from '@playwright/test';

export async function captureDownload(
  page: Page,
  trigger: () => Promise<unknown>,
): Promise<Download> {
  const event = page.waitForEvent('download');
  await trigger();
  return event;
}

Use it directly in a test:

import { test, expect } from '@playwright/test';
import { captureDownload } from './support/captureDownload';

test('downloads the selected customer statement', async ({ page }) => {
  await page.goto('/customers/C-1042/statements');

  const download = await captureDownload(page, () =>
    page.getByRole('button', { name: 'Download June statement' }).click(),
  );

  expect(download.suggestedFilename())
    .toBe('statement-C-1042-2026-06.pdf');
  expect(await download.failure()).toBeNull();
});

The helper does not save, parse, or decide what is correct. Those responsibilities vary by artifact and remain at the call site. This is preferable to a global page.on handler that accepts every download and writes it into a common directory.

Typing the callback as Promise lets callers use a Locator action without casting. The helper awaits the trigger before returning the event promise. That is appropriate for normal download actions because Playwright can complete the action once the browser begins the download. If an application action itself remains pending for unusual reasons, await both promises with Promise.all after arming the event.

Do not add a retry loop inside the helper. Repeating a click can create duplicate exports or charges. Let Playwright Test retry the complete test with controlled state when that policy is appropriate.

For a full lifecycle explanation before using these patterns, read how to use How to download a file in Playwright.

2. playwright how to download a file: Build the First Reliable Test

A PDF test should reject an HTML error page disguised as a PDF and verify at least one stable business value. Start with the file signature, then use a project-approved PDF parser for text or structure.

This runnable example uses only Node APIs for a transport-level check:

import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';

test('exports a real PDF invoice', async ({ page }, testInfo) => {
  await page.goto('/invoices/INV-1042');

  const event = page.waitForEvent('download');
  await page.getByRole('link', { name: 'Download invoice PDF' }).click();
  const download = await event;

  expect(download.suggestedFilename()).toBe('invoice-INV-1042.pdf');
  expect(download.url()).toContain('/invoices/INV-1042/pdf');

  const destination = testInfo.outputPath('invoice-INV-1042.pdf');
  await download.saveAs(destination);

  expect(await download.failure()).toBeNull();

  const bytes = await readFile(destination);
  expect(bytes.subarray(0, 5).toString('ascii')).toBe('%PDF-');
  expect(bytes.length).toBeGreaterThan(100);
});

The illustrative minimum of 100 bytes is not a universal quality threshold. It only rejects obviously empty fixtures. Real acceptance criteria should parse invoice number, currency, totals, tax, dates, and customer identity. Use a PDF library compatible with your Node version, and keep parser code separate from UI steps.

Do not search raw PDF bytes for visible text. Text may be compressed or encoded, and the test can fail even when a reader displays the correct content. A proper parser understands PDF objects and text extraction.

When PDF layout itself is critical, combine semantic extraction with page rendering and targeted visual assertions. Avoid whole-document visual snapshots for values that legitimately change. Mask or normalize timestamps and generated identifiers only when the product treats them as volatile.

3. Example: Parse a CSV Export With Quoted Fields

CSV is deceptively complex. A row can contain commas, quotes, or newlines inside quoted fields. Install a maintained CSV parser rather than splitting every production file on commas. The following example uses csv-parse, an explicit project dependency:

npm install --save-dev csv-parse
import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { parse } from 'csv-parse/sync';

test('exports filtered orders as CSV', async ({ page }, testInfo) => {
  await page.goto('/orders');
  await page.getByLabel('Status').selectOption('paid');

  const event = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Export CSV' }).click();
  const download = await event;

  const destination = testInfo.outputPath('paid-orders.csv');
  await download.saveAs(destination);

  const text = await readFile(destination, 'utf8');
  const records = parse(text, {
    columns: true,
    bom: true,
    skip_empty_lines: true,
  }) as Array<Record<string, string>>;

  expect(Object.keys(records[0])).toEqual([
    'order_id',
    'customer',
    'total',
    'status',
  ]);
  expect(records).toHaveLength(2);
  expect(records).toEqual(
    expect.arrayContaining([
      expect.objectContaining({
        order_id: 'ORD-1042',
        customer: 'Patel, Asha',
        status: 'PAID',
      }),
    ]),
  );
  expect(records.every(record => record.status === 'PAID')).toBe(true);
});

This test verifies that the UI filter affected the file, not just the screen. A stronger setup seeds exactly known records through an API or database fixture, then asserts all expected IDs. Avoid production-like shared data because row counts can change while the test runs.

Also consider delimiter, byte-order mark, line endings, decimal formatting, timezone, and spreadsheet formula injection based on product requirements. Do not force a specific line ending unless consumers require it. Assert behavior, not incidental serialization.

4. Example: Read a JSON Download as a Stream

createReadStream avoids saving a second copy when the test only needs bytes. The stream is available for a successful download and throws for a failed or canceled one.

This helper collects a reasonably sized JSON export:

import type { Download } from '@playwright/test';

async function readDownloadText(download: Download): Promise<string> {
  const stream = await download.createReadStream();
  const chunks: Buffer[] = [];

  for await (const chunk of stream) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  }

  return Buffer.concat(chunks).toString('utf8');
}

Use it to validate account export semantics:

import { test, expect } from '@playwright/test';

test('downloads a complete account JSON export', async ({ page }) => {
  await page.goto('/settings/privacy');

  const event = page.waitForEvent('download');
  await page
    .getByRole('button', { name: 'Download my data' })
    .click();
  const download = await event;

  expect(download.suggestedFilename()).toMatch(
    /^account-export-\d{4}-\d{2}-\d{2}\.json$/,
  );

  const text = await readDownloadText(download);
  const payload: unknown = JSON.parse(text);

  expect(payload).toEqual(
    expect.objectContaining({
      schemaVersion: 2,
      profile: expect.objectContaining({
        email: 'qa.user@example.test',
      }),
      preferences: expect.any(Object),
    }),
  );
});

For very large JSON, collecting all chunks defeats streaming's memory advantage. Use a streaming JSON parser or save the file and let a tool process it incrementally. Most browser tests should use compact, deterministic fixtures so failures remain fast and diagnosable.

Runtime schema validation with a library such as Zod can make contract errors clearer, but do not add a library solely to restate a few expect assertions. Use the validation system already standard in the project.

5. Example: Assert a Dynamic Suggested Filename Safely

Filenames are part of user experience. They affect discoverability, automation by downstream users, extension association, and duplicate-file behavior. Assert the format without hard-coding values your product intentionally generates at runtime.

Suppose a report is named sales-YYYY-MM-DD.csv in UTC:

import { test, expect } from '@playwright/test';

test('uses the documented report filename format', async ({
  page,
}) => {
  await page.goto('/sales/reports');

  const event = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Export daily sales' }).click();
  const download = await event;

  const filename = download.suggestedFilename();

  expect(filename).toMatch(/^sales-\d{4}-\d{2}-\d{2}\.csv$/);
  expect(filename).not.toContain('/');
  expect(filename).not.toContain('\\');
  expect(filename).not.toContain('..');
});

If the exact date is part of the contract, control the application clock or calculate the expected date in the same documented timezone. A regex validates shape but would allow yesterday's report. The best assertion depends on the risk you are targeting.

Sanitize before using suggestedFilename under any path that an external system will process. Browsers normally return a basename, but defense in depth is cheap:

import path from 'node:path';

const suggested = download.suggestedFilename();
const basename = path.basename(suggested);
expect(basename).toBe(suggested);

await download.saveAs(testInfo.outputPath(basename));

Never interpolate a suggested name into a shell command. Node filesystem and parser APIs accept a path argument directly and avoid command parsing.

6. Example: Capture Several Files From One Batch Action

Some products produce multiple attachments from a single click. A single waitForEvent captures only one. Use a temporary listener, know the expected count, and remove the listener in a finally block.

import {
  test,
  expect,
  type Download,
  type Page,
} from '@playwright/test';

function waitForDownloads(
  page: Page,
  expectedCount: number,
): {
  result: Promise<Download[]>;
  dispose: () => void;
} {
  let resolveResult: (downloads: Download[]) => void;
  const downloads: Download[] = [];

  const result = new Promise<Download[]>(resolve => {
    resolveResult = resolve;
  });

  const handler = (download: Download) => {
    downloads.push(download);
    if (downloads.length === expectedCount) {
      resolveResult(downloads);
    }
  };

  page.on('download', handler);

  return {
    result,
    dispose: () => page.off('download', handler),
  };
}

The test attaches the collector before clicking:

test('downloads all monthly statements', async ({ page }, testInfo) => {
  await page.goto('/statements');
  const collector = waitForDownloads(page, 3);

  try {
    await page.getByRole('button', { name: 'Download all' }).click();
    const downloads = await collector.result;

    const names = downloads
      .map(item => item.suggestedFilename())
      .sort();

    expect(names).toEqual([
      'statement-april.pdf',
      'statement-june.pdf',
      'statement-may.pdf',
    ]);

    for (const download of downloads) {
      await download.saveAs(
        testInfo.outputPath(download.suggestedFilename()),
      );
      expect(await download.failure()).toBeNull();
    }
  } finally {
    collector.dispose();
  }
});

A production helper should incorporate the test timeout or an explicit timeout so a missing third event produces a targeted error. It should also reject if more files arrive than expected. The example keeps mechanics visible, but the business test still asserts the exact set.

When the UI can instead create one ZIP archive, validate the ZIP entry names and contents with a ZIP library. A single archive may provide a simpler user experience and test boundary than a burst of browser downloads.

7. Example: Handle a Download Started Inside a Popup

Capture events from the page that initiates the transfer. If the original page opens a report preview in a popup and the popup owns the download button, wait on the popup.

import { test, expect } from '@playwright/test';

test('downloads a report from its preview popup', async ({
  page,
}, testInfo) => {
  await page.goto('/reports/quarterly');

  const popupEvent = page.waitForEvent('popup');
  await page.getByRole('link', { name: 'Preview report' }).click();
  const popup = await popupEvent;

  await expect(popup.getByRole('heading', { level: 1 }))
    .toHaveText('Quarterly report');

  const downloadEvent = popup.waitForEvent('download');
  await popup.getByRole('button', { name: 'Download PDF' }).click();
  const download = await downloadEvent;

  expect(download.page()).toBe(popup);
  const destination = testInfo.outputPath('quarterly-report.pdf');
  await download.saveAs(destination);
  expect(await download.failure()).toBeNull();
});

Download.page() returns the Page that originated the download. That assertion helps catch a workflow that unexpectedly downloads from the opener or another tab.

Arm the popup event before clicking the preview link, then arm the download event before clicking in the popup. These are two separate event boundaries. Fixed sleeps are unnecessary.

A BrowserContext download listener can observe all pages, but broad scope makes association harder when the product performs background or concurrent downloads. Prefer the page-level event whenever ownership is known.

Dialogs in popup workflows follow the same ownership principle. The How to handle alerts in Playwright examples guide covers that companion pattern.

8. Example: Compare the Browser Download With an API Response

A hybrid test can prove that the UI selects the correct export and that its content matches an API contract. Use this sparingly because it couples UI and service layers. It is valuable for high-risk exports where the browser transformation or chosen filters are common defect sources.

import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';

test('UI export matches the report API', async ({
  page,
  request,
}, testInfo) => {
  const expectedResponse = await request.get(
    '/api/reports/orders?status=paid',
  );
  expect(expectedResponse.ok()).toBe(true);
  const expectedCsv = await expectedResponse.text();

  await page.goto('/orders');
  await page.getByLabel('Status').selectOption('paid');

  const event = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Export CSV' }).click();
  const download = await event;

  const destination = testInfo.outputPath('orders.csv');
  await download.saveAs(destination);
  const actualCsv = await readFile(destination, 'utf8');

  expect(normalizeNewlines(actualCsv))
    .toBe(normalizeNewlines(expectedCsv));
});

function normalizeNewlines(value: string): string {
  return value.replace(/\r\n/g, '\n');
}

This example assumes the API and browser export are expected to serialize identically except for line endings. If ordering, timestamps, or generated metadata can differ, parse both artifacts and compare stable records instead.

Do not use the same implementation as the system under test to calculate expected totals. Seed known source records and compute simple expectations independently. Otherwise, a shared defect can make both sides agree.

APIRequestContext shares cookie state only when designed through the relevant fixture or context. Confirm authentication behavior in your suite rather than assuming the request fixture represents the same user automatically.

9. Example: Test Cancellation and Failed Transfers

download.cancel() is an explicit Playwright API. A successful cancellation causes download.failure() to resolve to canceled. Use a deterministic slow endpoint in a test environment so the download remains active.

import { test, expect } from '@playwright/test';

test('reports a canceled large export', async ({ page }) => {
  await page.goto('/test-fixtures/large-export');

  const event = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Start large download' }).click();
  const download = await event;

  await download.cancel();

  expect(await download.failure()).toBe('canceled');
  await expect(
    page.getByRole('status', { name: 'Export canceled' }),
  ).toBeVisible();
});

The status assertion is valid only if the product actually observes browser cancellation and exposes that message. Many applications do not receive a reliable client-side cancellation signal. In that case, assert only Playwright's download state or test the application's own cancel-export control separately.

A failed attachment transfer may produce a platform-specific failure message. Avoid asserting an entire unstable browser error string unless it is your public contract. Prefer expect(await download.failure()).not.toBeNull() and log the value for diagnosis.

An HTTP 4xx response may render an error page instead of creating a Download. For that scenario, assert the response and visible error. Do not treat absence of a download as proof of a correct error message.

10. Advanced Playwright Download Handling Examples for CI

A CI-ready example saves the file in a unique test directory and attaches it only when useful. The fixture can inspect testInfo.status after the test body:

import { test as base } from '@playwright/test';
import { access } from 'node:fs/promises';

export const test = base.extend<{ exportPath: string }>({
  exportPath: async ({}, use, testInfo) => {
    const path = testInfo.outputPath('export.csv');
    await use(path);

    if (testInfo.status !== testInfo.expectedStatus) {
      try {
        await access(path);
        await testInfo.attach('failed-export', {
          path,
          contentType: 'text/csv',
        });
      } catch {
        // No artifact was created, so there is nothing to attach.
      }
    }
  },
});

A test saves to exportPath and performs domain assertions. If it fails, the fixture attaches the artifact before teardown. This keeps successful runs lean while preserving evidence.

CI portability rules are straightforward:

  • Prefer saveAs over download.path() because path is unavailable with remote browser connections.
  • Keep destination paths inside testInfo.outputPath.
  • Use synthetic data with no secrets or personal information.
  • Avoid worker-shared directories and mutable shared accounts.
  • Upload test-results according to a short, explicit retention policy.
  • Enable trace on first retry and retain the relevant trace for failures.
  • Record the filename, URL, byte size, and parser error in failure diagnostics.

If a CI job times out before a download appears, use Playwright timeout troubleshooting to distinguish a missing event from a slow transfer.

Interview Questions and Answers

Q: Why is page.waitForEvent registered before the click?

A download can start immediately during the click. Registering first guarantees the listener exists when the event is emitted. Awaiting the event before the click would deadlock the sequence, while registering afterward creates a race.

Q: What should a strong download assertion include?

It should cover the stable filename contract, successful transfer, actual format, and important business content. Depending on risk, it can also verify selected filters, record counts, ordering, encoding, and security-sensitive values. Extension and size alone are weak evidence.

Q: How do you test multiple files triggered by one action?

Install a scoped page.on('download') listener before the action, collect exactly the expected number of Download objects, await the collector, and remove the listener in finally. Then assert the complete filename set and validate each payload.

Q: Why should complex CSV not be parsed with split(',')?

CSV permits quoted delimiters, escaped quotes, embedded newlines, and optional byte-order marks. Simple splitting corrupts valid records and produces misleading tests. A standards-compliant parser turns the file into records before assertions.

Q: How do popup downloads change the test?

The event must be captured from the popup Page that initiates the transfer. First arm and receive the popup, then arm popup.waitForEvent('download') before clicking its download control. download.page() can confirm ownership.

Q: What is the purpose of download.createReadStream?

It exposes successful download bytes as a Node readable stream. It supports hashing and incremental parsing without requiring a saved copy or loading the entire file into memory. It throws for failed or canceled downloads.

Q: How would you make a filename assertion robust?

I encode the documented stable format, assert exact controlled identifiers, and control time when the date must be exact. I also reject path separators and traversal fragments before using the suggestion. I avoid a pattern so broad that a wrong report or extension passes.

Q: How should CI retain downloaded artifacts?

Save them under the test's isolated output directory and attach only useful artifacts, often only on failure. Use synthetic data, define retention, and protect access. Never retain customer exports by default.

Common Mistakes

  • Showing only event capture and calling it a complete test. Validate bytes and business meaning.
  • Reusing one global download listener. It creates ownership and teardown problems.
  • Parsing every CSV with split(','). Quoted fields require a real parser.
  • Searching raw PDF bytes for visible text. Use a PDF parser after checking the signature.
  • Hard-coding a dynamic date without controlling the relevant clock and timezone.
  • Trusting suggestedFilename as a path or shell fragment.
  • Capturing a popup-owned download from the opener page.
  • Assuming a page.on collector will finish before test teardown without an awaited completion promise.
  • Comparing UI and API artifacts byte for byte when ordering or metadata is intentionally variable.
  • Expecting a download event for an application error page that never starts a transfer.
  • Testing cancellation against a tiny file that completes before the cancel call matters.
  • Attaching every successful artifact in CI, which increases storage and data exposure.
  • Using download.path in a suite that can run against a remote browser.
  • Allowing retries to reuse a stale export job or shared output filename.

Conclusion

These How to download a file in Playwright examples move from event capture to real quality evidence. Keep the event next to its trigger, select saveAs or createReadStream based on the validator, and assert the artifact's stable format and business content. Scope listeners tightly for batch and popup scenarios.

Choose two or three patterns that match your product's exports, then add worker-safe paths, deterministic test data, and failure-only artifact retention. A download test earns its place in the suite when it can tell the team not just that a file appeared, but that the user received the correct file.

Interview Questions and Answers

Describe a race-free Playwright download pattern.

I create the download event promise before the action, perform the action, and await the promise afterward. Then I save or stream the file in the same test control flow. This makes event ownership and teardown deterministic.

What makes a download test more than a transport check?

A transport check proves a nonempty file appeared. A quality test parses the format and verifies stable business facts such as the selected account, filters, totals, headers, or record set. It also catches error pages saved under a misleading extension.

How do you safely test a batch download?

I install a named, scoped listener before the action and collect exactly the expected count into an awaited promise. I remove the listener in finally, assert the entire filename set, and validate each completed payload. I include a targeted timeout in the production helper.

Why is split(',') insufficient for CSV validation?

CSV can contain quoted commas, quotes, newlines, and encoding markers. A split-based test can misread correct files and miss serialization defects. I use a compliant parser and assert the resulting records.

How do you validate a dynamic download filename?

I assert controlled identifiers exactly and express only genuinely dynamic segments with a constrained pattern. If the date is contractual, I control or calculate time in the documented timezone. I also prevent path traversal before saving.

What changes for a download initiated in a popup?

I listen on the popup because Playwright emits the event on the page that starts the transfer. I arm the popup event before opening it, then arm its download event before the download action. I can assert download.page() equals that popup.

When is a UI and API download comparison appropriate?

It is appropriate for high-risk exports where the UI's selected filters or transformations often fail. I compare parsed stable records rather than volatile bytes and avoid using the same faulty calculation as the expected oracle. I do not add this coupling to every simple smoke test.

What CI controls would you apply to download examples?

I use testInfo.outputPath, unique data, saveAs for remote portability, traces on retry, and narrow failure attachments. I prohibit production personal data and set retention and access rules. Shared filenames and unawaited listeners are not allowed.

Frequently Asked Questions

What is a complete Playwright download example?

A complete example arms page.waitForEvent('download'), performs the user action, receives the Download, saves or streams it, and asserts filename, transfer status, format, and stable business content.

How do I validate a PDF downloaded by Playwright?

Save the file, verify its %PDF- signature, then use a PDF-aware parser to extract required text, metadata, or page structure. Do not search raw PDF bytes for visible text.

How do I validate a CSV download in Playwright?

Save and read the file, parse it with a standards-compliant CSV parser, and assert headers plus seeded business records. Use string splitting only for deliberately constrained fixtures without quoting.

How can I capture multiple Playwright downloads from one click?

Register a temporary page.on('download') handler before the click, collect the expected number, await a completion promise, and remove the handler in finally. Then validate the complete file set.

How do I handle a download in a popup?

Wait for the popup before the action that opens it, then call popup.waitForEvent('download') before clicking the popup's download control. Capture from the page that initiates the transfer.

Is download.suggestedFilename safe to use as a path?

Treat it as untrusted browser output. Reduce it to a basename, reject separators or traversal fragments, and place it under a controlled directory such as testInfo.outputPath.

How do I test a canceled Playwright download?

Capture the Download, call await download.cancel(), and expect download.failure() to be 'canceled'. Use a deterministic slow test endpoint so the transfer does not finish first.

Should download artifacts be attached to every CI result?

Usually no. Save under the isolated test output and attach useful files on failure or for a targeted audit. Apply synthetic-data and retention rules because exports may contain sensitive information.

Related Guides