Introduction
File handling is one of the trickier parts of end-to-end testing. Whether your users upload documents, download reports, or interact with file pickers, your tests need to verify that workflow without touching the actual filesystem in fragile ways. Testing file uploads and downloads: a step-by-step guide walks through the patterns that work reliably across real browsers.
In this article, we'll cover four practical techniques: intercepting file input elements, handling custom file chooser buttons, capturing downloads, and organizing test fixtures. Each approach solves a different scenario you'll encounter in production apps.
Uploads — setInputFiles on file inputs
The simplest file interaction is a standard HTML file input. Playwright provides setInputFiles() to programmatically populate the input without the native file dialog.
Imagine a CSV uploader in your SaaS dashboard. Users click a file input, select a CSV, and the app parses it. Here's how to test that:
import { test, expect } from '@playwright/test';
test('upload CSV and verify parsing', async ({ page }) => {
await page.goto('/upload');
// Set the file input to a test CSV
await page.locator('input[type="file"]').setInputFiles('./fixtures/users.csv');
// Trigger upload (your app might do this automatically on change)
await page.locator('button:has-text("Upload")').click();
// Verify success feedback
await expect(page.locator('text=Upload successful')).toBeVisible();
});
This is fast and deterministic. Playwright handles the OS-level complexity; you just point to a file path and the browser populates the input as if the user had selected it through the dialog.
If your file input has accept="image/*" or other MIME constraints, setInputFiles() still works—Playwright bypasses the native dialog, so validation happens in your app code, not the OS.
For multiple files, pass an array:
await page.locator('input[type="file"][multiple]').setInputFiles([
'./fixtures/file1.pdf',
'./fixtures/file2.pdf',
]);
File choosers — waitForEvent('filechooser') for custom buttons
Not every file picker is a standard HTML input. Many modern apps use custom buttons that trigger native file dialogs. Think of a "Select file" button that's styled completely differently.
In these cases, you can't use setInputFiles() directly on a button. Instead, listen for the file chooser event and respond to it:
test('upload via custom file button', async ({ page }) => {
await page.goto('/media-library');
// Set up listener before clicking
const fileChooserPromise = page.waitForEvent('filechooser');
// Click the custom button that opens the dialog
await page.locator('button:has-text("Choose Image")').click();
// Wait for the dialog and respond immediately
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('./fixtures/photo.jpg');
// App processes the selected file
await expect(page.locator('img[src*="photo.jpg"]')).toBeVisible({ timeout: 5000 });
});
The key is waitForEvent('filechooser'). It returns a promise that resolves when the dialog opens. You then call setFiles() on the file chooser object to simulate the user's selection.
This pattern works with any button or UI element that triggers a native file dialog, regardless of how it's styled or implemented. Playwright intercepts the underlying Chromium/WebKit/Firefox file chooser event.
You can also cancel the dialog if your test needs to verify that scenario:
const fileChooser = await fileChooserPromise;
await fileChooser.cancel();
Downloads — waitForEvent('download') then save or read
Testing downloads requires a similar event-listener pattern. When your app initiates a download—say, exporting a report as PDF—you capture it and either save it locally or read it in memory.
Here's a real scenario: your analytics dashboard has a "Export as CSV" button. You need to verify the CSV contains the right data.
test('export report as CSV', async ({ page }) => {
await page.goto('/reports');
// Set up download listener before triggering download
const downloadPromise = page.waitForEvent('download');
// Click export button
await page.locator('button:has-text("Export CSV")').click();
// Wait for download to complete
const download = await downloadPromise;
// Save to a temporary location
const filePath = await download.path();
// Or use suggested filename
console.log('Downloaded:', download.suggestedFilename());
// Read and verify content
const fs = require('fs');
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('user@example.com');
expect(content).toContain('revenue: $1200');
});
Once the download completes, you get a download object with methods to inspect it:
path()— the temporary file path where Playwright saved itsuggestedFilename()— the filename the server sent (e.g., "report_2025_01.csv")saveAs(path)— move the file to a permanent locationdelete()— clean up the temporary file
If you want to read the file into memory without saving to disk:
const buffer = await download.path().then(path =>
require('fs').promises.readFile(path)
);
// Process buffer (for binary files like PDFs, use Buffer)
For multi-file downloads (e.g., ZIP archives), the same pattern applies—you get one download event per file. If your app bundles multiple files into one ZIP, you'll receive a single download event for the ZIP.
Fixtures — Keep test files small and in the repo
Good test practice means your test files belong in version control. Keep them small and representative, not production-sized.
Create a fixtures/ directory in your test root:
fixtures/
users.csv (100 bytes, 5 rows)
photo.jpg (minimal valid JPEG)
report.pdf (minimal valid PDF with test content)
large_dataset.xlsx (1 MB if testing performance)
Store minimal valid files. A CSV with 5 test rows is enough to verify parsing logic; you don't need 10,000 rows. This keeps the repo lean and tests fast.
Use relative paths from your test file:
// In playwright.config.ts, define testDir
export default defineConfig({
testDir: './tests',
});
// In tests/uploads.spec.ts, use relative path
await page.locator('input[type="file"]').setInputFiles('./fixtures/users.csv');
If a test file needs to be generated dynamically (e.g., a CSV with random data), generate it in the test setup:
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
test('upload generated CSV', async ({ page }) => {
// Generate a temporary CSV
const tempPath = './temp_users.csv';
const csv = 'id,email,role\n1,alice@test.com,admin\n2,bob@test.com,user';
fs.writeFileSync(tempPath, csv);
await page.goto('/upload');
await page.locator('input[type="file"]').setInputFiles(tempPath);
await page.locator('button:has-text("Upload")').click();
await expect(page.locator('text=Upload successful')).toBeVisible();
// Clean up
fs.unlinkSync(tempPath);
});
For large fixture files that shouldn't be in the repo, use a separate fixtures bucket or download them as part of CI setup. Most teams check small fixtures into git and ignore large ones.
Common mistakes
Not waiting before setInputFiles. Clicking a button and immediately calling setInputFiles() may race with the browser opening the dialog. Always await the button click first, or use waitForEvent('filechooser') if you need the event listener ready before the dialog opens.
Forgetting to await waitForEvent. Download and file chooser events are asynchronous. Set up the listener and then trigger the action, not the other way around:
// ✓ Correct
const downloadPromise = page.waitForEvent('download');
await page.locator('button').click();
const download = await downloadPromise;
// ✗ Wrong
await page.locator('button').click();
const download = await page.waitForEvent('download'); // May timeout or miss event
Not cleaning up temporary files. If you generate or download files in tests, clean them up in a teardown or use fs.promises.unlink() to avoid disk bloat during long test runs.
Assuming file paths are absolute. Always use relative paths from your project root or configure testDir in playwright.config.ts. Absolute paths break in CI.
Key takeaways
- Use
setInputFiles()for standard file input elements—it's the simplest and fastest approach. - Use
waitForEvent('filechooser')for custom buttons that trigger native file dialogs. - Use
waitForEvent('download')to capture downloads; choosepath()to read locally orsaveAs()to persist. - Store small, valid test fixtures in your repo under a
fixtures/directory and use relative paths. - Always set up event listeners before triggering the action to avoid race conditions.
- Clean up temporary files after tests to keep your CI environment tidy.






Comments
Loading comments…