File upload and download testing with Playwright

setInputFiles, file choosers, in-memory files, download assertions and API uploads, with working examples.

Published by SampleTestFiles. About 4 minutes to read.

Playwright does not drive the operating system's file dialog. It sets files directly on the page's file input, which is faster and works the same way on every platform and in headless mode. This guide covers the patterns you need for upload and download tests.

Set up fixtures

Download a few small samples and commit them alongside your tests. Small files keep the suite fast; the 10 KB PDF, the 1 KB PNG and the 10 KB CSV are enough for most behaviour tests.

tests/
  fixtures/
    sample-pdf-10kb.pdf
    sample-png-1kb.png
    sample-csv-10kb.csv
  upload.spec.ts

Do not download fixtures during the test run. A suite that depends on a third-party site fails whenever the network does.

Upload a file

Locate the file input and call setInputFiles with a path.

import { test, expect } from '@playwright/test';
import path from 'node:path';

const fixture = (name: string) => path.join(__dirname, 'fixtures', name);

test('uploads a PDF', async ({ page }) => {
  await page.goto('/documents');
  await page.getByLabel('Choose a file').setInputFiles(fixture('sample-pdf-10kb.pdf'));
  await page.getByRole('button', { name: 'Upload' }).click();

  await expect(page.getByRole('listitem').filter({ hasText: 'sample-pdf-10kb.pdf' })).toBeVisible();
});

setInputFiles works on hidden inputs too, which is common when a styled button stands in for the real control.

For several files, pass an array. To clear a selection, pass an empty array.

await input.setInputFiles([fixture('sample-pdf-10kb.pdf'), fixture('sample-png-1kb.png')]);
await input.setInputFiles([]);

When there is no input to find

Some pages create the file input on the fly when a button is clicked. Wait for the file chooser event instead:

const chooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Browse' }).click();
const chooser = await chooserPromise;
await chooser.setFiles(fixture('sample-pdf-10kb.pdf'));

Start waiting before the click. Otherwise the event can fire before the test is listening.

Upload from memory

A file does not have to exist on disk. Pass a name, a MIME type and a buffer. This is the convenient way to produce large or unusual inputs on demand.

test('rejects files over 5 MB', async ({ page }) => {
  await page.goto('/documents');
  await page.getByLabel('Choose a file').setInputFiles({
    name: 'too-large.bin',
    mimeType: 'application/octet-stream',
    buffer: Buffer.alloc(5 * 1024 * 1024 + 1),
  });

  await expect(page.getByRole('alert')).toContainText('5 MB');
});

Two cautions. Buffer.alloc produces zeros, which is fine for a size check but is not a valid PDF or image; when the application inspects content, use a real sample file. And Playwright limits in-memory files to 50 MB, so pass a path for anything larger.

The same technique gives you edge cases without extra fixtures:

// A zero-byte file
await input.setInputFiles({ name: 'empty.txt', mimeType: 'text/plain', buffer: Buffer.alloc(0) });

// A file whose declared type does not match its name
await input.setInputFiles({ name: 'photo.png', mimeType: 'application/pdf', buffer: pdfBytes });

Drag and drop

Drop zones listen for a drop event carrying a DataTransfer object. Build one in the page and dispatch it:

import { readFile } from 'node:fs/promises';

const bytes = await readFile(fixture('sample-png-1kb.png'));
const dataTransfer = await page.evaluateHandle((data) => {
  const dt = new DataTransfer();
  dt.items.add(new File([new Uint8Array(data)], 'sample-png-1kb.png', { type: 'image/png' }));
  return dt;
}, [...bytes]);

await page.getByTestId('drop-zone').dispatchEvent('drop', { dataTransfer });

Most drop zones also contain a hidden file input. Where they do, setInputFiles on that input is simpler and exercises the same upload code.

Verify a download

Wait for the download event, then inspect the saved file.

import { stat } from 'node:fs/promises';

test('downloads the original file', async ({ page }) => {
  await page.goto('/documents');
  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('link', { name: 'Download sample-pdf-10kb.pdf' }).click();
  const download = await downloadPromise;

  expect(download.suggestedFilename()).toBe('sample-pdf-10kb.pdf');
  const saved = await download.path();
  expect((await stat(saved)).size).toBe(10240);
});

Asserting on the exact size catches truncation. To prove the content is unchanged, hash it and compare with the fixture:

import { createHash } from 'node:crypto';

const sha256 = async (file: string) => createHash('sha256').update(await readFile(file)).digest('hex');
expect(await sha256(saved)).toBe(await sha256(fixture('sample-pdf-10kb.pdf')));

Upload through the API

When the user interface is not what you are testing, post the file with Playwright's request context. It is much faster than driving the page and makes a good setup step.

import { readFile } from 'node:fs/promises';

test('API accepts a CSV import', async ({ request }) => {
  const response = await request.post('/api/imports', {
    multipart: {
      file: {
        name: 'sample-csv-10kb.csv',
        mimeType: 'text/csv',
        buffer: await readFile(fixture('sample-csv-10kb.csv')),
      },
    },
  });

  expect(response.status()).toBe(201);
});

A table-driven validation test

Type and size rules are easiest to cover with one parameterised test:

const cases = [
  { file: 'sample-pdf-10kb.pdf', accepted: true },
  { file: 'sample-png-1kb.png', accepted: false },
  { file: 'empty-file.txt', accepted: false },
];

for (const { file, accepted } of cases) {
  test(`${file} is ${accepted ? 'accepted' : 'rejected'}`, async ({ page }) => {
    await page.goto('/documents');
    await page.getByLabel('Choose a file').setInputFiles(fixture(file));
    await page.getByRole('button', { name: 'Upload' }).click();
    await expect(page.getByRole(accepted ? 'status' : 'alert')).toBeVisible();
  });
}

Add the zero-byte file and a file with a non-ASCII name to the list; they are the cases most often forgotten.

Common problems

  • "Non-multiple file input can only accept single file." The input lacks the multiple attribute and you passed an array of several files.
  • The upload starts before the test expects. Many pages upload as soon as a file is chosen. Wait for the network response with page.waitForResponse rather than clicking a button that does not exist.
  • Paths break in CI. Build paths from __dirname or the project root, never from the current working directory.
  • Downloads vanish. Downloaded files are deleted when the browser context closes. Copy the file with download.saveAs() if you need it afterwards.

Files used in this guide

Sample files referred to in this guide
FileFormatSizeContentsDownload
10 KB PDF samplesample-pdf-10kb.pdf PDF 10 KB10,240 bytes Pages: 2 Download PDF
1 KB PNG samplesample-png-1kb.png PNG 1 KB1,024 bytes 21 × 14 px Download PNG
10 KB CSV samplesample-csv-10kb.csv CSV 10 KB10,240 bytes Data rows: 78 Download CSV
One-pixel PNG (1 × 1)sample-pixel-1x1.png PNG 69 B69 bytes 1 × 1 px Download PNG
Empty file (0 bytes)empty-file.txt TXT 0 B0 bytes Lines: 0 Download TXT

Related guides