CSV imports, profile photo uploads, PDF report downloads, image gallery drag-and-drop — every real product has at least one file flow, and they're notorious for sitting outside what most automation tools handle cleanly. Cypress fixed this in version 9.3 with the built-in cy.selectFile command. Combined with cy.readFile for downloads, the file flows you used to skip in test coverage become one-line tests.
Uploading a file with cy.selectFile
For a standard <input type="file">, the API is exactly what you'd hope:
cy.get("[data-testid='avatar-input']")
.selectFile("cypress/fixtures/profile.png");Cypress reads the file from disk, attaches it to the input as a File object, and dispatches the change event. React, Vue, and other framework inputs receive the file exactly as if a user picked it through the OS file dialog.
The fixture path is relative to the project root. Drop your test files into cypress/fixtures/ and reference them with the path you'd see in the file tree. If you store fixtures elsewhere, prefix the path with ./ or pass an absolute path.
Multiple files in one call
If the input has the multiple attribute, pass an array:
cy.get("[data-testid='attachments-input']").selectFile([
"cypress/fixtures/contract.pdf",
"cypress/fixtures/exhibit-a.pdf",
"cypress/fixtures/exhibit-b.pdf",
]);Each file becomes a File object on the input's files collection. Real apps treat this exactly the same as the user holding Shift and selecting three files in their OS file picker.
Drag-and-drop uploads
Modern apps almost never use bare file inputs. They use a drop zone — a <div> decorated with dragover/drop event handlers. cy.selectFile supports that with a single option:
cy.get("[data-testid='dropzone']").selectFile(
"cypress/fixtures/photo.jpg",
{ action: "drag-drop" },
);Cypress fires dragenter, dragover, and drop events on the target with a synthetic DataTransfer carrying the file. The dropzone receives the same payload it would from a real drag.
For drop zones that aren't form inputs, you usually still want to assert the upload triggered the right UI change — a preview thumbnail, a progress bar, or a server-rendered "Uploaded successfully" toast.
Verifying upload success
The interesting half of an upload test is what the app does after the file lands. Three patterns you'll write often:
// 1. The filename appears in the UI
cy.get("[data-testid='avatar-input']").selectFile("cypress/fixtures/profile.png");
cy.get("[data-testid='avatar-filename']").should("contain", "profile.png");
// 2. A preview image renders
cy.get("[data-testid='attachments-input']").selectFile("cypress/fixtures/photo.jpg");
cy.get("[data-testid='avatar-preview']")
.should("have.attr", "src")
.and("match", /^data:image\/jpeg/);
// 3. A success message appears after the upload completes
cy.intercept("POST", "/api/uploads").as("upload");
cy.get("[data-testid='dropzone']").selectFile(
"cypress/fixtures/contract.pdf",
{ action: "drag-drop" },
);
cy.wait("@upload").its("response.statusCode").should("eq", 201);
cy.get("[data-testid='upload-toast']").should("contain", "Uploaded");The third pattern combines cy.intercept (chapter 4) with cy.selectFile to wait for the actual server-side processing — exactly the kind of condition-based wait Cypress wants you to use instead of cy.wait(2000).
Downloads — cy.readFile is your friend
Cypress saves downloaded files to cypress/downloads/ by default. After clicking a download trigger, you assert against the file on disk with cy.readFile:
cy.get("[data-testid='download-report']").click();
cy.readFile("cypress/downloads/report.pdf").should("exist");cy.readFile retries automatically — it polls the file until it appears or the timeout fires. So you don't need a manual wait between the click and the assertion; the read is the wait.
For text-based files (CSV, JSON, TXT), cy.readFile returns the parsed contents and you can assert on what's inside:
cy.get("[data-testid='export-csv']").click();
cy.readFile("cypress/downloads/users.csv").then((content: string) => {
expect(content).to.contain("alice@test.com");
expect(content).to.contain("bob@test.com");
expect(content.split("\n")).to.have.length.greaterThan(2);
});For JSON, cy.readFile parses automatically:
cy.readFile("cypress/downloads/orders.json").then((data: { orders: unknown[] }) => {
expect(data.orders).to.have.length(12);
});For binary files (PDF, ZIP, XLSX), should("exist") and a check on file size are usually enough. To inspect contents, use a Node-side cy.task and a parser like pdf-parse or xlsx.
Configuring the downloads folder
If your CI shards artefacts by spec or you want downloads grouped by run, configure the folder:
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
downloadsFolder: "cypress/downloads", // default
},
});A real production setup often points the folder at a per-run subdirectory created in setupNodeEvents, so two parallel CI workers don't fight over the same files. For most local development, the default is fine.
Cleaning up between runs
Stale downloads from yesterday's failed test can poison today's run — cy.readFile("…/report.pdf") will pass before the new file even arrives. The fix is to clear the folder before each spec:
// In cypress/support/e2e.ts (runs before every spec)
beforeEach(() => {
cy.task("clearDownloads");
});
// In cypress.config.ts setupNodeEvents
import { rmSync, mkdirSync } from "node:fs";
export default defineConfig({
e2e: {
downloadsFolder: "cypress/downloads",
setupNodeEvents(on, _config) {
on("task", {
clearDownloads() {
rmSync("cypress/downloads", { recursive: true, force: true });
mkdirSync("cypress/downloads", { recursive: true });
return null;
},
});
},
},
});cy.task calls a Node-side function from the test (chapter 4 covers tasks in more depth). The function deletes the directory and recreates it — clean slate for every spec.
A complete CSV export test
Putting upload, intercept-wait, and download together — a typed end-to-end CSV export flow:
describe("User export", () => {
beforeEach(() => {
cy.task("clearDownloads");
cy.visit("/admin/users");
});
it("exports the user list as CSV", () => {
cy.intercept("GET", "/api/exports/users.csv").as("export");
cy.get("[data-testid='export-csv-btn']").click();
cy.wait("@export").its("response.statusCode").should("eq", 200);
cy.readFile("cypress/downloads/users.csv").then((content: string) => {
const lines = content.trim().split("\n");
expect(lines[0]).to.equal("id,email,role,createdAt");
expect(lines).to.have.length.greaterThan(1);
expect(content).to.match(/alice@test\.com/);
});
});
it("uploads a CSV to bulk-create users", () => {
cy.intercept("POST", "/api/imports/users").as("import");
cy.get("[data-testid='import-csv-input']")
.selectFile("cypress/fixtures/new-users.csv");
cy.wait("@import").its("response.statusCode").should("eq", 201);
cy.get("[data-testid='import-toast']")
.should("contain", "Imported 3 users");
});
});Three flows in one spec: download, contents-assertion, upload. Each step waits on a real signal (an aliased intercept or the file appearing on disk) — never a fixed sleep.
The upload flow visualised
Step 1 of 5
Pick the file
Test picks a fixture from cypress/fixtures/. Path is relative to the project root.
⚠️ Common mistakes
- Forgetting to clear
cypress/downloads/between tests. Yesterday'sreport.pdfis still there from a failed run, today'scy.readFile("…/report.pdf")passes before the click ever fired. Wire up aclearDownloadstask insetupNodeEventsand call it frombeforeEach— small one-time setup, big reliability win. - Using a third-party plugin (
cypress-file-upload,cy.upload) when you don't need to.cy.selectFilehas been built-in since Cypress 9.3 and covers basic uploads, drag-drop, and multi-file. The legacy plugins still exist in tutorials online, but installing them on a modern Cypress project just adds a dependency and a deprecation warning. Reach for the built-in command first. - Asserting on a binary download with
cy.readFile(path).then(...)to inspect contents. Cypress can read text and JSON; for binaries (PDF, ZIP, XLSX) the contents are the raw bytes and string assertions are meaningless. Either limit the assertion to.should("exist")plus a file-size check, or shell out to a Node-side parser throughcy.taskfor actual content checks.
🎯 Practice task
Wire up real upload and download tests against a public app. 25-30 minutes.
- Upload practice — visit
https://the-internet.herokuapp.com/upload. The page has a basic file input. SetbaseUrlaccordingly and createcypress/e2e/uploads.cy.ts. - Create a tiny fixture:
cypress/fixtures/sample.txtcontaining the textHello Cypress!. Add a test that:- Visits
/upload. - Uses
cy.get("input[type='file']").selectFile("cypress/fixtures/sample.txt")to pick the file. - Clicks the Upload button.
- Asserts the page shows "File Uploaded!" and the filename
sample.txt.
- Visits
- Add a second test that uses the drag-drop variant:
cy.get("#drag-drop-upload").selectFile("cypress/fixtures/sample.txt", { action: "drag-drop" }). Confirm the dropzone shows the filename. - Download practice — set
baseUrlto a small public CSV-export tool or use any internal app of yours that produces a file. If you don't have one handy, thehttps://the-internet.herokuapp.com/downloadpage lists downloadable files. Click one, thency.readFile("cypress/downloads/<filename>")to assert the file exists. - Wire up a
clearDownloadstask incypress.config.tsexactly as in the lesson, and call it from abeforeEachblock in your downloads spec. Run the spec twice — confirm the file still exists each time even though the folder was wiped at the start. - Stretch: find or create a CSV export endpoint, click the export, read the file with
cy.readFile, and assert the header row matchesid,email,role,createdAt(or whatever your app produces). Counting rows withcontent.split("\n").lengthis a useful sanity check for "the export contains the right number of records." This is the canonical CSV-export test every team eventually writes.
That closes chapter 3 — the DOM corners. The next chapter steps out of the DOM entirely and into the network: cy.intercept, request stubbing, alias-based waits, and the cy.fixture patterns that make tests deterministic without standing up real backends.