Skip to main content
Back to Blog

Automating File Upload Tests with Playwright, Cypress and Selenium

Written by Ankur Prajapati

Published 2026-01-10

LinkedIn profile
Advertisement

Upload flows are often left out of automated suites because the native file picker cannot be scripted. None of the major frameworks actually need the picker: they set files on the <input type="file"> element directly. This post shows how to do that in Playwright, Cypress and Selenium, the problems that come up in real apps (hidden inputs, custom drop zones, remote browsers), and how to assert on more than "no error appeared".

The fixtures

Every example below uses files of known type and exact size, so the assertions can be precise. For a 5 MB PDF limit we use three files: 5,242,879, 5,242,880 and 5,242,881 bytes. You can download them from the 5 MB PDF preset or build exact sizes with ?ext=pdf&bytes=.... Store them under fixtures/, or generate them in a setup step (see managing test fixtures).

Playwright

setInputFiles works on the input element even when it is visually hidden behind a styled button:

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

test('rejects a PDF one byte over 5 MiB', async ({ page }) => {
  await page.goto('/upload');

  const responsePromise = page.waitForResponse('**/api/upload');
  await page.locator('input[type=file]').setInputFiles(
    path.join(__dirname, 'fixtures', 'max-size-over-5242881.pdf')
  );
  await page.getByRole('button', { name: 'Upload' }).click();

  const res = await responsePromise;
  expect(res.status()).toBe(413);
  await expect(page.getByText('Maximum file size is 5 MB')).toBeVisible();
});

If the app opens the picker from a button and there is no reachable input, catch the chooser instead:

const chooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose file' }).click();
const chooser = await chooserPromise;
await chooser.setFiles('fixtures/max-size-exact-5242880.pdf');

For small in-memory files, pass a buffer and control the name and MIME type directly. This is handy for type-mismatch tests:

await page.locator('input[type=file]').setInputFiles({
  name: 'photo.jpg',
  mimeType: 'image/jpeg',
  buffer: Buffer.from('this is not a jpeg'),
});

To clear the selection, call setInputFiles([]).

Cypress

Cypress (9.3 and later) has selectFile. Paths are relative to the project root:

it('accepts a PDF of exactly 5 MiB', () => {
  cy.intercept('POST', '/api/upload').as('upload');
  cy.visit('/upload');

  cy.get('input[type=file]').selectFile('cypress/fixtures/max-size-exact-5242880.pdf', { force: true });
  cy.contains('button', 'Upload').click();

  cy.wait('@upload').its('response.statusCode').should('eq', 201);
});

{ force: true } is needed when the input is hidden with CSS. For drop zones that listen to drop events rather than the input, use { action: 'drag-drop' } on the drop zone element:

cy.get('[data-testid=dropzone]').selectFile('cypress/fixtures/sample.png', { action: 'drag-drop' });

A note on large files: Cypress reads the fixture into the browser, so very large files (hundreds of MB) make tests slow and memory-heavy. Keep automated boundary tests at the size the limit requires and run truly large uploads separately.

Selenium

Selenium uses send_keys with an absolute path on the file input. Do not click the "Browse" button; that opens an OS dialog WebDriver cannot control.

import os
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome()
driver.get("https://staging.example.test/upload")

path = os.path.abspath("fixtures/max-size-over-5242881.pdf")
driver.find_element(By.CSS_SELECTOR, "input[type=file]").send_keys(path)
driver.find_element(By.XPATH, "//button[text()='Upload']").click()

WebDriverWait(driver, 30).until(
    EC.visibility_of_element_located((By.XPATH, "//*[contains(., 'Maximum file size')]"))
)

Two issues come up often:

  • Hidden inputs. Some drivers raise ElementNotInteractableException for inputs with display: none. Make the input visible first: driver.execute_script("arguments[0].style.display='block';", el).
  • Remote browsers. On Selenium Grid or a cloud provider, the path refers to the machine running the browser, not your test runner. Set a local file detector so the client uploads the file to the node first: from selenium.webdriver.remote.file_detector import LocalFileDetector, then driver.file_detector = LocalFileDetector().

For multiple files, join absolute paths with a newline in a single send_keys call, provided the input has the multiple attribute.

Assert on what the server stored, not just the UI

A green "Uploaded" toast proves the client got a success response. It does not prove the file arrived intact. For accepted files, we add an API-level check after the UI test:

const res = await request.get('/api/files/' + fileId);
const body = await res.body();
expect(body.length).toBe(5242880);
expect(createHash('sha256').update(body).digest('hex')).toBe(expectedSha256);

(request is Playwright's API request fixture; createHash comes from node:crypto.) This catches pipelines that re-encode or truncate files while still returning 200.

Running these tests in CI

A few things that save debugging time once the suite runs on a build server:

  • Build large fixtures in global setup, not per test. In Playwright, a globalSetup file can write the size-edge files once into a temp directory; tests then read paths from an environment variable. Regenerating a 5 MB file in every test adds up quickly across browsers and retries.
  • Check fixture sizes before the tests start. A one-line assertion (fs.statSync(p).size === 5242881) catches the case where a checkout with Git line-ending conversion or an LFS pointer file replaced the real fixture. Without it, the "over the limit" test passes because a 130-byte pointer file was rejected for being an invalid PDF.
  • Raise timeouts only where needed. Upload tests against a remote staging server legitimately take longer than a click test. Set a longer timeout on the upload response wait, not on the whole suite, so slow tests elsewhere still fail fast.
  • Keep traces for failures. Playwright's trace viewer and Cypress's video both show the network request, which usually explains an upload failure faster than the assertion message does.

A minimal upload suite

  1. Valid file of a normal size: accepted, stored size matches.
  2. Exactly at the limit: accepted.
  3. One byte over: rejected with the application's error, not a proxy error page.
  4. 0-byte file: rejected with a clear message.
  5. Wrong extension for the content: behaves as your policy says.
  6. Cancel mid-upload (with a larger file and throttling): UI resets, nothing is stored.

Item 6 needs a throttled connection to be reliable; see testing upload progress bars with throttling. For manual exploratory checks, the Dummy File Generator Chrome extension can also place a generated file straight into the upload box of the page you are on, which saves the download-then-pick step.

The broader test plan is in the file upload testing guide.

Advertisement