PRACTICAL GUIDE / Playwright multiple file chooser testing
Prove that every selected file reaches the upload
Build Playwright checks that catch dropped, reordered, or cleared attachments across the file input, preview UI, request, and saved record reliably.
In this guide6 sections
What you will learn
- Follow the selection through every layer
- Prefer the input when you can locate it
- Catch a chooser that only exists after the click
- Separate selection bugs from upload bugs
Three attachments appear in the preview, yet the submitted case contains only the last one. The test is green because it checks the filename chips and never inspects what the server accepted. A useful upload test has to find the exact handoff where the other two files disappeared.
Follow the selection through every layer
A multi-file upload crosses more boundaries than its small UI suggests. The browser owns an HTMLInputElement whose files property is a FileList. Frontend code usually copies those File objects into component state, renders previews, performs validation, constructs a FormData request, and interprets a server response. The service may then scan, rename, reject, or persist each attachment. A pass at one boundary does not prove the next boundary worked.
That distinction matters because most weak tests stop at the first visible success. A filename chip proves that the application rendered a string. It does not prove that the same file remains in the input, that its bytes entered the request, or that the service associated it with the right record. The reverse can happen too. A browser input may still contain three files while a React state update retains only one. The page looks wrong even though the low-level selection succeeded. Treat those as separate observations, not competing explanations.
Playwright provides two practical routes into this flow. When the test can locate the file input, locator.setInputFiles() sets one or more paths or in-memory file payloads on that control. The official API accepts a single value or an array. Relative paths are resolved from the process working directory, and an empty array clears the selection. When the input is created dynamically and is not available before the user action, the page emits a filechooser event. A FileChooser from that event exposes setFiles(), isMultiple(), page(), and the associated element.
Neither route automates the operating system's file picker. Playwright supplies files to the input through browser automation. That is normally an advantage: the test is portable and does not depend on desktop coordinates or a particular window manager. It also defines a coverage limit. A check that calls setInputFiles() does not verify the native picker's layout, recent-files list, or platform-specific filtering. It verifies what the web application does once files have been selected.
The HTML multiple attribute controls whether the input accepts more than one file. fileChooser.isMultiple() is a good guard when a design requires multiple attachments, because a missing attribute changes the capability of the control. It is not the final assertion. The method can return true while the application discards all but one file during a later state update. Count and identify the actual FileList, then inspect a product-facing result after submission.
Names alone are weak identities. Two files can share a basename while containing different bytes. A service can normalize names, so an exact stored filename may not even be part of the contract. Pick evidence that matches the product rule. For a support-case form, that might be the client-side name, media type, size, and the attachment identifiers returned by the creation endpoint. For an import tool, the stronger result is the number of parsed rows and a validation error tied to the correct source file. For a document workflow, it may be the persisted document titles and checksums reported by an API designed to expose them.
Order deserves an explicit decision. Some products preserve the user's selection order because the first image becomes a cover. Other products treat attachments as an unordered set. Do not add an order assertion merely because an array is convenient. If order is contractual, use files with unmistakable identities and prove the order at the input, preview, and saved record. If order is irrelevant, compare sorted identities and avoid a flaky promise the product never made.
The same discipline applies to limits. A control that advertises five attachments needs cases for one file, the maximum allowed set, and one over the limit. Those are different product decisions. The over-limit case should assert which files remain selected, the exact user-facing rejection, and whether submission is blocked. A generic red banner is not enough if the bug under investigation silently removes the earliest file.
Prefer the input when you can locate it
Direct input access is the simplest and most readable choice. A label-based locator survives changes to generated IDs and lets the test express the same field name a user sees. The input may be visually hidden behind a styled button; that does not require a file-chooser listener if the locator can still identify the real control. Keep the selection and its first assertions together so a failure says whether Playwright could set the files or the application lost them afterward.
The following example checks four distinct facts. The browser input receives all three payloads. The preview renders their names. Submission returns a successful response from the expected endpoint. Finally, the service response identifies three accepted attachments in the promised order. A regression that changes the component from appending files to replacing state will fail before submission. A backend regression that stores only the final multipart part will fail at the response assertion.
import { Buffer } from 'node:buffer';
import { expect, test } from '@playwright/test';
test('submits every selected evidence file', async ({ page }) => {
await page.goto('/cases/new');
const files = [
{
name: 'browser-console.txt',
mimeType: 'text/plain',
buffer: Buffer.from('TypeError: total is undefined\n'),
},
{
name: 'request-timeline.json',
mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify({ requests: 7, failed: 1 })),
},
{
name: 'environment.csv',
mimeType: 'text/csv',
buffer: Buffer.from('key,value\nbrowser,chromium\n'),
},
];
const input = page.getByLabel('Evidence files');
await input.setInputFiles(files);
const selected = await input.evaluate((element) =>
Array.from((element as HTMLInputElement).files ?? [], (file) => ({
name: file.name,
size: file.size,
type: file.type,
})),
);
expect(selected).toEqual(
files.map((file) => ({
name: file.name,
size: file.buffer.byteLength,
type: file.mimeType,
})),
);
await expect(page.getByTestId('evidence-name')).toHaveText(
files.map((file) => file.name),
);
const responsePromise = page.waitForResponse(
(response) =>
response.url().endsWith('/api/cases') &&
response.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Create case' }).click();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as {
attachments: Array<{ originalName: string }>;
};
expect(body.attachments.map((item) => item.originalName)).toEqual(
files.map((file) => file.name),
);
});The response shape in that test is a product contract, not a universal Playwright convention. Use an endpoint your application actually exposes. If creation returns only a case ID, follow it with an authenticated API read and assert the stored attachments there. Do not parse opaque multipart bytes merely to say the request looked busy when a supported business API can report what the server accepted.
In-memory payloads make identities obvious and remove a common CI failure: a relative fixture path resolves from a different working directory. They also allow precise boundary content such as an empty file, a Unicode filename, or two equal names with different bytes. Their cost is memory. A test process holds each buffer, and the browser automation channel has to transfer it. Keep real large-file coverage in a small, deliberate group backed by disk fixtures rather than copying large buffers into every worker.
Disk fixtures need equally deliberate paths. Resolve them from the test module or from a known repository fixture directory instead of relying on the shell's current directory. The API deliberately resolves relative paths from that current directory, which may be the repository root locally and another directory inside a container. A missing fixture should fail during setup with its absolute path in the message. It should not be mistaken for a browser that cannot choose several files.
Avoid assertions that can never fail. If the test builds files and then compares files.length with a hard-coded three, it has proved its own fixture. Reading the input's FileList can fail when the product changes the control. Reading the service result can fail when request construction or persistence changes. Those are real oracles because a product regression can falsify them.
Clearing deserves a separate example in suites where users can revise their selection. Pass an empty array, assert that the FileList is empty, and assert that every preview and validation message is removed. Then choose a new file and submit it. This catches components that clear the native input but retain stale objects in application state. It also catches the opposite defect, where the chips disappear while old files remain queued for submission.
Catch a chooser that only exists after the click
Some upload buttons do not have a stable input in the DOM. The click handler creates an input, assigns attributes, calls its click() method, and removes it after selection. A locator written before the click has nothing to find. This is the case the filechooser event is designed for.
Register the wait before the triggering action. The event can fire during the click, so starting the wait afterward creates a race that is especially visible on fast CI machines. The correct sequence is a promise without await, the click, and then awaiting the promise. Playwright's official examples use this order for exactly that event-driven handoff.
import { Buffer } from 'node:buffer';
import { expect, test } from '@playwright/test';
test('adds several files through a dynamically created chooser', async ({ page }) => {
await page.goto('/incidents/INC-204/evidence');
const chooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Add evidence' }).click();
const chooser = await chooserPromise;
expect(chooser.page()).toBe(page);
expect(chooser.isMultiple()).toBe(true);
await chooser.setFiles([
{
name: 'network.txt',
mimeType: 'text/plain',
buffer: Buffer.from('GET /health 503\n'),
},
{
name: 'steps.txt',
mimeType: 'text/plain',
buffer: Buffer.from('1. Sign in\n2. Open dashboard\n3. Refresh\n'),
},
]);
await expect(page.getByTestId('pending-evidence')).toHaveCount(2);
await expect(page.getByTestId('pending-evidence')).toHaveText([
'network.txt',
'steps.txt',
]);
await expect(page.getByRole('button', { name: 'Upload 2 files' })).toBeEnabled();
});isMultiple() turns a subtle markup regression into a direct failure. Without that check, the eventual symptom might be one missing attachment, and the team could waste time in request parsing or storage. Still, the method only reports the chooser's capability. The count and filenames in the pending list prove that the application consumed the selected set. A later submission check is still required if persistence is part of the scenario.
The event also identifies its page. That matters when a test has a main page and a popup, or when an embedded workflow opens a second tab. A page-level listener naturally scopes the event to the page on which it was registered. chooser.page() is useful diagnostic evidence when a helper receives a chooser and needs to reject one from an unexpected tab.
Do not add a permanent page.on('filechooser') handler that automatically supplies the same fixtures to every chooser in a long test. That handler forks control flow. A later, unrelated chooser can consume the files, and an exception thrown inside an unawaited async listener may be reported far from the action that caused it. A targeted waitForEvent() keeps one trigger paired with one selection. If the application genuinely opens an unpredictable chooser, wrap the listener in a helper that records and awaits every job before the test ends.
An event timeout has several possible causes. The button might be disabled, covered, or no longer wired to an input. The application might now render a stable input, making the event path unnecessary. A JavaScript exception could occur before the input's click(). The test may also have registered too late. Increasing the timeout does not distinguish these causes. Inspect the click action, browser console, and DOM snapshot around the trigger.
One near-miss looks almost identical: the chooser arrives, but isMultiple() is false. That is not an event race. It means the associated input does not accept a multi-file set. Check whether a conditional render omitted the multiple attribute, whether the button opened the wrong input, or whether a mobile variant intentionally allows only one file. The event evidence is positive in this case, so retrying the wait only hides the actual markup or routing error.
Separate selection bugs from upload bugs
The fastest investigation records the state at each irreversible handoff. Immediately after setInputFiles() or setFiles(), read the live FileList. After frontend validation, inspect the rendered accepted and rejected lists. At submission, identify the exact request and its response. After persistence, query the product record. The first incorrect checkpoint owns the next debugging step.
Suppose the input contains three files and the preview contains one. Network work is irrelevant for the moment. Look for state setters called once per file that replace an array instead of extending it, asynchronous validators whose last completion wins, or deduplication keyed only by a non-unique basename. A trace's action snapshots can show the UI changing around the selection, but attach your own file manifest because a DOM snapshot is not a dependable record of file bytes.
Suppose both the input and preview contain three, but the creation response contains one attachment. Now inspect request construction and server behavior. Confirm that the form code appends every File under the field name the API expects. Check the actual response rather than assuming a successful status means all parts were processed. A service may accept the case while returning per-file validation results. That is a partial success and deserves explicit assertions.
A third shape occurs after a user changes another form field. The initial selection is correct, then a component rerender replaces the input node and the new node has an empty FileList. Preview chips may remain because they live in separate state. The following temporary diagnostic marks the original DOM node, captures names before and after the action, and attaches the evidence to the test report. It will fail if the input is replaced or cleared. Use it to locate the bug, then keep only the product-level regression assertions that matter long term.
import { Buffer } from 'node:buffer';
import { expect, test } from '@playwright/test';
test('diagnoses an attachment input replaced by a form rerender', async ({ page }, testInfo) => {
await page.goto('/claims/new');
const input = page.getByLabel('Supporting documents');
const marker = `upload-input-${testInfo.retry}`;
await input.evaluate((element, value) => {
(element as HTMLInputElement).dataset.qaInstance = value;
}, marker);
await input.setInputFiles([
{
name: 'receipt.txt',
mimeType: 'text/plain',
buffer: Buffer.from('order,total\nA-104,149.00\n'),
},
{
name: 'warranty.txt',
mimeType: 'text/plain',
buffer: Buffer.from('serial=SN-8821\nterm=24 months\n'),
},
]);
const readState = () =>
input.evaluate((element) => {
const control = element as HTMLInputElement;
return {
marker: control.dataset.qaInstance ?? null,
names: Array.from(control.files ?? [], (file) => file.name),
};
});
const before = await readState();
expect(before.names).toEqual(['receipt.txt', 'warranty.txt']);
await page.getByLabel('Claim category').selectOption('electronics');
const after = await readState();
await testInfo.attach('file-input-state', {
body: Buffer.from(JSON.stringify({ before, after }, null, 2)),
contentType: 'application/json',
});
expect(after.marker).toBe(marker);
expect(after.names).toEqual(before.names);
await expect(page.getByTestId('document-name')).toHaveText(before.names);
});The marker is intentionally diagnostic, not a production attribute contract. A framework migration may legitimately replace a DOM node while preserving selected files through another supported mechanism. If replacement is acceptable, assert retained user-visible state and the submitted attachments instead. The temporary marker tells the team what happened; it does not automatically decide whether that implementation is wrong.
Trace Viewer helps reconstruct timing. Its Actions panel shows the locator used, duration, source location, action log, and before and after DOM snapshots. The Network panel can be filtered to requests around an action. Use those features to answer concrete questions: did the category change happen after selection, did submission occur, and which request followed it? Do not infer file contents from a screenshot of three chips. Attach a small JSON manifest with expected names, sizes, and types, then compare it with state read from the page or service.
Another near-miss is server-side rejection that the UI summarizes poorly. The request can contain every file, while antivirus scanning, type validation, quota checks, or duplicate rules reject some. The correct assertion depends on the API response and stored record, not on the input. Preserve the response body when it is safe to do so, redact user data, and assert the per-file result your contract promises. Calling the incident a Playwright selection failure sends the fix to the wrong team.
Timeout signatures also differ. A timeout waiting for filechooser means no matching page event reached the wait. A completed chooser followed by a timeout on a preview assertion means selection occurred but expected UI state did not appear. A response wait that never resolves points to submission, request matching, navigation, or an earlier validation block. Keep those waits close to their triggers so the report identifies the broken boundary without a page-long call log.
Roll the change through an existing suite
Start with the tests that already upload more than one file. Inventory whether each uses a stable input locator, a chooser event, drag and drop, or a helper that hides the mechanism. Do not mechanically convert all cases to one API. Classify them by the control the application actually presents. A direct input is easier to reason about; a dynamic input needs the event path; a drop zone needs its own interaction coverage.
Replace shared fixtures with small, named payload factories where content matters. A factory can return valid JSON, a zero-byte text file, or duplicate names with different content. Keep large binary fixtures on disk. Record the purpose in the test name rather than producing file1, file2, and file3, which make a failure report unreadable. Distinct identities let a reviewer see whether the first, middle, or last file vanished.
Add assertions one boundary at a time. First read the FileList after selection. Next assert the accepted and rejected preview state. Then add one service-level result to the small set of end-to-end upload tests. Running every frontend case through storage makes the suite slow and couples UI checks to downstream availability. Running none of them through storage leaves a critical handoff untested. A balanced suite has many focused component or browser checks and a few complete journeys.
Keep diagnostics on failure without turning every run into an artifact dump. This configuration retains a trace only when a test fails, captures a failure screenshot, and writes a local HTML report. It does not make the assertions stronger; it makes the first failing attempt reviewable. Retries remain limited because a passing retry does not repair a lost attachment.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 45_000,
expect: { timeout: 5_000 },
retries: process.env.CI ? 1 : 0,
reporter: [
['line'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
],
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Cross-browser execution has a real cost. Each upload case transfers fixtures again and exercises another browser implementation. Run the core multiple-selection contract in all supported engines if browser behavior is part of your product support promise. Keep expensive maximum-size or malware-scanning scenarios in a narrower project unless the risk justifies multiplying them across every engine and worker.
Roll out strict assertions without masking existing defects. If a legacy test currently checks only one preview chip, add logging or attachments for all selected names and run it against the current product. When it exposes a real partial-upload bug, file that bug and decide whether the test should fail the release. Do not weaken the new assertion to match broken behavior. Likewise, do not mark an unstable case as fixed just because one retry found all files.
Parallel workers need isolated records and destinations. Give each test a unique case or claim rather than uploading into a shared account record that another worker edits. If disk fixtures are read-only, they can be shared. If a test generates or modifies a file, place it under that test's output directory. Cleanup should delete only records created by the current test identity. Broad cleanup can erase another worker's evidence and produce the same missing-attachment symptom as the product bug.
Review helper APIs during migration. A helper named uploadFiles() that accepts an array but silently uses the last element is worse than repeated explicit code. Make the helper return observed evidence such as selected names or the response, and keep the final business assertion in the test. Avoid helpers that register global event listeners or swallow a failed response to keep teardown moving.
Know when the chooser is the wrong test boundary
Do not use a chooser event merely because the feature uploads files. If a stable <input type="file"> exists, setInputFiles() is shorter, easier to scope, and easier to diagnose. Waiting for an event adds a race boundary without adding product coverage. The event path earns its complexity only when the triggering interaction creates or reveals a control that the test cannot address beforehand.
A drag-and-drop surface is another contract. Supplying files to a hidden input may prove later validation and upload logic, but it does not prove the drop zone accepts a user's drop. Test the drop interaction with the Playwright capability supported by the project's pinned version, then keep one downstream assertion. Do not claim drag coverage from a file-chooser test when the production handler listens to different browser events.
Native picker appearance and operating-system integration also sit outside this boundary. If the risk is an Electron permission, a managed desktop policy, or a mobile webview's document provider, a browser-level setFiles() check cannot answer it. Use the environment and tool that actually owns that integration. Keep the Playwright test for web application behavior after selection.
Avoid multi-file browser tests for pure server validation rules that can be proved through an API test. Filename normalization, quota accounting, and archive scanning often have many combinations. Driving a page for every combination adds browser latency and makes failures harder to localize. Cover the matrix close to the service, then retain browser journeys for the wiring and user feedback.
Do not assert input order if the product treats attachments as a set. Sorting names is not a compromise when order has no meaning; it is the correct contract. Conversely, do not sort away a failure when the first item becomes the cover image or processing priority. Ask the product owner which behavior users depend on and encode that answer once.
Skip enormous in-memory buffers when the goal is only to prove multiple selection. Small payloads expose array handling with less transfer and memory pressure. A separate large-file scenario can cover progress, cancellation, proxy limits, and server timeouts. Combining size stress with a multi-file state regression creates an expensive test whose first failure is ambiguous.
Finally, do not stop at isMultiple() for an end-to-end claim. That guard is valuable for the control's capability and nothing beyond it. When the release risk is lost evidence, the decisive assertion belongs on the accepted or persisted attachments. When the release risk is a dynamic button opening the wrong control, the chooser's page and multiplicity may be exactly the right boundary. The best test is the smallest one that can fail for the defect the team has agreed to prevent.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why does my Playwright test upload only one of several files?
First inspect the input after selection and confirm that its `files` collection contains every expected name. If that state is correct, move the check to the preview model, multipart request, and server response until the count first drops.
Should I use setInputFiles or wait for the filechooser event?
Choose `locator.setInputFiles()` when a stable locator can reach the file input or its associated label. Reserve `page.waitForEvent('filechooser')` for controls that create or reveal the input only as part of the click.
How can Playwright upload files created during a test?
Pass file payload objects containing `name`, `mimeType`, and a Node.js `Buffer`. This avoids temporary fixture paths, but large payloads consume test-process memory, so disk fixtures remain sensible for genuinely large files.
Does fileChooser.isMultiple prove that all files were uploaded?
No. The method reports whether the associated chooser accepts multiple files; it says nothing about the selected count, request body, validation result, or stored attachments.
Can a test clear a file input before choosing another set?
An empty array passed to `setInputFiles()` or `setFiles()` clears the current selection. Assert the product's cleared preview as well, because an empty browser selection and stale application state are different defects.
RELATED GUIDES
Continue the learning route
GUIDE 01
Test Reduced Motion with Playwright
Use Playwright reduced motion testing with media emulation to verify static alternatives, disabled animations, usable content, and regression checks in CI.
GUIDE 02
Test localStorage Directly with the Playwright API
Learn Playwright localStorage API testing through practical setup, failure analysis, CI evidence, security boundaries, and measurable release gates for QA and SDET teams.
GUIDE 03
Test sessionStorage Directly with the Playwright API
A practical guide to Playwright sessionStorage API testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 04
Test WebAuthn Passkey Registration with Playwright
Master Playwright WebAuthn passkey registration testing with implementation examples, failure analysis, evidence design, CI controls, and release-ready QA checklists.
GUIDE 05
Test WebSocket Subprotocol Negotiation with Playwright
Learn Playwright WebSocket subprotocol testing with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.