Playwright does not drive the operating system's file dialog. It sets files directly on the page's file input, which is faster and works the same way on every platform and in headless mode. This guide covers the patterns you need for upload and download tests.
Set up fixtures
Download a few small samples and commit them alongside your tests. Small files keep the suite fast; the 10 KB PDF, the 1 KB PNG and the 10 KB CSV are enough for most behaviour tests.
tests/
fixtures/
sample-pdf-10kb.pdf
sample-png-1kb.png
sample-csv-10kb.csv
upload.spec.tsDo not download fixtures during the test run. A suite that depends on a third-party site fails whenever the network does.
Upload a file
Locate the file input and call setInputFiles with a path.
import { test, expect } from '@playwright/test';
import path from 'node:path';
const fixture = (name: string) => path.join(__dirname, 'fixtures', name);
test('uploads a PDF', async ({ page }) => {
await page.goto('/documents');
await page.getByLabel('Choose a file').setInputFiles(fixture('sample-pdf-10kb.pdf'));
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole('listitem').filter({ hasText: 'sample-pdf-10kb.pdf' })).toBeVisible();
});setInputFiles works on hidden inputs too, which is common when a styled button stands in for the real control.
For several files, pass an array. To clear a selection, pass an empty array.
await input.setInputFiles([fixture('sample-pdf-10kb.pdf'), fixture('sample-png-1kb.png')]);
await input.setInputFiles([]);When there is no input to find
Some pages create the file input on the fly when a button is clicked. Wait for the file chooser event instead:
const chooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Browse' }).click();
const chooser = await chooserPromise;
await chooser.setFiles(fixture('sample-pdf-10kb.pdf'));Start waiting before the click. Otherwise the event can fire before the test is listening.
Upload from memory
A file does not have to exist on disk. Pass a name, a MIME type and a buffer. This is the convenient way to produce large or unusual inputs on demand.
test('rejects files over 5 MB', async ({ page }) => {
await page.goto('/documents');
await page.getByLabel('Choose a file').setInputFiles({
name: 'too-large.bin',
mimeType: 'application/octet-stream',
buffer: Buffer.alloc(5 * 1024 * 1024 + 1),
});
await expect(page.getByRole('alert')).toContainText('5 MB');
});Two cautions. Buffer.alloc produces zeros, which is fine for a size check but is not a valid PDF or image; when the application inspects content, use a real sample file. And Playwright limits in-memory files to 50 MB, so pass a path for anything larger.
The same technique gives you edge cases without extra fixtures:
// A zero-byte file
await input.setInputFiles({ name: 'empty.txt', mimeType: 'text/plain', buffer: Buffer.alloc(0) });
// A file whose declared type does not match its name
await input.setInputFiles({ name: 'photo.png', mimeType: 'application/pdf', buffer: pdfBytes });Drag and drop
Drop zones listen for a drop event carrying a DataTransfer object. Build one in the page and dispatch it:
import { readFile } from 'node:fs/promises';
const bytes = await readFile(fixture('sample-png-1kb.png'));
const dataTransfer = await page.evaluateHandle((data) => {
const dt = new DataTransfer();
dt.items.add(new File([new Uint8Array(data)], 'sample-png-1kb.png', { type: 'image/png' }));
return dt;
}, [...bytes]);
await page.getByTestId('drop-zone').dispatchEvent('drop', { dataTransfer });Most drop zones also contain a hidden file input. Where they do, setInputFiles on that input is simpler and exercises the same upload code.
Verify a download
Wait for the download event, then inspect the saved file.
import { stat } from 'node:fs/promises';
test('downloads the original file', async ({ page }) => {
await page.goto('/documents');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download sample-pdf-10kb.pdf' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('sample-pdf-10kb.pdf');
const saved = await download.path();
expect((await stat(saved)).size).toBe(10240);
});Asserting on the exact size catches truncation. To prove the content is unchanged, hash it and compare with the fixture:
import { createHash } from 'node:crypto';
const sha256 = async (file: string) => createHash('sha256').update(await readFile(file)).digest('hex');
expect(await sha256(saved)).toBe(await sha256(fixture('sample-pdf-10kb.pdf')));Upload through the API
When the user interface is not what you are testing, post the file with Playwright's request context. It is much faster than driving the page and makes a good setup step.
import { readFile } from 'node:fs/promises';
test('API accepts a CSV import', async ({ request }) => {
const response = await request.post('/api/imports', {
multipart: {
file: {
name: 'sample-csv-10kb.csv',
mimeType: 'text/csv',
buffer: await readFile(fixture('sample-csv-10kb.csv')),
},
},
});
expect(response.status()).toBe(201);
});A table-driven validation test
Type and size rules are easiest to cover with one parameterised test:
const cases = [
{ file: 'sample-pdf-10kb.pdf', accepted: true },
{ file: 'sample-png-1kb.png', accepted: false },
{ file: 'empty-file.txt', accepted: false },
];
for (const { file, accepted } of cases) {
test(`${file} is ${accepted ? 'accepted' : 'rejected'}`, async ({ page }) => {
await page.goto('/documents');
await page.getByLabel('Choose a file').setInputFiles(fixture(file));
await page.getByRole('button', { name: 'Upload' }).click();
await expect(page.getByRole(accepted ? 'status' : 'alert')).toBeVisible();
});
}Add the zero-byte file and a file with a non-ASCII name to the list; they are the cases most often forgotten.
Common problems
- "Non-multiple file input can only accept single file." The input lacks the
multipleattribute and you passed an array of several files. - The upload starts before the test expects. Many pages upload as soon as a file is chosen. Wait for the network response with
page.waitForResponserather than clicking a button that does not exist. - Paths break in CI. Build paths from
__dirnameor the project root, never from the current working directory. - Downloads vanish. Downloaded files are deleted when the browser context closes. Copy the file with
download.saveAs()if you need it afterwards.