Native file dialogs are a trap if your goal is stable browser automation. The browser opens them, the OS owns them, and most test flakiness comes from trying to drive a boundary that Playwright is specifically designed to bypass.

My default rule is simple: test the upload pipeline through the DOM, not through the operating system dialog. In Playwright, that usually means setting files directly on input[type=file], asserting the UI reacts correctly, and verifying the server-side effect through a stable observable such as the uploaded filename, preview state, or a follow-up API response.

Playwright supports this model directly, which is why it is a better fit for upload testing than frameworks that encourage dialog automation as the main path.

The core distinction: file chooser vs native dialog

A file chooser is the browser-level control exposed to page automation. A native dialog is the operating system window that appears after a user clicks a file input. They are not the same thing.

That distinction matters because Playwright can interact with file inputs and file choosers at the browser automation layer. It does not need to click through the OS picker. If you build tests around the native dialog, you inherit timing, focus, and desktop-environment failures that are unrelated to your app.

If a test depends on the OS picker, it is usually testing the machine more than the app.

The reliable default: set files on the input directly

If your app renders a real file input, this is the cleanest path. Playwright can attach a fixture file to the element without opening a dialog.

import { test, expect } from '@playwright/test';
import path from 'path';
test('uploads an avatar', async ({ page }) => {
  await page.goto('/profile');

  const filePath = path.join(__dirname, 'fixtures', 'avatar.png');
  await page.locator('input[type="file"]').setInputFiles(filePath);

  await expect(page.getByText('avatar.png')).toBeVisible();
});

Why this is the preferred route:

  • It avoids the native dialog completely.
  • It works in headless and headed runs.
  • It is less sensitive to OS theme, window focus, and browser UI behavior.
  • It maps closely to what the browser does after a user selects a file.

For most product tests, this is enough. If you only need confidence that the user can upload a supported file type, this is the shortest reliable path.

Hidden file inputs are not a problem

Many upload components hide the real file input and layer a custom button on top. That is fine, as long as the input still exists in the DOM.

You do not need the input to be visible to test it. You need it to be reachable.

await page.locator('button', { hasText: 'Upload file' }).click();
await page.locator('input[type="file"]').setInputFiles('tests/fixtures/report.pdf');

If the input is truly hidden, Playwright can still target it with a locator. The important part is that the component remains accessible to automation. If the app removes the input from the DOM until some custom widget opens, then the test should target the component’s actual state transition, not a browser dialog.

When hidden inputs become a testing smell

A hidden input is not automatically bad. It becomes a problem when the upload component is so abstracted that you cannot make assertions about what happened.

Look for these issues:

  • The input is recreated on every render, which discards state.
  • The component accepts files but does not expose filename, count, or validation state.
  • The UI depends on a transient toast instead of durable state.

If you cannot assert the result, the test will drift toward implementation details or flake on timing.

How to handle file chooser events when the UI really needs them

Sometimes the app genuinely emits a filechooser event, for example when a button triggers the browser’s picker through scripted interaction. Playwright still gives you a stable path, but you should treat it as a browser event, not an OS automation problem.

const [fileChooser] = await Promise.all([
  page.waitForEvent('filechooser'),
  page.getByRole('button', { name: 'Choose file' }).click(),
]);

await fileChooser.setFiles(‘tests/fixtures/invoice.csv’);

Use this when the page logic requires the chooser event to open or validate UI flow. Do not use it because you want to click through the desktop picker. That adds no coverage and increases failure modes.

Fixture files should be small, explicit, and versioned

Upload tests are only stable if the files themselves are deterministic.

I recommend keeping fixtures under version control and making them intentionally small:

  • A tiny valid PNG or PDF for the happy path
  • A clearly invalid file type for validation tests
  • A boundary-size file only when the application enforces size limits
  • A file with a tricky name, such as spaces or unicode, if your app handles filenames

The file should support the assertion. If the test says it uploads a PNG, the fixture should be a real PNG, not a renamed text file.

That sounds obvious, but it is a useful guardrail for CI-only upload failures. A renamed file can pass local UI checks and fail once backend content validation, antivirus scanning, or MIME sniffing is involved.

Keep the assertion tied to observable behavior

Prefer assertions that prove the upload changed app state:

  • Filename appears in the UI
  • Preview renders
  • Submit button becomes enabled
  • Validation message appears for bad files
  • Server returns a stored file reference

Avoid asserting only that the click happened or that the input value changed. Those are intermediate states, not business outcomes.

Drag and drop uploads need a different test shape

If your product supports drag and drop uploads, test the drop zone separately from the file input.

A drag and drop component often has two behaviors:

  1. The page accepts dropped files.
  2. The page still supports fallback input selection.

Those are related but not identical. Do not assume one proves the other.

Playwright can simulate drag-and-drop behavior for many UI patterns, but the exact implementation depends on how the app listens for events. If your dropzone uses browser DataTransfer objects, make the test explicit about that data path.

import { test, expect } from '@playwright/test';
test('drops a file into the upload zone', async ({ page }) => {
  await page.goto('/uploads');

  const fileName = 'avatar.png';
  await page.locator('[data-testid="dropzone"]').setInputFiles(`tests/fixtures/${fileName}`);

  await expect(page.getByText(fileName)).toBeVisible();
});

That example only works if the component accepts an input-backed file path on that element. If it does not, use the app’s actual drag-drop event flow. The point is not to force one technique everywhere. The point is to cover the user path that the component genuinely implements.

A compact decision table for upload tests

Upload pattern Best Playwright approach Main risk What to assert
Real <input type="file"> setInputFiles() Weak UI assertions Filename, preview, enabled submit
Button opens browser chooser waitForEvent('filechooser') then setFiles() Overusing chooser path Same as above
Hidden input behind custom UI Target hidden input directly Component may be inaccessible Component state after selection
Drag and drop area Simulate the drop flow the component uses False confidence from input-only test Drop success state and validation
CI-only upload failures Use fixture files and server-side checks Environment-specific backend validation API result, stored asset, error handling

Debugging CI-only upload failures

When uploads pass locally and fail in CI, I look for the environment, not the locator.

Typical causes include:

  • File path differences between local and CI runners
  • Relative paths that depend on the working directory
  • Unsupported fixture formats on Linux containers
  • Backend limits such as file size, MIME type, or antivirus scanning
  • Missing permissions on temporary directories or storage mounts
  • Timeouts caused by upload completion being slower in CI

The fastest way to isolate the problem is to separate UI selection from backend confirmation.

await page.locator('input[type="file"]').setInputFiles('tests/fixtures/avatar.png');
await expect(page.getByText('avatar.png')).toBeVisible();

// Optional, if the app exposes an API or visible confirmation state

await expect(page.getByText('Upload complete')).toBeVisible({ timeout: 10_000 });

If the UI reflects the selected file but the upload never completes, you likely have a server, network, or environment problem. If the file never appears in the UI, focus on the frontend component and locator.

What I would not test with Playwright alone

Some upload behavior belongs outside browser automation:

  • Virus scanning or content inspection logic
  • Object storage permissions
  • Signed URL generation
  • Retry behavior for transient backend errors
  • File processing jobs that complete asynchronously after the browser closes

Those are better covered with API tests, integration tests, or job-level checks. Browser automation should verify the user-visible interaction, not replace every downstream concern.

That separation keeps your browser suite smaller and easier to debug.

A practical upload testing strategy that scales

For a team maintaining upload-heavy product flows, I would organize coverage like this:

  1. One happy-path browser test per upload surface Use setInputFiles() or the component’s actual chooser flow.

  2. One validation test per important file rule Wrong type, too large, or missing required metadata.

  3. One drag-and-drop test where the feature matters Do not duplicate it for every form.

  4. One backend assertion for persistence Verify the server stored the file or returned the expected reference.

  5. A small fixture set checked into the repo Keep it stable and readable.

This gives you coverage without turning uploads into a maintenance tax.

A note on tool choice

If your app mainly needs browser-level upload validation, Playwright is a strong fit because it exposes direct control over file selection without depending on the native dialog. That reduces flakiness and makes CI behavior easier to reason about.

That said, Playwright is not a substitute for everything around uploads. If your main pain is downstream file processing, object storage, or asynchronous backend workflows, a browser test is only one layer. The test should match the failure mode you care about.

Bottom line

To test browser file uploads in Playwright without native dialog flakiness, skip the OS picker and drive the file input or file chooser directly. Use real fixture files, assert visible state changes, and split UI coverage from backend persistence checks.

If your test needs to prove that users can select a file, Playwright can do that reliably without touching the desktop dialog. If your test depends on the dialog itself, you are probably testing the wrong layer.

FAQ

Can I upload files in Playwright when the input is hidden?

Yes. If the input exists in the DOM, Playwright can usually target it directly with setInputFiles(). The better question is whether the component exposes enough observable state to assert the upload succeeded.

Should I test drag and drop uploads separately from file input uploads?

Yes, if your app supports both paths. They are related but not identical flows, and each can fail independently.

Why do file upload tests fail only in CI?

Common causes include path resolution, fixture format issues, slower network or backend processing, size limits, and asynchronous completion timing.

Is filechooser better than setInputFiles()?

Not usually. Use setInputFiles() for the simplest reliable coverage. Use filechooser only when the page flow requires that event.

What should I assert after selecting a file?

Assert a user-visible outcome, such as filename display, preview rendering, validation feedback, or upload completion. Avoid relying only on the input value.