Article · Network & API Mocking for Reliable Tests

Mocking File Uploads in Playwright

A file-upload flow has two halves that flake independently: attaching the file to the input, and the multipart POST that carries it to the server. Depending on a real upload endpoint means depending on its latency, storage, and virus-scan hooks — none of them deterministic. This page expands Playwright Route Mocking Strategies within Network & API Mocking for Reliable Tests, showing how to set the file with setInputFiles and stub the upload response so both halves are controlled.

13 sections URL: /network-api-mocking-for-reliable-tests/playwright-route-mocking-strategies/mocking-file-uploads-in-playwright/
Attach the file, stub the upload setInputFiles attaches an in-memory buffer; page.route fulfills the multipart POST with a fixed success response. setInputFilesin-memory buffer multipart POSTpage.route intercept fulfill 200{ url: '/f/1' }
Attach an in-memory buffer, then fulfill the multipart POST with a fixed response.

Root cause #

A test that uploads a real file and waits for the server to store it inherits every source of backend variance: upload bandwidth, temp-storage latency, and any post-processing like thumbnailing or scanning. On a fast local run the response comes back in milliseconds; on CI, or against a shared staging backend, it lands late and an assertion on the “uploaded” state fires early. The attach step is also fragile if it points at a real file on disk that may not exist in the CI checkout.

Two independently flaky halves The attach step and the multipart upload each introduce nondeterminism unless controlled. real file on diskmay be missing in CI real upload endpointlatency + processing
Control both halves: an in-memory buffer for the attach, a stubbed response for the POST.

The fix is to control both halves. setInputFiles accepts an in-memory buffer, so no real file needs to exist, and page.route fulfils the upload POST with a fixed success (or error) response so the timing and outcome are deterministic — the same interception discipline as how to mock REST APIs in Playwright.

Step-by-step fix #

1. Attach an in-memory file and stub the upload #

Route before attach before submit Register the upload route, attach the in-memory file, then submit — so the stubbed response is deterministic. 1 route upload 2 setInputFiles 3 submit + assert
Register the route before submitting so the multipart POST never escapes the mock.
// tests/upload.spec.ts — in-memory file + stubbed upload response
import { test, expect } from '@playwright/test';

test('shows success after upload', async ({ page }) => {
  await page.route('**/api/upload', async (route) => {
    await route.fulfill({ status: 200, contentType: 'application/json',
      body: JSON.stringify({ url: '/files/1', name: 'report.csv' }) }); // deterministic, no real storage
  });
  await page.goto('/upload');
  await page.getByLabel('Attach file').setInputFiles({
    name: 'report.csv', mimeType: 'text/csv', buffer: Buffer.from('a,b\n1,2'), // no file on disk needed
  });
  await page.getByRole('button', { name: 'Upload' }).click();
  await expect(page.getByText('report.csv')).toBeVisible();
});

2. Assert the request carried the file #

When the payload matters, inspect the intercepted request before fulfilling.

await page.route('**/api/upload', async (route) => {
  const body = route.request().postData() ?? '';
  expect(body).toContain('report.csv'); // the multipart body includes the filename
  await route.fulfill({ status: 200, body: '{"ok":true}', contentType: 'application/json' });
});

3. Cover the failure path deterministically #

// Force a rejected upload to test the error UI without a real backend.
await page.route('**/api/upload', (route) =>
  route.fulfill({ status: 413, body: '{"error":"file too large"}', contentType: 'application/json' }));

Pitfalls #

File-upload anti-patterns and fixes Real disk files, unstubbed endpoint, late routing, and elapsed-time waits each map to a fix. point at a real disk file in-memory buffer hit the real endpoint fulfill the multipart POST route after submit route before the click wait a fixed time assert the success state
Control both the file source and the upload response to remove backend variance.
  • Pointing at a real disk file that may be absent in CI — use an in-memory buffer.
  • Hitting the real upload endpoint — fulfill the multipart POST.
  • Routing after submit — the request escapes; route before the click.
  • Fixed-time waits — assert the visible success state instead.

Reliability targets #

File-upload scorecard Targets for upload flake, real-backend calls, missing-file failures, and CI pass rate. < 0.5%upload flake 0real-backend calls 0missing-file fails ≥ 99.5%CI pass
An in-memory buffer plus a stubbed response removes both flaky halves at once.
Metric Target How to hit it
Upload-test flake rate < 0.5% Stub the multipart POST
Real-backend upload calls 0 page.route before submit
Missing-file failures 0 In-memory buffer, no disk file
CI pass rate ≥ 99.5% Assert the success state, not a delay

Frequently Asked Questions #

Do I need a real file on disk to test uploads? No — pass { name, mimeType, buffer } to setInputFiles so the file exists only in memory and the CI checkout needs nothing extra.

How do I assert the file actually reached the request? Read route.request().postData() inside the handler; the multipart body includes the filename and content you can assert on before fulfilling.

Can I test upload progress bars? For arrival-time progress, pair the stub with page.clock to advance deterministically, since a single fulfilled response has no real transfer time.

Validation Happens in Two Places #

An upload is validated on the client and again on the server, and tests routinely cover one and assume the other. The two produce different user experiences and both belong in a suite.

Client-side validation rejects before any bytes leave the browser: an extension the input does not accept, a size above a configured limit, a count above a maximum. These are fast to test because no network is involved, and they are the checks most likely to be quietly broken by a refactor, since nothing fails loudly when a validation rule stops being applied.

Server-side validation rejects after the transfer: a mismatched content type, a virus-scan failure, a size limit the client did not enforce, a quota exceeded. These need route control to produce reliably, and they are where the user-facing message matters most — a generic failure after a two-minute upload is a support ticket, while a specific one is a retry.

The case worth testing explicitly is the disagreement: a file the client accepts and the server rejects. That gap is common, because the two limits are configured in different places and drift apart, and the resulting experience — an upload that appears to work and then fails — is far worse than an immediate rejection.

// Server rejects what the client allowed: assert the message is actionable.
// Trade-off: one more fixture per rejection reason, and it covers the case where
// two independently configured limits have drifted apart.
await page.route('**/api/uploads', (route) =>
  route.fulfill({ status: 413, json: { error: 'file_too_large', maxBytes: 5_242_880 } }));

await expect(page.getByRole('alert')).toContainText(/5 MB/);
await expect(page.getByLabel('Attachment')).toHaveValue('');   // selection cleared or retained deliberately

Uploads Have Two Halves, and Tests Usually Cover One #

A file upload is a browser interaction followed by a network exchange, and most tests verify only the first — the file is selected, the interface acknowledges it, and the assertion stops there. The half that breaks in production is the second: what the interface does while the transfer is in flight, and what it does when the transfer fails partway.

Three states deserve explicit coverage. In progress, where a progress indicator should advance and the submit control should be disabled to prevent a duplicate upload. Failed mid-transfer, where the interface must offer a retry without losing the user’s file selection. And rejected by the server — too large, wrong type, virus-scan failure — where the message must be specific enough to act on rather than a generic failure.

Each of these is straightforward with route control and nearly impossible to produce reliably against a real backend. Aborting the upload request models a dropped connection; fulfilling with a 413 models a size rejection; delaying the response makes the in-progress state observable long enough to assert on.

// Model a failed transfer, then a successful retry, deterministically.
// Trade-off: the attempt counter is state, so it lives inside the test and
// never at module scope where it would leak into the next one.
let attempt = 0;
await page.route('**/api/uploads', async (route) => {
  attempt += 1;
  if (attempt === 1) return route.abort('connectionfailed');
  await route.fulfill({ status: 201, json: { id: 'file_1', name: 'invoice.pdf' } });
});

Choosing the File the Test Supplies #

The input side has its own decisions, and the default — pointing at a fixture on disk — is not always the best one.

A real file from disk is the most faithful and the least flexible: its size and content are fixed, and testing a size limit means committing a large binary that inflates the repository for everyone. A buffer created in the test avoids that entirely, since the file’s name, type and size are constructed inline, which makes a size-limit test a single line rather than a checked-in artefact.

Content type deserves particular attention because it is a common source of environment-dependent behaviour: a file selected by the operating system carries a type derived from its extension and the platform’s mapping, which differs between a developer’s machine and a minimal container. Constructing the file in the test removes that variation, and where the real mapping is the subject, one deliberate test with a real file covers it.

The final consideration is the multiple-file and drag-and-drop paths, which frequently take entirely different code in the application and are covered far less often than the file-picker path. Both are reachable from a test, and a suite that only ever exercises the picker is verifying one of the three ways users actually add files.

Cleaning Up What an Upload Leaves Behind #

Upload tests create artefacts on both sides: temporary files the test constructed, and records the application stored. Neither disappears on its own, and both accumulate across a long run.

Constructing files in memory rather than on disk removes half the problem entirely. For the other half, uploads should be namespaced per worker like any other written record, so two workers uploading simultaneously cannot collide on a filename the server derives from the original.

Covering the drag-and-drop and multiple-file paths matters because they frequently take entirely different code from the file picker, and a suite that only exercises the picker is verifying one of the three ways users actually add files.

Constructing files in memory keeps the repository small and the size limits easy to vary.