How to Test File Uploads in Playwright, Cypress and Selenium

10 min readupdated July 24, 2026

Your test clicks the Upload button. A file picker opens, the one drawn by Windows or macOS rather than by the page. Your test sits there until it times out.

This is the moment people start searching for tools to automate the operating system, and that is a wrong turn worth stopping early.

Your test cannot control that dialog, and it does not need to. The dialog belongs to the operating system, outside the browser, where browser automation has no reach. Tools like AutoIt exist for it, and every one of them makes your suite platform-specific and fragile.

The fix is much simpler than the problem looks.

Do not click the button

Behind almost every upload control there is a plain HTML input:

<input type="file" name="report" />

You can set a file on that input directly. No dialog opens, because you never triggered the thing that opens it. The browser treats the file as chosen, the page's JavaScript fires as normal, and the upload proceeds exactly as it would for a user.

That is the whole technique. Everything below is the syntax and the details.

One thing that surprises people: the input is often invisible. Designers hide the default control because it cannot be styled, then put a pretty button on top. The input is still in the page, and it still works.

Playwright

await page.getByLabel('Upload report').setInputFiles('tests/fixtures/report.pdf');

Multiple files, when the input allows them:

await page.getByLabel('Attachments').setInputFiles([
  'tests/fixtures/a.pdf',
  'tests/fixtures/b.pdf',
]);

Clearing a selection, which is worth testing because Remove buttons often have bugs:

await page.getByLabel('Upload report').setInputFiles([]);

You can also upload without a file on disk, which is the neatest option for size and type checks:

await page.getByLabel('Upload report').setInputFiles({
  name: 'report.pdf',
  mimeType: 'application/pdf',
  buffer: Buffer.from('fake pdf content'),
});

If there is no input element at all, because the page opens the picker from JavaScript, catch the chooser event instead:

const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Upload' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('tests/fixtures/report.pdf');

Set up the promise before the click. If you await the click first, the event fires while nothing is listening and you wait forever.

Cypress

.selectFile() has been built in since Cypress 9.3, so ignore older articles telling you to install a plugin.

cy.get('input[type=file]').selectFile('cypress/fixtures/report.pdf');

Multiple files:

cy.get('input[type=file]').selectFile([
  'cypress/fixtures/a.pdf',
  'cypress/fixtures/b.pdf',
]);

For a hidden input, force is the documented and correct answer here:

cy.get('input[type=file]').selectFile('cypress/fixtures/report.pdf', { force: true });

This is worth a note, because force: true is normally something to avoid. Here it is legitimate. You are not hiding a broken interaction. You are setting a value on an input the design deliberately hides, and no user clicks it either. Leave a short comment saying so, since a reviewer scanning for forced clicks should be able to see the difference.

Drag and drop uploads, which a plain selectFile will not exercise:

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

And generated content, with no fixture file:

cy.get('input[type=file]').selectFile({
  contents: Cypress.Buffer.from('file contents'),
  fileName: 'report.txt',
  mimeType: 'text/plain',
});

Selenium

sendKeys on the input, with the file path:

WebElement input = driver.findElement(By.cssSelector("input[type='file']"));
input.sendKeys("/absolute/path/to/report.pdf");
const input = await driver.findElement(By.css("input[type='file']"));
await input.sendKeys('/absolute/path/to/report.pdf');

Two details cause most of the trouble.

Use an absolute path. A relative path is resolved against the browser's working directory, not your test's, and the failure message will not mention paths at all. Build it properly:

String path = Paths.get("src/test/resources/report.pdf").toAbsolutePath().toString();
input.sendKeys(path);

A hidden input throws. Selenium checks the element is interactable before it acts, so a styled upload button with a hidden input gives you ElementNotInteractableException. Make it visible for the moment you need it:

await driver.executeScript(
  "arguments[0].style.display = 'block'; arguments[0].style.visibility = 'visible';",
  input
);
await input.sendKeys(path);

This is one of the few places where reaching for JavaScript is reasonable, because you are working around a styling choice rather than around a real interaction problem.

The Grid detail almost everyone hits

Running against a remote Grid, the file lives on your machine and the browser runs on another one. The path you send means nothing there, and the error is confusing because the file plainly exists where you are looking.

Tell Selenium to ship the file across:

LocalFileDetector detector = new LocalFileDetector();
((RemoteWebElement) input).setFileDetector(detector);
input.sendKeys(path);

Skip this and uploads pass locally and fail in CI, which is the most annoying shape of failure there is. If your suite runs on Grid, set the file detector for every remote driver rather than per test.

What to actually assert

This is where most upload tests are weak. They select a file, check no error appeared, and pass. That proves the page did not crash.

An upload has stages, and each can break on its own:

// 1. the file was accepted by the form
await expect(page.getByText('report.pdf')).toBeVisible();

// 2. the upload finished, rather than merely started
await expect(page.getByText('Upload complete')).toBeVisible();

// 3. it survived, which is the part users care about
await page.reload();
await expect(page.getByRole('link', { name: 'report.pdf' })).toBeVisible();

That reload is the assertion that catches real bugs. Plenty of upload features show a success message from client-side state while the file never reached storage. Without the reload, your test agrees with the bug.

Where you can, check the server directly. A quick API call confirming the file exists with the right size beats any amount of reading the interface.

The failure paths matter more than the happy path

Uploads are unusual: users hit the error cases constantly, because people pick the wrong file all the time. These are worth more coverage than a successful upload:

  • A file that is too large. Does the message name the limit? "Upload failed" helps nobody.
  • A rejected file type. Is it blocked, and is the reason clear?
  • A file with an awkward name. Spaces, accents, a very long name, a name with % or # in it. This breaks more systems than anyone expects.
  • A zero-byte file. A surprisingly common crash.
  • Two uploads at once, if the interface allows it.

Buffer-based uploads make the size cases cheap, because you can generate exactly the size you need instead of committing a large file to the repository:

// a 12 MB file, against a 10 MB limit, with nothing stored in git
await page.getByLabel('Upload').setInputFiles({
  name: 'too-big.pdf',
  mimeType: 'application/pdf',
  buffer: Buffer.alloc(12 * 1024 * 1024),
});
await expect(page.getByText('Files must be under 10 MB')).toBeVisible();

Keep your fixtures small and boring

Commit small files, a few kilobytes each, and generate anything large at runtime. A repository carrying a 50 MB sample video makes every clone slower forever, and nobody remembers why it is there.

Give fixtures names that say what they test: too-big.pdf, wrong-type.exe, name with spaces.png. A year later that naming is the only documentation of what each case was for.

Remember this

Never automate the operating system dialog. Set the file on the <input type="file"> and the dialog never opens. Playwright uses setInputFiles, Cypress uses selectFile with force for hidden inputs, Selenium uses sendKeys with an absolute path and needs LocalFileDetector on Grid. Then assert past the success message, reload, and spend most of your effort on the rejection cases, because that is where users actually spend theirs.

Related: Selenium element not interactable and API testing for beginners, which is the fastest way to confirm the file really arrived.

References: Playwright file uploads and cy.selectFile.

Put it into practice

Solve a graded problem: write a test, and we check it would catch a real bug.

Browse the problems →

Keep reading