05 · File Upload & Download¶
Uploading via set_input_files¶
Most upload widgets are, underneath any custom styling, an
<input type="file">. Playwright can set its value directly — no need to
interact with the OS-native file picker dialog at all, which is the right
approach: OS dialogs aren't part of the DOM and can't be automated
reliably or portably across CI environments.
from playwright.sync_api import Page
def test_upload_resume(page: Page):
page.goto("https://example.com/apply")
page.locator("input[type='file']").set_input_files("fixtures/resume.pdf")
page.get_by_role("button", name="Submit application").click()
from playwright.sync_api import expect
expect(page.get_by_text("resume.pdf uploaded")).to_be_visible()
set_input_files works even if the input is visually hidden by CSS
(display: none or opacity: 0, a common technique so a styled button
triggers a native input under the hood) — Playwright can set files on it
directly without needing it to be visible, since no real click on the OS
dialog is involved.
Multiple files, and clearing a selection¶
page.locator("input[type='file']").set_input_files(
["fixtures/photo1.jpg", "fixtures/photo2.jpg"]
)
# no output for either — a list of paths uploads multiple files
# at once (for inputs with the `multiple` attribute); an empty
# list clears whatever was previously selected
Uploading a file created in-memory¶
Sometimes there's no file on disk to reference — you want to test a CSV upload with content generated by the test itself.
page.locator("input[type='file']").set_input_files(
files=[{
"name": "data.csv",
"mimeType": "text/csv",
"buffer": b"name,age\nAda,30\nGrace,85\n",
}]
)
# no output — Playwright writes this in-memory buffer as the
# uploaded file's content, with no temp file ever touching disk
Triggering upload via a file chooser event¶
Some UIs open the file input only after a button click dispatches a
programmatic .click() on a hidden input — in that case, wrap the
triggering click in expect_file_chooser.
with page.expect_file_chooser() as fc_info:
page.get_by_role("button", name="Choose file").click()
file_chooser = fc_info.value
file_chooser.set_files("fixtures/resume.pdf")
# no output — expect_file_chooser listens for the native file
# dialog opening (fired as a page event even though the real OS
# dialog never actually appears in headless mode) and captures it
Downloading files¶
A download starts when the page navigates to a downloadable resource or a
link/button triggers one. Playwright captures it as a Download object
via expect_download, without your test needing to interact with the
browser's actual download manager UI.
def test_export_report_downloads_csv(page: Page):
page.goto("https://example.com/reports")
with page.expect_download() as download_info:
page.get_by_role("button", name="Export as CSV").click()
download = download_info.value
assert download.suggested_filename == "report.csv"
download.save_as(f"downloads/{download.suggested_filename}")
download.suggested_filename reflects the filename the server/browser
would use by default (from Content-Disposition or the URL); save_as
writes the downloaded bytes to a path you control, which you can then open
and assert on the actual content.
Asserting on downloaded content¶
import csv
def test_exported_csv_has_expected_rows(page: Page):
page.goto("https://example.com/reports")
with page.expect_download() as download_info:
page.get_by_role("button", name="Export as CSV").click()
download = download_info.value
path = download.path() # temp path Playwright already saved it to
with open(path, newline="") as f:
rows = list(csv.reader(f))
assert rows[0] == ["Date", "Revenue", "Orders"]
assert len(rows) > 1
download.path() gives you the temporary file Playwright already wrote
the download to internally, without needing save_as at all when you only
need to inspect content rather than keep a permanent copy — handy for
assertions that don't need to persist the artifact past the test.
Configuring the download directory for a whole run¶
# conftest.py
import pytest
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
return {**browser_context_args, "accept_downloads": True}
# no output — accept_downloads defaults to True in modern
# Playwright versions, but making it explicit documents intent
# and matters if a project has changed the default
How It Actually Works¶
set_input_files doesn't simulate mouse clicks and OS file-picker dialogs at all — Playwright talks to the browser's DevTools Protocol (CDP) directly and calls DOM.setFileInputFiles, which writes the file paths straight into the <input type="file"> element's internal file list, bypassing the OS dialog entirely (that's why it works headlessly, where no real dialog could even render). Downloads work the same way in reverse: Playwright intercepts the browser's Page.downloadWillBegin/downloadWillFinish CDP events, which fire the instant the browser's network layer decides a response is a download (based on Content-Disposition or MIME type) — the download object you get in your script is just a handle to that in-flight browser-side download, which is why you still need to explicitly save_as() it to control where the file actually lands on disk.
Exercise¶
Using https://the-internet.herokuapp.com/upload (upload) and
https://the-internet.herokuapp.com/download (a page listing downloadable
files):
- Create a small local text file fixture, upload it via
input[type='file'], click "Upload", and assert the resulting page shows the correct filename in#uploaded-files. - Upload an in-memory buffer (no file on disk) with
set_input_filesusing thefiles=[{...}]form, and confirm it uploads successfully. - On the download page, pick any linked file, wrap the click in
expect_download(), and assertdownload.suggested_filenamematches the link text. - Save the downloaded file with
save_as()into adownloads/folder in your project, then open it with plain Python and assert it's non-empty. - Write a test that uploads a file whose size exceeds a limit your app enforces (pick any oversized fixture you create), and assert the correct client-side validation error appears instead of a successful upload.