Simulating File Uploads With the File API
Upload components combine several awkward pieces of the browser: a file input whose value cannot be set programmatically, a drag-and-drop zone driven by DataTransfer, client-side checks on type and size, previews built with FileReader or object URLs, and finally a multipart request to the server. Each is easy to leave untested, and together they tend to be tested only by someone dragging a real file onto a real page before a release. This guide covers constructing File objects in tests, uploading through user-event the way a user would, simulating drop events, testing validation and previews, and asserting on the exact request sent — all in Vitest with jsdom, plus a Playwright approach for the cases jsdom cannot represent. It sits under DOM and browser API mocking.
Root Cause Analysis
File inputs resist testing by design. Browsers forbid scripts from setting an input’s files, because that would let a page upload arbitrary local files without the user’s consent. Tests inherit that restriction, so the obvious approach — assign to input.value — does nothing, and teams conclude uploads cannot be tested without a browser.
In fact the File constructor is fully available, and user-event’s upload helper sets the input’s files property and dispatches the right events, exactly as a user’s selection would. What remains genuinely hard in jsdom is the edge: real image decoding, real drag feedback from the operating system, and anything relying on layout.
The upload logic itself — accept only images, reject files over five megabytes, show a preview, send the file with the right field name, handle a server rejection — is ordinary application code. It deserves ordinary tests, and it is where most upload bugs live: a size limit checked in megabytes on one side and mebibytes on the other, a MIME check that trusts the file extension, a preview URL that is never revoked.
Reproducible Setup
An avatar upload component with client-side validation, a preview, and a submit that posts multipart form data.
// src/components/AvatarUpload.tsx
const MAX_BYTES = 5 * 1024 * 1024;
const ACCEPT = ['image/png', 'image/jpeg', 'image/webp'];
export function AvatarUpload({ onUploaded }: { onUploaded: (url: string) => void }) {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const preview = useObjectUrl(file); // creates and revokes
function accept(f: File | undefined) {
if (!f) return;
if (!ACCEPT.includes(f.type)) return setError('Please choose a PNG, JPEG or WebP image.');
if (f.size > MAX_BYTES) return setError('Images must be 5 MB or smaller.');
setError(null); setFile(f);
}
async function submit() {
const body = new FormData();
body.append('avatar', file!);
const res = await fetch('/api/me/avatar', { method: 'POST', body });
if (!res.ok) return setError('Upload failed. Please try again.');
onUploaded((await res.json()).url);
}
return (
<div onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); accept(e.dataTransfer.files[0]); }} data-testid="dropzone">
<label>Profile picture<input type="file" accept={ACCEPT.join(',')} onChange={(e) => accept(e.target.files?.[0])} /></label>
{preview && <img src={preview} alt="Preview of your new profile picture" />}
{error && <p role="alert">{error}</p>}
<button disabled={!file} onClick={submit}>Save picture</button>
</div>
);
}
Implementation
Step 1 — Construct files with the size and type you need. A File needs content, a name and a type; its size is the content’s length, so a large file is a large buffer — cheap to create in memory.
// test/files.ts
export const aFile = (name: string, type: string, bytes = 1024) =>
new File([new Uint8Array(bytes)], name, { type });
export const aPng = (bytes?: number) => aFile('avatar.png', 'image/png', bytes);
Step 2 — Upload through the input the way a user would. user-event sets files and fires input and change, respecting the input’s accept attribute by default.
// src/components/AvatarUpload.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { aPng, aFile } from '../../test/files';
test('shows a preview and enables saving for a valid image', async () => {
const user = userEvent.setup();
render(<AvatarUpload onUploaded={() => {}} />);
await user.upload(screen.getByLabelText('Profile picture'), aPng());
expect(screen.getByRole('img', { name: /preview/i })).toHaveAttribute('src', expect.stringMatching(/^blob:/));
expect(screen.getByRole('button', { name: 'Save picture' })).toBeEnabled();
});
Step 3 — Test validation, including files the accept attribute would filter. To exercise the component’s own type check, tell user-event not to apply accept — otherwise it silently drops the file before your code sees it.
test.each([
['a PDF', aFile('cv.pdf', 'application/pdf'), 'Please choose a PNG, JPEG or WebP image.'],
['an oversized image', aPng(5 * 1024 * 1024 + 1), 'Images must be 5 MB or smaller.'],
])('rejects %s with a clear message', async (_label, file, message) => {
const user = userEvent.setup({ applyAccept: false });
render(<AvatarUpload onUploaded={() => {}} />);
await user.upload(screen.getByLabelText('Profile picture'), file);
expect(screen.getByRole('alert')).toHaveTextContent(message);
expect(screen.getByRole('button', { name: 'Save picture' })).toBeDisabled();
});
Step 4 — Simulate a drop. jsdom lacks a full DataTransfer, but a drop event only needs a dataTransfer object with a files list, which fireEvent accepts directly.
import { fireEvent } from '@testing-library/react';
test('accepts an image dropped onto the zone', () => {
render(<AvatarUpload onUploaded={() => {}} />);
fireEvent.drop(screen.getByTestId('dropzone'), { dataTransfer: { files: [aPng()], types: ['Files'] } });
expect(screen.getByRole('img', { name: /preview/i })).toBeInTheDocument();
});
Step 5 — Assert on the multipart request. MSW receives the real FormData, so the test can check the field name, file name and type the server will see.
import { http, HttpResponse } from 'msw';
import { server } from '../../test/msw/server';
test('posts the file under the avatar field and reports the new URL', async () => {
let received: File | undefined;
server.use(http.post('/api/me/avatar', async ({ request }) => {
received = (await request.formData()).get('avatar') as File;
return HttpResponse.json({ url: 'https://cdn.example.test/a.png' });
}));
const onUploaded = vi.fn();
const user = userEvent.setup();
render(<AvatarUpload onUploaded={onUploaded} />);
await user.upload(screen.getByLabelText('Profile picture'), aPng(2048));
await user.click(screen.getByRole('button', { name: 'Save picture' }));
await waitFor(() => expect(onUploaded).toHaveBeenCalledWith('https://cdn.example.test/a.png'));
expect(received).toMatchObject({ name: 'avatar.png', type: 'image/png', size: 2048 });
});
Step 6 — Check preview URLs are revoked. Object URLs hold memory until revoked; spy on the global functions and assert each created URL is released on unmount or replacement.
test('revokes the preview URL when the component unmounts', async () => {
const revoke = vi.spyOn(URL, 'revokeObjectURL');
const { unmount } = render(<AvatarUpload onUploaded={() => {}} />);
await userEvent.setup().upload(screen.getByLabelText('Profile picture'), aPng());
unmount();
expect(revoke).toHaveBeenCalledOnce();
});
A word on file contents: most tests do not care what bytes a file holds, only its name, type and size, and a zero-filled buffer is fine. When the component inspects content — sniffing the first bytes to confirm an image really is a PNG rather than trusting its declared type — build the buffer with the real magic bytes at the start. That single detail turns a type check that trusts the extension into one that is genuinely tested.
Verification
Confirm validation is exercised by the component rather than filtered by the helper: temporarily remove the component’s type check and run the rejection tests. With applyAccept: false they must fail — proof that the test reaches your code.
npx vitest run src/components/AvatarUpload.test.tsx --reporter=verbose
# ✓ shows a preview and enables saving for a valid image
# ✓ rejects a PDF with a clear message
# ✓ rejects an oversized image with a clear message
# ✓ accepts an image dropped onto the zone
# ✓ posts the file under the avatar field and reports the new URL
Then add one Playwright test for what jsdom cannot show — a real file chosen through setInputFiles and rendered as an actual image — so image decoding and layout are covered once.
Troubleshooting
Symptom: the upload does nothing and no change event fires. Diagnosis: user-event dropped the file because its type did not match accept. Fix: pass applyAccept: false when testing rejection of disallowed types, as in Step 3.
Symptom: URL.createObjectURL is not a function. Diagnosis: some jsdom versions do not implement it. Fix: stub URL.createObjectURL and URL.revokeObjectURL in setup with functions that return and accept blob: strings, which also makes the revocation spy in Step 6 straightforward.
Symptom: FileReader never calls onload. Diagnosis: the read is asynchronous and the test asserts too early. Fix: use findBy queries or waitFor for anything produced after a read, rather than asserting synchronously after the upload.
Symptom: the server receives an empty file. Diagnosis: the request is built from a stale state value, or a manual Content-Type header overrides the multipart boundary. Fix: let fetch set the header from the FormData, and assert on the received file’s size in the MSW handler.
FAQ
Can I test real image dimensions in jsdom?
No — jsdom does not decode images, so an Image element never reports natural width and height. Put dimension checks behind a small function you can stub in component tests, and cover real decoding once in a Playwright test with a genuine image fixture.
How do I test multiple-file selection?
Pass an array to user.upload; the input needs the multiple attribute or user-event will take only the first. Test the limit on count the same way as the size limit — at, under and over.
Should uploads go directly to object storage?
Often, via a pre-signed URL — in which case the test asserts that the component first requests the URL from your API and then sends the file to the returned address. MSW can intercept both requests, and the two-step flow is worth testing because failures in the second step are commonly mishandled.
How do I test upload progress?
fetch has no upload progress events; components that show progress use XMLHttpRequest. Put the upload behind a small function that reports progress through a callback, test the component with a fake that emits progress values, and verify the real implementation once in a browser.
Related
- Back to DOM & Browser API Mocking
- Testing form error recovery and retry — handling the server’s rejection gracefully.
- Asserting request payloads without brittle snapshots — more on inspecting what was sent.
- Mocking matchMedia for responsive component tests — another browser API jsdom leaves to you.