Blog / How to Test File Upload and Download Flows End-to-End
How-To

How to Test File Upload and Download Flows End-to-End

A script cannot set a file input's value, so every test framework starts past the picker. Here is how to test uploads, dropzones, and downloads anyway.

You cannot set a file input’s value from JavaScript. Try it in Chromium and the page throws an InvalidStateError, because a file input’s value “may only be programmatically set to the empty string.” Browsers block it on purpose. A page has no business attaching a file from your disk without you (HTML spec).

Every E2E framework ships its own upload API to get around that rule. It is almost like a service entrance that gets the file inside without anyone walking through the front door. It works. Your test also starts one step past the door, with the picker, the drop gesture, and the phone’s photo library left outside.

What you’ll learn

  • Why a script cannot set a file input’s value
  • The upload API in Selenium, Cypress, and Playwright
  • How to test dropzones, downloads, and negative paths
  • Where each upload API stops, and what to check past it

What an Upload Test Proves

Each upload API does something narrow. Playwright and Selenium hand the browser a file path through the automation protocol, and in Chromium, Playwright does it with the DevTools command DOM.setFileInputFiles. Cypress builds a File inside the page and attaches it through a DataTransfer, a route any page script can take with a file it built itself. None of them touches the picker.

Everything downstream of that write is real. The browser fires change and posts a multipart request with the file’s name and type in the part headers. Server validation and the rendered result both get exercised.

Everything upstream is not. Your test proves the app accepts a file. It does not prove a person can pick one. The path has six steps, and the upload API enters at step three:

  1. Control clicked: The user clicks the upload button or drags a file over a zone.
  2. Picker opened: The OS file dialog or a File System Access picker appears. No upload API operates it.
  3. File attached: The file lands on the input or arrives in a drop event. Every upload API enters here.
  4. Request sent: The browser posts the file as multipart/form-data.
  5. Server validated: The server checks size, type, and content, then stores the file.
  6. Result rendered: The UI shows the file, an error, or a progress state.

For a plain <input type="file"> the gap at step two is small enough to ignore, because the browser owns that dialog and the browser is not the thing you are shipping. For a drag-and-drop zone or a mobile photo picker, steps one to three are the feature.

Why Upload Tests Break in Four Predictable Ways

When an upload test goes red, the first useful question is which step went red. Was it the picker, the transport, or the server? Those are three different subsystems with three different owners, and the framework only hands you step three.

A form upload posts as multipart/form-data, defined in RFC 7578, so a passing “file attached” assertion tells you nothing about whether the encoded request arrived intact or whether the server took it. Splitting the failure that way before you start debugging saves the afternoon you would otherwise spend tightening a selector that was never the problem. Upload tests break in four predictable ways:

  1. Wrong entry point: The test attaches to a hidden input, but the app reads files from a drop event the input never fires.
  2. Fixture drift: The upload succeeds locally and times out in CI, where the fixture path or the network is different.
  3. Half a download test: The test stops at the click, so a corrupt or empty export ships unnoticed.
  4. A stale locator: A redesign replaces the upload control, and the locator for its hidden input stops matching.

The fourth is the upkeep every selector-based flow carries, and uploads are not exempt. When the markup around the control changes, the flaky failures that follow look like upload bugs. They are locator bugs, and the fix lands in the wrong subsystem. Most of this guide is ordinary end-to-end testing discipline applied to a flow that happens to sit behind a security rule.

How Each Framework Handles File Uploads

Selenium, Cypress, and Playwright, the three open-source end-to-end testing tools compared below, all attach the file to the input element and skip the OS picker. What differs is the API surface and how much of the flow each one reaches.

ScenarioSeleniumCypressPlaywright
Visible file inputsend_keys(path)selectFile()setInputFiles()
Hidden input behind a buttonsend_keys on the hidden inputselectFile() with force: truesetInputFiles(), or the filechooser event
Picker opened by showOpenFilePicker()No input to targetNo input to targetNo filechooser event fires
Drag-and-drop zoneHand-built drop event in execute_scriptselectFile() with action: ‘drag-drop’locator.drop(), from 1.60
Download verificationBiDi download_end event, Grid managed downloads, or a polled foldercy.readFile() on downloadsFolderwaitForEvent(‘download’)

A hidden input is one line in every column. The row worth reading twice is the showOpenFilePicker() one, because a picker opened through the File System Access API has no input element behind it. No upload API has anything to attach to.

TLDR on tool choice. If your upload is a bare input, all three are one line and the pick barely matters. It starts to matter at the dropzone and the download.

How to Test a Standard File Upload

For a normal <input type="file">, attach a fixture through the framework’s upload API and assert on what the app renders afterward. Keep fixtures small and committed to the repo so CI and local runs read identical bytes. That alone closes the second failure mode above before it costs you a night. Here is the same test in all three frameworks.

Playwright attaches files with setInputFiles, which the Playwright docs describe as setting the value of the file input to the given paths. Hidden inputs take it too, and no dialog opens at any point:

// Playwright: attach the fixture, then assert the UI accepted it
await page.getByLabel('Upload avatar').setInputFiles('tests/fixtures/avatar.png');
await expect(page.getByText('avatar.png')).toBeVisible();

Cypress ships this built in. The .selectFile() command landed in Cypress 9.3.0 in January 2022, and the release notes shipped a migration guide for teams coming off the third-party cypress-file-upload plugin (Cypress docs). For an input hidden behind a button, pass { force: true } as the second argument so Cypress skips its actionability checks:

cy.get('input[type=file]').selectFile('cypress/fixtures/avatar.png');
cy.contains('avatar.png').should('be.visible');

Selenium takes the oldest and simplest approach. You send the absolute file path as keystrokes to the input element, which the Selenium file upload guide documents as using the send keys method to send the full path to the file. A hidden input accepts it by default, because the W3C WebDriver spec leaves strictFileInteractability off unless a session turns it on:

upload = driver.find_element(By.CSS_SELECTOR, "input[type='file']")
upload.send_keys("/abs/path/to/avatar.png")

When the input stays hidden behind a button, Playwright models the handoff explicitly. Its filechooser event fires, in the docs’ own wording, when a file chooser is supposed to appear:

const [fileChooser] = await Promise.all([
  page.waitForEvent('filechooser'),
  page.getByRole('button', { name: 'Choose file' }).click(),
]);
await fileChooser.setFiles('tests/fixtures/report.pdf');

Read that wording again. The event fires when the dialog is supposed to appear, and setFiles then supplies what the dialog would have produced. Playwright answers on the picker’s behalf.

The handoff is specific to <input type="file">. Point that test at a button that calls showOpenFilePicker() and, on Playwright 1.56.1 driving its bundled headless Chromium 141, no event fires. The page’s call rejects with an AbortError that reads “Intercepted by Page.setInterceptFileChooserDialog()”, and Playwright closed the bug report for this as not planned (issue #8850).

Give the app an <input type="file"> fallback in test builds instead. In react-dropzone the default made this easy to hit. Releases from 12.0.0 (February 2022) until 14.2.9 (October 2024) set useFsAccessApi to true, so on Chromium over HTTPS or localhost a click went to showOpenFilePicker() and the AbortError read as a user cancel. Pass useFsAccessApi: false in test builds, which is the default from 14.2.9 on.

How to Test a Drag-and-Drop Dropzone

Dropzones are where the hidden-input trick stops working, because some of them have no input to target and read files from a drop event carrying a DataTransfer object. Cypress covers this with an action flag on selectFile(). Its docs describe the flag as mimicking a user dragging files from the operating system into the browser:

cy.get('[data-cy=dropzone]').selectFile('cypress/fixtures/avatar.png', { action: 'drag-drop' });
cy.contains('Upload complete').should('be.visible');

Playwright 1.60, published in May 2026, added locator.drop() for this case. It fires dragenter, dragover, and drop at the zone with a synthetic DataTransfer:

await page.locator('#dropzone').drop({ files: 'tests/fixtures/avatar.png' });
await expect(page.getByText('Upload complete')).toBeVisible();

Prefer it to the older workaround of building a DataTransfer yourself and dispatching drop. A hand-built drop runs the zone’s handler even when the zone would refuse a real drag. Give a zone a dragover listener that never calls preventDefault(), the bug that makes a user’s drop bounce off, and the dispatched event uploads the file anyway.

locator.drop() refuses that same zone and throws “Drop target did not accept the drop”. The test goes red where it should.

Selenium has no command for dropping a file from outside the page. With the hand-built event as the only route, the acceptance check is yours to add through execute_script. Dispatch a cancelable dragover at the zone first. If dispatchEvent returns false, the zone accepted the drag and you can dispatch the drop.

Register the File Once

Upload your fixture to Pie once. Every web run reads the same file.

How to Verify a File Download End-to-End

A download test earns its name when it opens the downloaded file and checks it. Stopping at the click shows a request fired and says nothing about the file on disk. Playwright makes the full loop straightforward by exposing a download event and handing you the saved file:

import fs from 'node:fs';

const [download] = await Promise.all([
  page.waitForEvent('download'),
  page.getByRole('button', { name: 'Export CSV' }).click(),
]);
await download.saveAs('tests/output/report.csv');
const rows = fs.readFileSync('tests/output/report.csv', 'utf8').trim().split('\n');
expect(rows[0]).toBe('order_id,sku,quantity');
expect(rows.length).toBeGreaterThan(1);

The two expect lines are the test. download.suggestedFilename() comes from the Content-Disposition header, the link’s download attribute, or the URL, never from the body. Serve an empty body under that header and the download still arrives as report.csv, saved as a 0-byte file.

The assertion that catches real bugs

A presence check only proves a file arrived. Read its size and contents too, or a zero-byte export slips through.

Selenium has three routes, and they differ in how much of the waiting you do yourself:

  • BiDi events: WebDriver BiDi defines browsingContext.downloadWillBegin and browsingContext.downloadEnd. Selenium’s Python bindings expose them as download_will_begin and, from 4.36 (October 2025), download_end, on a session started with enable_bidi.
  • Grid managed downloads: On a Grid node started with --enable-managed-downloads true, a session that sets se:downloadsEnabled can pull files back with get_downloadable_files() and download_file().
  • A fixed folder: Set the browser’s download directory before the session starts, trigger the download, then poll that directory until the file appears.

The Grid and folder routes both poll, because Selenium does not wait for downloads to finish and the file list is an immediate snapshot (Selenium docs). Polling is where downloads go flaky. Too short a timeout fails in CI. Too long and the suite drags.

Cypress does the waiting for you. Downloads land in downloadsFolder (cypress/downloads by default). Read the file there with cy.readFile(), which retries until the file exists or the command times out.

How to Test the Upload Paths Teams Skip

A suite that only uploads a valid file never exercises validation. Unrestricted file upload is a recognized vulnerability class in the OWASP catalog, whose entry opens by noting that uploaded files represent a significant risk to applications. These cases double as security tests. Cover four explicitly:

  • Oversized file: Upload a file past the documented limit and assert the app rejects it with a clear message, not a 500 or a silent hang.
  • Disguised type: Attach a .exe where an image is expected, a double extension like invoice.pdf.exe, and executable bytes saved as invoice.png. Chromium labels each multipart part by its file name, and the last file goes up declared as image/png. Only a server check on the bytes catches it.
  • Zero-byte and corrupt files: An empty file and a truncated one should fail validation gracefully, not crash a downstream parser.
  • Interrupted upload: Kill the connection mid-transfer and confirm the app recovers without leaving a half-written record.

Each one is negative testing in the plain sense, a crafted fixture plus an assertion about how the app fails. Write them once and they guard the server code a happy-path UI test never reaches.

How to Test a File Upload on iOS and Android

On a phone, the upload button hands off to the operating system, and neither platform draws that screen inside your app:

  • iOS: The photo picker Apple added in iOS 14 is PHPickerViewController, and Apple’s documentation says its view hierarchy belongs to the system.
  • Android: PickVisualMedia opens the system photo picker on Android 11 and later, and the Android docs say it falls back to the ACTION_OPEN_DOCUMENT document picker where the photo picker is unavailable. The same button can open two different screens.

The OS draws permission prompts outside your app too, as we covered in the guide to testing app permissions on iOS and Android. A web framework cannot reach either screen. Driving them takes Appium or XCUITest queries written per platform, against screens that change between OS versions, the kind of brittleness that makes mobile E2E tests flake. Two moves keep the suite stable:

  • Seed the media first: Appium’s pushFile adds the file to an iOS Simulator’s Photos library, doing what xcrun simctl addmedia does. On Android, the UiAutomator2 driver pushes the file and runs a media scan so the gallery lists it.
  • Write the picker steps per platform: Tap through each system picker with its own steps, and expect the Android path to change with OS version and Google System Updates.

How Pie Tests a Web Upload Flow

On a web app, Pie takes the upload as a plain-English step. You register the file once, the step reads upload the signed contract, and Pie supplies that file to the page’s file input. In Pie, an autonomous QA platform, you author the step without writing or maintaining a selector for the upload control. The file lives with Pie, so there is no fixture path to keep in sync between your laptop and CI.

After the upload, assert the state the screen settles on, such as the file listed by name or the rejection message for the oversized fixture from the section above. Write each one as an explicit check in the step, as you would in a script.

Exports need one more check. A success toast tells you the export ran, and what the file holds is another question. Keep a separate check on the bytes or on the export API, which Pie can run alongside the UI step as a configured shell command, such as a curl call to your export endpoint.

Where Your Upload Test Should Stop

Upload testing feels harder than it should because of a rule you did not write. A file input’s value belongs to the user, and every framework’s upload API enters one step past the picker.

Past that step, a dropzone test needs a drop the zone can refuse and a showOpenFilePicker() button needs an input to fall back to. On a phone, seed the photo library before you touch the picker.

Pie takes the web upload step and its file. The selector and the fixture path stop being yours to maintain. Keep the byte check on every export, because a test that ends at the filename will pass the empty file.

Upload Steps in Plain English

Write the upload as one sentence. Leave the selector to Pie.

Frequently Asked Questions

Browsers block it. A script may only set a file input's value to an empty string, and Chromium throws an InvalidStateError for anything else. The rule stops a page from attaching a file from your disk without you, which is why every framework ships its own upload API.

No. Selenium send_keys, Cypress selectFile and Playwright setInputFiles all attach the file to the input element and skip the operating system's picker.

Playwright's filechooser event intercepts the dialog for a file input. It does not fire when a page opens a picker with showOpenFilePicker.

In Cypress, call selectFile with the drag-drop action. In Playwright 1.60 or later, call locator.drop on the zone.

Avoid a hand-built drop event where you can. It runs the drop handler even on a zone that would refuse a real drag, while locator.drop fails the test.

Wait for the download, save it, then open it. In Playwright that means waitForEvent('download') and saveAs, followed by a read of the saved file.

Check the size and the contents. A zero-byte export arrives with the same suggested filename as a good one.

The negative paths. A file over the size limit, a disguised type, a zero-byte file, a double extension like invoice.pdf.exe, and an upload that fails halfway.

Check the bytes on the server. Chromium labels each upload by its file name, so the declared type proves nothing.

Seed the media first. Appium's pushFile adds a file to an iOS Simulator's Photos library and runs a media scan on Android.

Then drive each system picker with its own steps, because iOS and Android draw different picker screens outside your app.

On a web app you register the file with Pie and write the upload as a plain-English step. Pie supplies that file to the page's file input, so the step needs no selector or fixture path from you.

Exported files still need a separate check on their bytes.

Adithya Aggarwal
Adithya Aggarwal
CTO & Co-founder at Pie

Eight years building search and delivery systems at Amazon. The kind of scale where flaky tests block billion-dollar releases. Now CTO at Pie, building AI agents that adapt when your UI changes. LinkedIn →