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.
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 #
// 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 #
- 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 #
| 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.