File Upload Testing in Playwright: Single, Multiple, Drag-Drop
File inputs look trivial until your test suite hits one and the browser’s native OS file picker freezes the run. The good news: Playwright was built to bypass that dialog entirely. In this guide you’ll learn practical Playwright file upload testing for single files, multiple files, hidden inputs, the file chooser event, and even drag-and-drop dropzones—all in TypeScript, all runnable.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why file uploads are tricky to automate
A standard upload control is an <input type="file"> element. When a real user clicks it, the operating system opens a native file dialog that lives outside the browser’s DOM. Selenium historically struggled here, and so do tools that rely purely on pixel automation. Playwright sidesteps the problem: instead of clicking the button and wrestling with the OS dialog, you set the file directly on the input element using the Chrome DevTools Protocol. No native dialog ever appears.
That means the core API—locator.setInputFiles()—works headless, in CI, and across Chromium, Firefox, and WebKit without any extra OS configuration. The complications you’ll actually meet are: hidden inputs styled with custom buttons, sites that only expose a file-chooser via a JavaScript event, multi-file selectors, and modern drag-and-drop dropzones that never use a real input at all. We’ll cover each.
Single file upload with setInputFiles
The simplest case: locate the input and pass a path. Playwright resolves the path relative to the current working directory, so prefer an absolute path built from Node’s path module to keep the test stable across machines and CI runners.
import { test, expect } from '@playwright/test';
import path from 'node:path';
test('uploads a single file', async ({ page }) => {
await page.goto('https://the-internet.herokuapp.com/upload');
const filePath = path.join(__dirname, 'fixtures', 'invoice.pdf');
await page.locator('#file-upload').setInputFiles(filePath);
await page.getByRole('button', { name: 'Upload' }).click();
// The demo page echoes the uploaded file name back on success.
await expect(page.locator('#uploaded-files')).toHaveText('invoice.pdf');
});
Notice there is no click() on the input itself. Calling setInputFiles() populates the input and fires the input and change events the page’s JavaScript listens for. You only click the form’s submit button afterward. This is the canonical pattern for Playwright file upload testing and covers the majority of real-world forms.
Uploading multiple files at once
If the input carries the multiple attribute, pass an array of paths. Playwright assigns all of them in a single call, mirroring what a user would do by Ctrl/Cmd-selecting several files in the native dialog.
import { test, expect } from '@playwright/test';
import path from 'node:path';
test('uploads multiple files', async ({ page }) => {
await page.goto('https://example.com/gallery/upload');
const dir = path.join(__dirname, 'fixtures');
await page.locator('input[type="file"]').setInputFiles([
path.join(dir, 'photo-1.jpg'),
path.join(dir, 'photo-2.jpg'),
path.join(dir, 'photo-3.jpg'),
]);
// Assert the UI rendered a row per selected file.
await expect(page.getByTestId('file-row')).toHaveCount(3);
});
To clear a selection—useful when testing a “remove all” button or resetting between assertions—pass an empty array: await input.setInputFiles([]). This dispatches the same change event with zero files, which is exactly how a real reset behaves.
Uploading in-memory buffers (no fixture files)
Sometimes you don’t want to commit binary fixtures to the repo, or you need to generate a file dynamically—a CSV with a specific row count, or a corrupt payload to test validation. Instead of a path, pass an object with name, mimeType, and a buffer. Playwright streams it to the input as if it came from disk.
import { test, expect } from '@playwright/test';
test('uploads a generated CSV from a buffer', async ({ page }) => {
await page.goto('https://example.com/import');
const rows = ['id,name', '1,Ada', '2,Linus'].join('\n');
await page.locator('#csv-input').setInputFiles({
name: 'users.csv',
mimeType: 'text/csv',
buffer: Buffer.from(rows, 'utf-8'),
});
await page.getByRole('button', { name: 'Import' }).click();
await expect(page.getByText('2 users imported')).toBeVisible();
});
This is also the cleanest way to test edge cases: an empty file (Buffer.from('')), an oversized file, or a wrong MIME type to confirm your server-side validation rejects it.
Hidden inputs and the file chooser event
Many modern UIs hide the real <input type="file"> with CSS (display:none or zero opacity) and show a styled button instead. setInputFiles() still works on hidden inputs because Playwright sets files directly rather than clicking—visibility is irrelevant. If your locator can reach the input in the DOM, you’re fine.
But occasionally the input is created dynamically only after the button is clicked, or it lives in a place your locator can’t target. For those cases Playwright exposes the filechooser event. You set up a wait for the event, perform the click that triggers it, then call setFiles() on the returned chooser object.
import { test } from '@playwright/test';
import path from 'node:path';
test('handles a dynamic file chooser', async ({ page }) => {
await page.goto('https://example.com/avatar');
// Start waiting BEFORE the click that opens the chooser.
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Change avatar' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'fixtures', 'avatar.png'));
});
Order matters: register waitForEvent('filechooser') before the click, otherwise the event may fire before Playwright starts listening and your test will hang until timeout. The fileChooser.isMultiple() method tells you whether the underlying input accepts more than one file.
Drag-and-drop file uploads
Dropzones built with libraries like react-dropzone, Uppy, or FilePond almost always still render a hidden <input type="file"> underneath. The first thing to try is to ignore the drag UI entirely and call setInputFiles() on that hidden input. It is faster and far more reliable than simulating a drag.
import { test, expect } from '@playwright/test';
import path from 'node:path';
test('drops a file onto a react-dropzone area', async ({ page }) => {
await page.goto('https://example.com/dropzone');
// Most dropzones keep a real (hidden) input you can target directly.
await page
.locator('[data-testid="dropzone"] input[type="file"]')
.setInputFiles(path.join(__dirname, 'fixtures', 'report.xlsx'));
await expect(page.getByText('report.xlsx')).toBeVisible();
});
If the dropzone uses a custom drop handler that ignores the input and only reads event.dataTransfer, you must synthesize a real drop. Playwright can do this by creating a DataTransfer in the page context, attaching a file to it, and dispatching the drop event on the target element. The snippet below builds the DataTransfer via page.evaluateHandle and then dispatches the events.
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
test('simulates a real drag-and-drop drop event', async ({ page }) => {
await page.goto('https://example.com/custom-dropzone');
const buffer = fs.readFileSync(
path.join(__dirname, 'fixtures', 'report.xlsx'),
);
// Build a DataTransfer carrying the file inside the page.
const dataTransfer = await page.evaluateHandle(
({ data, name, type }) => {
const dt = new DataTransfer();
const file = new File([new Uint8Array(data)], name, { type });
dt.items.add(file);
return dt;
},
{
data: Array.from(buffer),
name: 'report.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
);
const dropzone = page.locator('[data-testid="dropzone"]');
await dropzone.dispatchEvent('dragenter', { dataTransfer });
await dropzone.dispatchEvent('drop', { dataTransfer });
await expect(page.getByText('report.xlsx')).toBeVisible();
});
This approach drives the actual drop handler with a genuine File object, so it works even when there is no underlying input. Reach for it only when the hidden-input shortcut isn’t available, since dispatching synthetic events is more brittle than setInputFiles().
Which approach should you use?
| Scenario | Recommended API | Notes |
|---|---|---|
Visible or hidden <input type="file"> | locator.setInputFiles(path) | Works on hidden inputs; no click needed |
| Multiple files | setInputFiles([p1, p2]) | Input must have multiple attribute |
| Generated / in-memory file | setInputFiles({ name, mimeType, buffer }) | No fixture files to commit |
| Input created only after a click | page.waitForEvent('filechooser') | Register the wait before the click |
| Dropzone with hidden input | setInputFiles() on the input | Try this first—fastest and most stable |
Pure custom drop handler | dispatchEvent('drop', { dataTransfer }) | Build DataTransfer with evaluateHandle |
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Verifying the upload actually happened
Setting files on an input is only half the test. A robust upload test asserts the outcome, not just the action. There are three layers worth checking:
- UI feedback — the filename, a thumbnail, or a success toast appears, e.g.
await expect(page.getByText('invoice.pdf')).toBeVisible(). - Network request — intercept the multipart POST with
page.waitForResponse()and assert a 200/201 status. - Input state — for client-side validation, read the selected file count back from the DOM with
locator.evaluate(el => el.files.length).
import { test, expect } from '@playwright/test';
import path from 'node:path';
test('asserts the upload network response', async ({ page }) => {
await page.goto('https://example.com/upload');
await page
.locator('#file-upload')
.setInputFiles(path.join(__dirname, 'fixtures', 'invoice.pdf'));
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/upload') && r.request().method() === 'POST',
),
page.getByRole('button', { name: 'Upload' }).click(),
]);
expect(response.status()).toBe(201);
});
Pairing the click with waitForResponse inside Promise.all avoids a race: the listener is armed before the click fires the request. This gives you a deterministic check that the server accepted the file, independent of how the UI renders the result.
Common pitfalls and fixes
- Relative paths break in CI. Always build paths with
path.join(__dirname, ...)rather than a bare'./file.pdf'string. - Clicking the input first. Don’t click an
<input type="file">beforesetInputFiles()—in headed mode it can open the OS dialog and stall the run. Just callsetInputFiles(). - Asserting too early. Use web-first assertions like
expect(...).toBeVisible()that auto-retry, instead of reading text once immediately after the click. - Wrong input when multiple exist. Scope your locator to the form or test id—
page.locator('input[type=file]')alone may match the wrong control on busy pages.
Conclusion
Solid Playwright file upload testing comes down to a small, dependable toolkit: setInputFiles() for single, multiple, hidden, and buffer-based inputs; the filechooser event for dynamically created inputs; and a synthesized DataTransfer drop only when a dropzone refuses to use a real input. Always pair the action with an assertion on the UI or the network response so the test proves the upload, not just the click. Master these patterns and the once-scary file input becomes one of the easiest things in your suite to automate reliably.
FAQ
Yes. setInputFiles() sets the file directly on the element through the DevTools protocol, so the input does not need to be visible or clickable. As long as your locator can match the <input type="file"> in the DOM—even if it’s styled with display:none—the upload works without forcing visibility.
How do I upload a file without committing a fixture to the repo?
Pass an object to setInputFiles() with name, mimeType, and a buffer created via Buffer.from(...). Playwright streams that in-memory buffer to the input exactly as if it were a real file on disk, which is ideal for generating CSVs or testing invalid payloads on the fly.
Can Playwright simulate true drag-and-drop file uploads?
Yes, though prefer targeting the dropzone’s hidden input with setInputFiles() first. If the dropzone only reads event.dataTransfer, build a DataTransfer with a File inside page.evaluateHandle, then fire dispatchEvent('drop', { dataTransfer }) on the target to drive the real drop handler.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
