If a test clicks an export button and then waits for a desktop save dialog, the test is already on the wrong path. OS dialogs are outside the browser automation boundary, and shared download folders turn a deterministic test into a race between workers, retries, and leftover files.

The reliable pattern is simple: trigger the download inside the browser, capture the browser-level download event, write the file into an isolated directory, and assert the file name, type, and contents. That works for CSV exports, PDFs, ZIP archives, and most generated reports. It also maps cleanly to CI, where there is no human to click the dialog anyway.

This article shows how I would structure that workflow in Playwright and Selenium, and where the failure modes usually hide.

The core distinction: browser download event, not OS save dialog

A browser download test is not the same as a UI test of the operating system’s file picker.

  • Browser download event: the browser decides a response should be downloaded, and the automation framework can observe or manage the file.
  • OS save dialog: the desktop shell asks a human where to save the file. This is not a stable test target for CI.

If your test depends on the native save dialog, you are testing the workstation, not your application.

For most export workflows, the right assertion is not just “a file appeared”. It is:

  1. the download was initiated from the expected UI action,
  2. the suggested filename is correct,
  3. the file landed in a worker-specific directory,
  4. the content matches the expected export format,
  5. the file is parseable by the consumer your product claims to support.

That last point matters. A CSV that exists but contains malformed quoting is still a broken export.

The flow I prefer is the same across frameworks:

  1. Set up a dedicated download directory per test worker or per test case.
  2. Trigger the export action.
  3. Wait for the download event.
  4. Save the artifact to the isolated path.
  5. Assert filename, MIME type or extension, and contents.
  6. Clean up the artifact after the test.

This keeps the test deterministic and makes retries safe. It also avoids false positives from stale files in a shared folder.

Decision table

Problem Better approach Why it helps
Native save dialog appears Configure browser downloads programmatically Removes OS-level interaction
Parallel tests overwrite files Per-worker download directory Avoids collisions and stale artifacts
Need to verify CSV export Parse the downloaded CSV Checks the actual artifact, not just the click path
Need to verify PDF export Inspect file existence and PDF metadata or text extraction Validates the generated document, not only the response code
ZIP or bundle export Unzip and assert inner files Catches archive structure issues

Playwright: capture the download event directly

Playwright has first-class support for downloads, which is one reason file-export tests are relatively straightforward there. The usual pattern is to create a browser context with a download path, trigger the action, and wait for the download event.

import { test, expect } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import { parse } from 'csv-parse/sync';
test('exports a CSV report', async ({ browser }, testInfo) => {
  const downloadDir = testInfo.outputPath('downloads');
  await fs.mkdir(downloadDir, { recursive: true });

  const context = await browser.newContext({ acceptDownloads: true });
  const page = await context.newPage();

  await page.goto('https://example.test/reports');

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

  expect(download.suggestedFilename()).toMatch(/report.*\.csv$/);

  const filePath = path.join(downloadDir, download.suggestedFilename());
  await download.saveAs(filePath);

  const raw = await fs.readFile(filePath, 'utf8');
  const records = parse(raw, { columns: true, skip_empty_lines: true });
  expect(records.length).toBeGreaterThan(0);

  await context.close();
});

A few details matter here:

  • acceptDownloads: true ensures the browser context is allowed to handle the file.
  • waitForEvent('download') should be attached before the click so you do not miss a fast response.
  • testInfo.outputPath() gives each test its own output area, which is safer than a global downloads folder.

If you are verifying a PDF download automation path, you can keep the same pattern and swap the content assertion. The file-level check may be enough if the application only guarantees delivery, but if the document is user-facing, extract text or inspect metadata with a PDF parser.

What usually breaks in Playwright

The failures are rarely about the click itself. They are usually about one of these:

  • the test waits for the wrong event, such as navigation instead of download,
  • the browser context is missing download permissions,
  • the filename is dynamic and the assertion is too strict,
  • the export endpoint returns HTML error content with a .csv filename,
  • the file is saved, but the test reads it before the write finishes.

That last point is one reason download.saveAs() is cleaner than trying to race the file system directly.

Selenium: use browser configuration, then verify the artifact outside the UI

Selenium does not expose a download event API in the same way Playwright does, so the implementation is more browser-specific. The usual approach is to configure the browser profile so downloads go to a known directory, then wait for the file to appear on disk.

The exact preferences vary by browser. For Chrome-based runs, the download directory can be set through browser options. The key is to isolate the folder per test worker and never point multiple tests at the same path.

from pathlib import Path
import time

from selenium import webdriver from selenium.webdriver.chrome.options import Options

worker_dir = Path(‘artifacts/downloads/test-1’) worker_dir.mkdir(parents=True, exist_ok=True)

options = Options() options.add_experimental_option(‘prefs’, { ‘download.default_directory’: str(worker_dir), ‘download.prompt_for_download’: False, ‘safebrowsing.enabled’: True, })

driver = webdriver.Chrome(options=options) try: driver.get(‘https://example.test/reports’) driver.find_element(‘css selector’, ‘button[aria-label=”Export CSV”]’).click()

target = worker_dir / 'report.csv'
deadline = time.time() + 20
while time.time() < deadline:
    if target.exists() and not target.name.endswith('.crdownload'):
        break
    time.sleep(0.2)

assert target.exists(), 'download never completed'
content = target.read_text(encoding='utf-8')
assert 'header_one' in content finally:
driver.quit()

Selenium users should be precise about what they are validating:

  • browser setup: downloads are routed to a known directory,
  • file completion: the temporary partial file is gone,
  • content validation: the export actually contains the expected data.

For CSV, parse the file instead of string-matching if the structure matters. For ZIP files, extract and assert the expected members. For PDFs, check file size plus a text-extraction or metadata pass if the test needs to prove document generation.

Selenium-specific failure modes

A few issues show up repeatedly:

  • browser permissions are not applied consistently across local and CI runs,
  • the temporary download extension stays on disk longer than expected,
  • the code checks for the file before the browser finishes writing it,
  • the test assumes a fixed filename when the application generates a timestamped one.

The fix is usually not a longer sleep. It is a better wait condition and a dedicated output path.

How to isolate download paths in CI

Browser download path isolation is what makes parallel runs safe.

I recommend one directory per worker, not one directory per suite. That lets retries and shards coexist without stepping on each other.

A good pattern in CI is:

  • use the test framework’s worker index or a unique job identifier,
  • create a fresh directory before each worker starts,
  • remove the directory after the run,
  • never reuse the same path between branches or reruns unless the folder is explicitly cleaned.

If you have multiple export tests in the same suite, keep their paths separate enough to make debugging easy. A failing CSV export should leave behind exactly one artifact in exactly one folder.

Shared download folders make failure analysis harder because a “passing” file may actually belong to a previous test.

What to assert beyond “file exists”

File existence is the weakest useful assertion. It tells you the browser produced something, but not whether it is correct.

I usually think in layers:

1. Filename

Check the suggested filename or the final saved filename. This catches broken naming logic, wrong extensions, and accidental filename sanitization.

2. Content type or format

When the framework exposes it, verify the expected format. If not, infer it by parsing. A CSV should parse as rows and columns. A ZIP should open. A PDF should not be zero bytes and should contain the expected document text or metadata.

3. Business content

Confirm a stable row, date, identifier, or report value. Do not overfit to the whole export if the file is large or intentionally dynamic.

4. Round-trip validity

If another system consumes the artifact, validate the artifact the same way that consumer would. That is the most honest end-to-end test.

When to unzip, parse, or inspect PDFs

The test strategy should match the artifact type.

  • CSV: parse with a CSV library, not line splitting.
  • JSON export: parse JSON and assert structure.
  • ZIP: extract and verify expected file names, counts, and key contents.
  • PDF: verify file creation, then extract text if the document’s content matters.

For PDF verification, the intent matters. If your product only promises a generated PDF, file presence may be enough. If your product promises a readable statement, invoice, or certificate, you need a deeper assertion than “download succeeded”.

Choosing the right level of strictness

Do not turn every download test into a byte-for-byte golden file comparison. That makes legitimate template changes expensive.

Use stricter assertions when:

  • the export has a fixed schema,
  • the document is compliance-sensitive,
  • a downstream integration consumes the exact format,
  • the file name is contractually important.

Use looser assertions when:

  • timestamps or generated IDs change every run,
  • the file contains large amounts of variable content,
  • the business risk is “wrong export shape” rather than “every byte must match”.

The point is to test the contract, not to freeze the implementation.

A practical checklist

Before merging a browser download test, I would check that it:

  • avoids OS dialogs,
  • uses a per-worker directory,
  • waits for the browser download event or a reliable completion signal,
  • asserts the filename or extension,
  • parses or inspects the file content,
  • cleans up artifacts after the run,
  • behaves the same locally and in CI.

If one of those is missing, the test is probably more fragile than it looks.

Final judgment

If you need to test browser downloads in Playwright and Selenium, the safest pattern is to stay inside the browser automation boundary and treat the downloaded file as the real assertion target. Playwright makes that workflow cleaner because it exposes the download event directly. Selenium can still do the job, but it depends more on browser configuration and file-system polling.

For a team maintaining CI-heavy UI tests, the best return comes from three habits: isolate the download path, verify the artifact contents, and stop depending on native dialogs. That combination removes most of the flakiness I see in export tests.

FAQ

Can I verify a download without saving the file?

Sometimes, but not reliably for end-to-end coverage. If the artifact matters to the user, saving and inspecting it is usually the stronger test.

What is the most stable way to assert a CSV download?

Save it to an isolated path, parse it as CSV, and assert a few stable columns or rows rather than raw text.

How do I handle dynamic filenames?

Assert the extension and known prefix or pattern, then use the framework’s suggested filename or the actual saved filename instead of hard-coding the full name.

Why do download tests fail more in CI than locally?

CI introduces parallelism, fresh browser instances, and no desktop interaction. Shared folders, timing assumptions, and OS dialogs tend to fail there first.

Is Selenium bad for download testing?

No. It is workable, but you need browser-specific configuration and a file-system based completion check. Playwright is more direct for this use case.

Should I compare exported files byte for byte?

Only when the file is intentionally stable and exact equality is part of the contract. For many exports, parsing and asserting key content is the better tradeoff.