File uploads are one of those features that look simple in the UI and then become annoying the moment you automate them. The visible control may be hidden behind a styled button, the app may validate MIME types on the client and server, and the upload flow may depend on a network request, a progress bar, or an external storage provider. If you are trying to test file uploads with Playwright, the good news is that Playwright handles the browser-side mechanics cleanly. The harder part is deciding what to assert, where to stub, and how much of the workflow you actually want to cover in one test.

This tutorial focuses on practical file upload testing with Playwright, not just the happy path. I will show how setInputFiles works, how to handle multiple files, how to verify validation failures, and where teams usually make upload tests flaky. I will also call out when a simpler low-code platform like Endtest, an agentic AI [Test automation](https://en.wikipedia.org/wiki/Test_automation) platform, can be a better fit for teams that want file upload coverage without owning custom Playwright code.

What Playwright is actually doing during a file upload

Playwright does not “click through the file picker” like a human would. That would be brittle and operating-system dependent. Instead, it sets files directly on the underlying <input type="file"> element. That is exactly what you want in automated tests, because the browser-side file picker is not part of your application logic.

The official Playwright docs describe file uploads through locator.setInputFiles(). Under the hood, Playwright attaches files to the file input and triggers the same application events that a user upload would trigger. That gives you a deterministic entry point for the test.

A good upload test should verify application behavior, not the browser picker UI. If you are trying to test the native OS file dialog, you are probably testing the wrong layer.

The typical upload flow has a few parts:

  1. The user selects one or more local files.
  2. The app validates file type, size, and count.
  3. The browser submits the file, often through multipart/form-data or a presigned upload request.
  4. The server stores the file and returns a reference or confirmation.
  5. The UI updates with success, failure, or preview state.

Playwright can cover all of that, but you should be deliberate about which layers are under test in a given scenario.

The simplest Playwright file upload test

The easiest case is a visible file input on the page. Create a small fixture file in your repository and upload it with setInputFiles.

import { test, expect } from '@playwright/test';
import path from 'path';
test('uploads a file', async ({ page }) => {
  await page.goto('https://your-app.example/upload');

const filePath = path.resolve(__dirname, ‘fixtures/sample.pdf’); await page.locator(‘input[type=”file”]’).setInputFiles(filePath);

await expect(page.getByText(‘sample.pdf’)).toBeVisible(); await expect(page.getByText(‘Upload complete’)).toBeVisible(); });

A few details matter here:

  • Use a stable test fixture file checked into the repo.
  • Resolve the path from the test file so CI and local runs behave the same.
  • Assert something observable after the upload, not just that the method returned.

If the page shows a filename preview or a success message, that is a reasonable first assertion. If the app calls an API and then updates the DOM, you may want to wait for the request as well.

Uploading files without depending on the UI file picker

Many apps hide the file input and wrap it with a custom button. That is fine. You do not need to click the visible button if the file input exists in the DOM.

typescript

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

If the input is hidden with CSS, Playwright can still target it as long as it is in the DOM and not disabled. This is one of the places where Playwright is more practical than end-to-end tests built around low-level mouse events.

If the app does not expose a real file input and instead uses drag and drop, you will need a different strategy. I will cover that below.

Testing multiple file uploads

For multi-file upload support, pass an array of files. Playwright will attach them to the file input in the same way a user would choose multiple files from the system dialog.

typescript

await page.locator('input[type="file"]').setInputFiles([
  'tests/fixtures/report-1.pdf',
  'tests/fixtures/report-2.pdf'
]);

await expect(page.getByText(‘2 files selected’)).toBeVisible();

This is useful for:

  • document batch uploads
  • profile galleries
  • attachment workflows
  • import tools that accept multiple CSV files

Make sure the input actually supports multiple selection. In HTML, that means the multiple attribute is present.

```html
<input type="file" multiple />

If the product only allows one file, your test should verify that a second selection is rejected, replaced, or blocked according to the requirement. Don't assume the UI will behave consistently across browsers if the product logic is unclear.

## Creating files dynamically in the test

Static fixtures are usually enough, but sometimes you need a file with a particular name or content. In that case, create one at runtime.

```typescript
import { test, expect } from '@playwright/test';
import fs from 'fs';
import os from 'os';
import path from 'path';
test('uploads a generated text file', async ({ page }) => {
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-'));
  const filePath = path.join(tempDir, 'notes.txt');
  fs.writeFileSync(filePath, 'hello from playwright');

await page.goto(‘https://your-app.example/upload’); await page.locator(‘input[type=”file”]’).setInputFiles(filePath);

await expect(page.getByText(‘notes.txt’)).toBeVisible(); });

This approach is useful when you want to test:

  • filename-specific validation
  • generated reports
  • Unicode file names
  • empty or malformed content

The tradeoff is maintenance. Generated files can make tests less readable if overused. I usually reserve them for edge cases that cannot be covered by a stable fixture.

Verifying validation rules

A lot of file upload testing is not about “does upload work,” it is about “does the app reject the wrong thing clearly.” That matters because poor validation leads to confusing user behavior and server-side failures.

Common validation checks include:

  • unsupported extension
  • wrong MIME type
  • file size over the limit
  • too many files
  • empty file
  • wrong image dimensions
  • password-protected or corrupted documents

Here is an example that checks a rejected file type:

import { test, expect } from '@playwright/test';
test('rejects unsupported file types', async ({ page }) => {
  await page.goto('https://your-app.example/upload');
  await page.locator('input[type="file"]').setInputFiles('tests/fixtures/script.exe');

await expect(page.getByText(‘Only PDF files are allowed’)).toBeVisible(); });

Do not stop at the UI message if the upload also hits a backend endpoint. A broken client-side validation rule can be masked by server rejection, and the test might still pass if you only assert one symptom. If the workflow matters, assert both the visible error and the fact that no upload completed.

A useful upload test proves that the wrong file does not get stored, not just that the page shows a red message.

Testing the upload request itself

For workflows that send a file to a backend API, it is often worth checking the network request. Playwright can wait for the upload call and inspect the response.

typescript

const [response] = await Promise.all([
  page.waitForResponse(res => res.url().includes('/api/uploads') && res.ok()),
  page.locator('input[type="file"]').setInputFiles('tests/fixtures/sample.pdf')
]);

expect(response.status()).toBe(200);

This is helpful when upload confirmation depends on the API response rather than on just the DOM state. It also gives you a clearer failure signal in CI. If the request returns 413, 415, or 500, you know the test failed in the backend path rather than in the UI.

The tradeoff is coupling. If your upload endpoint changes frequently, tests that assert the exact URL pattern can become noisy. Keep the assertion at the level of behavior, not implementation detail, unless the endpoint itself is part of the contract you want to protect.

How to test drag and drop uploads

Some apps support drag and drop instead of or in addition to a file picker. The implementation usually wraps a hidden input, but the UX depends on the drop zone.

Playwright does not have a built-in one-liner for native drag-and-drop file uploads across every custom implementation, but you can still test the UI in a few ways:

Option 1, use the hidden file input

If the drop zone is only a visual wrapper for a real <input type="file">, use setInputFiles. This is the most reliable path.

Option 2, dispatch a drag-and-drop event

For drop zones that read files from the drop event, you can create a data transfer payload and dispatch the event.

import { test, expect } from '@playwright/test';
import fs from 'fs';
test('uploads via drag and drop', async ({ page }) => {
  await page.goto('https://your-app.example/upload');

const filePath = ‘tests/fixtures/sample.pdf’; const buffer = fs.readFileSync(filePath);

await page.locator(‘[data-testid=”drop-zone”]’).dispatchEvent(‘drop’, { dataTransfer: { files: [ { name: ‘sample.pdf’, mimeType: ‘application/pdf’, buffer } ] } });

await expect(page.getByText(‘sample.pdf’)).toBeVisible(); });

The exact structure may vary depending on your app and Playwright version, so treat this as a pattern, not a universal recipe. In practice, the hidden-input route is easier to maintain.

Testing uploads in CI/CD

File upload tests can be stable in CI if you keep them deterministic. The common failures are usually not about Playwright itself, they are about environment differences.

Here are the problems I watch for:

  • fixture paths that work locally but not in CI
  • files checked into the wrong directory
  • backend storage credentials missing in CI
  • antivirus or security scanning delaying upload completion
  • browser throttling or slow network causing premature assertions
  • file size limits that differ between local dev and staging

A minimal GitHub Actions job looks like this:

name: playwright

on: push: pull_request:

jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npx playwright test

If upload behavior depends on a real backend, make sure the test environment has the right storage configuration. A missing bucket, bad credentials, or stale pre-signed URL will turn into noisy red builds that look like app failures but are really environment failures.

Making upload tests less flaky

Upload flows are a frequent source of flaky tests because they cross several boundaries at once: browser, filesystem, network, server, and storage. The best way to reduce that flakiness is to simplify what each test owns.

A few practical rules:

Keep fixture files small

Large files are slower and more likely to trigger resource or timeout issues. Use the smallest file that still exercises the relevant behavior.

Wait for the right signal

If the UI shows progress, wait for the final success state, not just the start of the upload. If the app emits a network request, wait for the response. If it writes to storage asynchronously, wait for the UI or API confirmation that the file is ready.

Avoid asserting on implementation-only details

A test that depends on internal progress bar percentages, generated DOM IDs, or exact request payload formatting is more likely to break on refactors.

Test the failure modes intentionally

Upload tests are more valuable when they cover errors, not just success. The product usually fails in the edges, not the happy path.

Don’t reuse one giant upload test for everything

A single test that uploads, validates, renames, previews, and deletes a file becomes hard to debug. Split high-value cases into focused tests so failures point to a specific contract.

What to assert after upload

The best assertion depends on the app. Common options include:

  • the filename appears in the UI
  • the preview image is rendered
  • the backend returns a success response
  • the uploaded file becomes downloadable
  • metadata such as size or type is shown
  • the record is linked to the right entity, like a profile or invoice

If the app stores a reference ID, it is often worth verifying that the file is attached to the right record after upload. That catches cases where the upload succeeds but the association fails.

For example:

typescript

await expect(page.getByTestId('uploaded-file-name')).toHaveText('sample.pdf');
await expect(page.getByTestId('upload-status')).toHaveText('Complete');

If the user can later download the file, that is an even stronger end-to-end check, because it confirms the upload path and retrieval path both work.

Common failure modes I would design for

When teams say upload tests are flaky, the root cause is usually one of these:

  • the file input is replaced after render, invalidating the locator
  • the app clears the file input before the UI assertion runs
  • the test assumes upload completion when only the request started
  • the test data file is not compatible with the validation rule
  • the upload completes, but the app needs extra polling before the file becomes visible
  • browser and server disagree on MIME type or extension validation

A good rule is to design one test for each meaningful failure mode. That gives you useful red/green signals instead of one vague “upload broken” result.

When a simpler platform is the better choice

If your team wants file upload coverage but does not want to own a Playwright framework, runners, browser setup, and CI glue, a managed platform can be a practical alternative. For example, Endtest supports file upload workflows in a low-code workflow, which can be easier for teams that want maintained, human-readable steps instead of custom framework code.

That does not make Playwright the wrong choice. It just means the ownership model matters. Playwright is a library, so your team owns the surrounding code, infrastructure, and maintenance. A platform can reduce that operational load, especially when upload scenarios need to be shared across QA, product, and support teams without requiring everyone to write TypeScript.

For teams evaluating the tradeoff, this Endtest comparison with Playwright is a reasonable starting point. My practical view is simple, if your organization is already equipped to maintain Playwright well, use it. If not, the hidden cost is usually not the upload test itself, it is the long tail of test ownership.

A short checklist for upload test design

Before you write or review a file upload test, ask these questions:

  • What exactly is the user trying to do, attach one file, attach many, or drag and drop?
  • What is the acceptance rule, size, type, count, dimensions, or content?
  • What should happen on failure, validation message, disabled submit, or server error?
  • What signal proves the upload finished, DOM text, API response, or a stored record?
  • Is the fixture file small, stable, and committed to the repo?
  • Will this test still be understandable six months from now?

If you cannot answer those clearly, the test is probably too broad or too coupled to implementation details.

Final take

To test file uploads with Playwright, start with the real control in the DOM, use setInputFiles for deterministic attachment, and assert the outcome that matters to the user and the backend. Keep fixture files small, cover negative cases intentionally, and wait for the right completion signal so CI does not turn a normal upload into a flaky test.

Playwright gives you a solid, low-level mechanism for file upload testing. The hard part is not the API, it is making sure the test reflects the actual contract of your product. If your team wants that coverage with less framework ownership, a managed approach like Endtest can be a simpler alternative for file upload workflows, especially when human-readable steps are easier to maintain than another pile of custom automation code.

For most teams, the right answer is not more automation, it is clearer ownership, narrower test scope, and a test that fails for the right reason.