26 KiB
文件操作
使用时机:测试文件上传、下载、拖放文件交互、文件类型验证及下载验证。 前置要求:core/locators.md、core/assertions-and-waiting.md
快速参考
// 上传——单个文件
await page.getByLabel("Upload").setInputFiles("fixtures/resume.pdf")
// 上传——多个文件
await page.getByLabel("Upload").setInputFiles(["fixtures/a.png", "fixtures/b.png"])
// 上传——清除选择
await page.getByLabel("Upload").setInputFiles([])
// 下载——等待并保存
const download = await page.waitForEvent("download")
await page.getByRole("button", { name: "Export CSV" }).click()
const path = await download.path() // 临时路径
await download.saveAs("test-results/export.csv") // 永久路径
// 文件选择对话框——非 input 元素上传
const fileChooser = await page.waitForEvent("filechooser")
await page.getByRole("button", { name: "Choose file" }).click()
await fileChooser.setFiles("fixtures/photo.jpg")
模式
单个文件上传
适用场景:表单包含标准的 <input type="file"> 元素。
避免场景:上传使用拖放区域,且底层没有文件输入元素。
TypeScript
import { test, expect } from "@playwright/test"
import path from "path"
test("upload a single document", async ({ page }) => {
await page.goto("/settings/profile")
const filePath = path.join(__dirname, "../fixtures/avatar.png")
await page.getByLabel("Profile picture").setInputFiles(filePath)
// 验证文件名出现在 UI 中
await expect(page.getByText("avatar.png")).toBeVisible()
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Profile updated")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
const path = require("path")
test("upload a single document", async ({ page }) => {
await page.goto("/settings/profile")
const filePath = path.join(__dirname, "../fixtures/avatar.png")
await page.getByLabel("Profile picture").setInputFiles(filePath)
await expect(page.getByText("avatar.png")).toBeVisible()
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Profile updated")).toBeVisible()
})
多个文件上传
适用场景:文件输入接受 multiple 属性,且需要一次附加多个文件。
避免场景:UI 只允许上传一个文件。请使用单个文件上传。
TypeScript
import { test, expect } from "@playwright/test"
import path from "path"
test("upload multiple attachments", async ({ page }) => {
await page.goto("/tickets/new")
const fixtures = ["doc1.pdf", "doc2.pdf", "screenshot.png"].map((f) =>
path.join(__dirname, "../fixtures", f)
)
await page.getByLabel("Attachments").setInputFiles(fixtures)
// 验证所有文件均已列出
await expect(page.getByTestId("file-list").getByRole("listitem")).toHaveCount(3)
// 通过清除并重新设置来移除一个文件
await page.getByLabel("Attachments").setInputFiles(fixtures.slice(0, 2))
await expect(page.getByTestId("file-list").getByRole("listitem")).toHaveCount(2)
})
JavaScript
const { test, expect } = require("@playwright/test")
const path = require("path")
test("upload multiple attachments", async ({ page }) => {
await page.goto("/tickets/new")
const fixtures = ["doc1.pdf", "doc2.pdf", "screenshot.png"].map((f) =>
path.join(__dirname, "../fixtures", f)
)
await page.getByLabel("Attachments").setInputFiles(fixtures)
await expect(page.getByTestId("file-list").getByRole("listitem")).toHaveCount(3)
await page.getByLabel("Attachments").setInputFiles(fixtures.slice(0, 2))
await expect(page.getByTestId("file-list").getByRole("listitem")).toHaveCount(2)
})
文件选择对话框
适用场景:上传由点击按钮触发,该按钮打开系统原生文件选择器,而非由可见的 <input type="file"> 触发。常见于拖放库和自定义上传组件。
避免场景:存在可见的 <input type="file"> —— 应直接使用 setInputFiles。
TypeScript
import { test, expect } from "@playwright/test"
test("upload via file chooser dialog", async ({ page }) => {
await page.goto("/documents")
// 在点击打开对话框的按钮之前注册监听器
const fileChooserPromise = page.waitForEvent("filechooser")
await page.getByRole("button", { name: "Upload document" }).click()
const fileChooser = await fileChooserPromise
// 验证对话框属性
expect(fileChooser.isMultiple()).toBe(false)
await fileChooser.setFiles("fixtures/report.pdf")
await expect(page.getByText("report.pdf")).toBeVisible()
})
test("upload multiple via file chooser", async ({ page }) => {
await page.goto("/gallery")
const fileChooserPromise = page.waitForEvent("filechooser")
await page.getByRole("button", { name: "Add photos" }).click()
const fileChooser = await fileChooserPromise
expect(fileChooser.isMultiple()).toBe(true)
await fileChooser.setFiles(["fixtures/photo1.jpg", "fixtures/photo2.jpg"])
await expect(page.getByRole("img")).toHaveCount(2)
})
JavaScript
const { test, expect } = require("@playwright/test")
test("upload via file chooser dialog", async ({ page }) => {
await page.goto("/documents")
const fileChooserPromise = page.waitForEvent("filechooser")
await page.getByRole("button", { name: "Upload document" }).click()
const fileChooser = await fileChooserPromise
expect(fileChooser.isMultiple()).toBe(false)
await fileChooser.setFiles("fixtures/report.pdf")
await expect(page.getByText("report.pdf")).toBeVisible()
})
test("upload multiple via file chooser", async ({ page }) => {
await page.goto("/gallery")
const fileChooserPromise = page.waitForEvent("filechooser")
await page.getByRole("button", { name: "Add photos" }).click()
const fileChooser = await fileChooserPromise
expect(fileChooser.isMultiple()).toBe(true)
await fileChooser.setFiles(["fixtures/photo1.jpg", "fixtures/photo2.jpg"])
await expect(page.getByRole("img")).toHaveCount(2)
})
拖放文件上传
适用场景:UI 包含一个拖放区域,通过 HTML5 拖放 API 接受文件,且没有 <input type="file"> 回退机制。
避免场景:存在文件输入元素 —— 即使是隐藏的输入元素也可以用 setInputFiles 处理。
TypeScript
import { test, expect } from "@playwright/test"
import fs from "fs"
import path from "path"
test("drop file onto upload zone", async ({ page }) => {
await page.goto("/upload")
// 将文件读入缓冲区
const filePath = path.join(__dirname, "../fixtures/data.csv")
const buffer = fs.readFileSync(filePath)
// 创建包含文件的 DataTransfer 并派发 drop 事件
const dropZone = page.getByTestId("drop-zone")
await dropZone.dispatchEvent("drop", {
dataTransfer: {
files: [{ name: "data.csv", mimeType: "text/csv", buffer }],
},
})
await expect(page.getByText("data.csv")).toBeVisible()
await expect(page.getByText("Upload complete")).toBeVisible()
})
test("drag-and-drop with hidden input fallback", async ({ page }) => {
await page.goto("/upload")
// 许多拖放库仍然使用隐藏的 <input type="file">
// 先检查是否存在——这比模拟 DnD 事件更可靠
const hiddenInput = page.locator('input[type="file"]')
if ((await hiddenInput.count()) > 0) {
await hiddenInput.setInputFiles("fixtures/data.csv")
}
await expect(page.getByText("data.csv")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
const fs = require("fs")
const path = require("path")
test("drop file onto upload zone", async ({ page }) => {
await page.goto("/upload")
const filePath = path.join(__dirname, "../fixtures/data.csv")
const buffer = fs.readFileSync(filePath)
const dropZone = page.getByTestId("drop-zone")
await dropZone.dispatchEvent("drop", {
dataTransfer: {
files: [{ name: "data.csv", mimeType: "text/csv", buffer }],
},
})
await expect(page.getByText("data.csv")).toBeVisible()
await expect(page.getByText("Upload complete")).toBeVisible()
})
test("drag-and-drop with hidden input fallback", async ({ page }) => {
await page.goto("/upload")
const hiddenInput = page.locator('input[type="file"]')
if ((await hiddenInput.count()) > 0) {
await hiddenInput.setInputFiles("fixtures/data.csv")
}
await expect(page.getByText("data.csv")).toBeVisible()
})
文件下载——等待并验证
适用场景:测试导出按钮、报告生成,或任何触发浏览器下载的操作。 避免场景:文件以页面导航方式提供(在新标签页中打开)。请改用多标签页模式。
TypeScript
import { test, expect } from "@playwright/test"
import fs from "fs"
test("download and verify file content", async ({ page }) => {
await page.goto("/reports")
// 在触发下载的点击之前注册
const downloadPromise = page.waitForEvent("download")
await page.getByRole("button", { name: "Export CSV" }).click()
const download = await downloadPromise
// 验证下载元数据
expect(download.suggestedFilename()).toBe("report-2025.csv")
// 保存到已知位置
const savePath = "test-results/report.csv"
await download.saveAs(savePath)
// 读取并验证内容
const content = fs.readFileSync(savePath, "utf-8")
expect(content).toContain("Name,Email,Status")
expect(content).toContain("Jane Doe,jane@example.com,Active")
// 验证文件大小合理
const stats = fs.statSync(savePath)
expect(stats.size).toBeGreaterThan(100)
})
test("download triggered by a link", async ({ page }) => {
await page.goto("/files")
const downloadPromise = page.waitForEvent("download")
await page.getByRole("link", { name: "Download invoice" }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/invoice-\d+\.pdf/)
// 使用 download.path() 获取临时文件路径
const tempPath = await download.path()
expect(tempPath).toBeTruthy()
})
JavaScript
const { test, expect } = require("@playwright/test")
const fs = require("fs")
test("download and verify file content", async ({ page }) => {
await page.goto("/reports")
const downloadPromise = page.waitForEvent("download")
await page.getByRole("button", { name: "Export CSV" }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe("report-2025.csv")
const savePath = "test-results/report.csv"
await download.saveAs(savePath)
const content = fs.readFileSync(savePath, "utf-8")
expect(content).toContain("Name,Email,Status")
expect(content).toContain("Jane Doe,jane@example.com,Active")
const stats = fs.statSync(savePath)
expect(stats.size).toBeGreaterThan(100)
})
test("download triggered by a link", async ({ page }) => {
await page.goto("/files")
const downloadPromise = page.waitForEvent("download")
await page.getByRole("link", { name: "Download invoice" }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/invoice-\d+\.pdf/)
const tempPath = await download.path()
expect(tempPath).toBeTruthy()
})
配置下载路径
适用场景:需要将下载文件保存到特定目录,或需要禁用下载对话框提示。
避免场景:通过 download.path() 或 download.saveAs() 使用默认临时路径即可满足需求。
TypeScript
// playwright.config.ts —— 全局下载行为
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
// 接受所有下载,无需提示
acceptDownloads: true, // 默认为 true
},
})
import { test, expect } from "@playwright/test"
import fs from "fs"
import path from "path"
// 通过自定义 fixture 实现每个测试独立的下载目录
const downloadTest = test.extend<{ downloadDir: string }>({
downloadDir: async ({}, use, testInfo) => {
const dir = path.join("test-results", "downloads", testInfo.title.replace(/\s+/g, "-"))
fs.mkdirSync(dir, { recursive: true })
await use(dir)
},
})
downloadTest("save downloads to organized directories", async ({ page, downloadDir }) => {
await page.goto("/exports")
const downloadPromise = page.waitForEvent("download")
await page.getByRole("button", { name: "Export" }).click()
const download = await downloadPromise
const savePath = path.join(downloadDir, download.suggestedFilename())
await download.saveAs(savePath)
expect(fs.existsSync(savePath)).toBe(true)
})
JavaScript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
use: {
acceptDownloads: true,
},
})
const { test, expect } = require("@playwright/test")
const fs = require("fs")
const path = require("path")
const downloadTest = test.extend({
downloadDir: async ({}, use, testInfo) => {
const dir = path.join("test-results", "downloads", testInfo.title.replace(/\s+/g, "-"))
fs.mkdirSync(dir, { recursive: true })
await use(dir)
},
})
downloadTest("save downloads to organized directories", async ({ page, downloadDir }) => {
await page.goto("/exports")
const downloadPromise = page.waitForEvent("download")
await page.getByRole("button", { name: "Export" }).click()
const download = await downloadPromise
const savePath = path.join(downloadDir, download.suggestedFilename())
await download.saveAs(savePath)
expect(fs.existsSync(savePath)).toBe(true)
})
文件类型验证
适用场景:测试应用程序拒绝无效文件类型并接受有效文件类型。 避免场景:应用程序不做任何客户端验证,完全依赖服务端检查(改为通过 API 测试)。
TypeScript
import { test, expect } from "@playwright/test"
test("rejects unsupported file types", async ({ page }) => {
await page.goto("/upload")
// 上传无效文件类型
await page.getByLabel("Upload image").setInputFiles({
name: "malware.exe",
mimeType: "application/octet-stream",
buffer: Buffer.from("fake-exe-content"),
})
await expect(page.getByText("Only JPG, PNG, and GIF files are allowed")).toBeVisible()
await expect(page.getByRole("button", { name: "Submit" })).toBeDisabled()
})
test("accepts valid file types", async ({ page }) => {
await page.goto("/upload")
await page.getByLabel("Upload image").setInputFiles({
name: "photo.jpg",
mimeType: "image/jpeg",
buffer: Buffer.from("fake-jpg-content"),
})
await expect(page.getByText("photo.jpg")).toBeVisible()
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
})
test("validates file size limits", async ({ page }) => {
await page.goto("/upload")
// 创建一个超过 5MB 限制的缓冲区
const largeBuffer = Buffer.alloc(6 * 1024 * 1024, "x")
await page.getByLabel("Upload document").setInputFiles({
name: "huge-file.pdf",
mimeType: "application/pdf",
buffer: largeBuffer,
})
await expect(page.getByText("File size must be under 5MB")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("rejects unsupported file types", async ({ page }) => {
await page.goto("/upload")
await page.getByLabel("Upload image").setInputFiles({
name: "malware.exe",
mimeType: "application/octet-stream",
buffer: Buffer.from("fake-exe-content"),
})
await expect(page.getByText("Only JPG, PNG, and GIF files are allowed")).toBeVisible()
await expect(page.getByRole("button", { name: "Submit" })).toBeDisabled()
})
test("accepts valid file types", async ({ page }) => {
await page.goto("/upload")
await page.getByLabel("Upload image").setInputFiles({
name: "photo.jpg",
mimeType: "image/jpeg",
buffer: Buffer.from("fake-jpg-content"),
})
await expect(page.getByText("photo.jpg")).toBeVisible()
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled()
})
test("validates file size limits", async ({ page }) => {
await page.goto("/upload")
const largeBuffer = Buffer.alloc(6 * 1024 * 1024, "x")
await page.getByLabel("Upload document").setInputFiles({
name: "huge-file.pdf",
mimeType: "application/pdf",
buffer: largeBuffer,
})
await expect(page.getByText("File size must be under 5MB")).toBeVisible()
})
大文件处理
适用场景:测试大文件的上传或下载,此时超时和进度指示器至关重要。 避免场景:每个测试都用大文件。大文件测试速度较慢。请放入单独的测试套件或标记为仅夜间运行。
TypeScript
import { test, expect } from "@playwright/test"
import fs from "fs"
import path from "path"
test.describe("large file operations", () => {
// 增加整个 describe 块的超时时间
test.slow() // 将默认超时时间延长三倍
test("upload large file with progress tracking", async ({ page }) => {
await page.goto("/upload")
// 在磁盘上创建测试文件(避免对超大文件使用 Buffer.alloc)
const largePath = path.join("test-results", "large-test-file.bin")
const stream = fs.createWriteStream(largePath)
for (let i = 0; i < 100; i++) {
stream.write(Buffer.alloc(1024 * 1024, "a")) // 共计 100MB
}
stream.end()
await new Promise((resolve) => stream.on("finish", resolve))
await page.getByLabel("Upload file").setInputFiles(largePath)
// 等待进度指示器出现
await expect(page.getByRole("progressbar")).toBeVisible()
// 等待完成——延长超时时间
await expect(page.getByText("Upload complete")).toBeVisible({ timeout: 120_000 })
// 清理
fs.unlinkSync(largePath)
})
test("download large file", async ({ page }) => {
await page.goto("/exports")
const downloadPromise = page.waitForEvent("download")
await page.getByRole("button", { name: "Export full dataset" }).click()
const download = await downloadPromise
const savePath = "test-results/large-export.zip"
await download.saveAs(savePath)
// 验证文件大小合理(至少 10MB)
const stats = fs.statSync(savePath)
expect(stats.size).toBeGreaterThan(10 * 1024 * 1024)
fs.unlinkSync(savePath)
})
})
JavaScript
const { test, expect } = require("@playwright/test")
const fs = require("fs")
const path = require("path")
test.describe("large file operations", () => {
test.slow()
test("upload large file with progress tracking", async ({ page }) => {
await page.goto("/upload")
const largePath = path.join("test-results", "large-test-file.bin")
const stream = fs.createWriteStream(largePath)
for (let i = 0; i < 100; i++) {
stream.write(Buffer.alloc(1024 * 1024, "a"))
}
stream.end()
await new Promise((resolve) => stream.on("finish", resolve))
await page.getByLabel("Upload file").setInputFiles(largePath)
await expect(page.getByRole("progressbar")).toBeVisible()
await expect(page.getByText("Upload complete")).toBeVisible({ timeout: 120_000 })
fs.unlinkSync(largePath)
})
test("download large file", async ({ page }) => {
await page.goto("/exports")
const downloadPromise = page.waitForEvent("download")
await page.getByRole("button", { name: "Export full dataset" }).click()
const download = await downloadPromise
const savePath = "test-results/large-export.zip"
await download.saveAs(savePath)
const stats = fs.statSync(savePath)
expect(stats.size).toBeGreaterThan(10 * 1024 * 1024)
fs.unlinkSync(savePath)
})
})
决策指南
| 场景 | 方法 | 关键 API |
|---|---|---|
标准 <input type="file"> |
在定位器上使用 setInputFiles() |
locator.setInputFiles(path) |
| 隐藏的文件输入 | 找到输入元素(即使隐藏),调用 setInputFiles() |
page.locator('input[type="file"]').setInputFiles() |
| 自定义按钮打开文件选择器 | 在点击前监听 filechooser 事件 |
page.waitForEvent('filechooser') |
| 无输入元素的拖放区域 | 使用 DataTransfer 派发 drop 事件 |
locator.dispatchEvent('drop', ...) |
| 带隐藏输入回退的拖放区域 | 优先对隐藏输入使用 setInputFiles() |
先检查 input[type="file"] 的数量 |
| 一次上传多个文件 | 向 setInputFiles() 传递数组 |
setInputFiles([path1, path2]) |
| 内存中测试文件(无需磁盘) | 传入包含 name、mimeType、buffer 的对象 |
setInputFiles({ name, mimeType, buffer }) |
| 下载——验证文件名 | download.suggestedFilename() |
page.waitForEvent('download') |
| 下载——验证内容 | download.saveAs() 然后使用 fs 读取 |
fs.readFileSync() |
| 下载——仅临时文件 | download.path() 返回临时位置 |
测试结束后自动删除 |
| 大文件上传/下载 | 使用 test.slow(),增加断言超时时间 |
{ timeout: 120_000 } |
反模式
| 不要这样做 | 问题 | 应改为 |
|---|---|---|
上传后使用 await page.waitForTimeout(3000) |
任意延迟;不稳定 | await expect(page.getByText('Upload complete')).toBeVisible() |
使用 CSS 选择器 await page.setInputFiles('#file', path) |
定位器脆弱;ID 变更后失效 | await page.getByLabel('Upload').setInputFiles(path) |
| 点击下载后立即读取文件 | 竞态条件;文件可能尚未写入 | 使用 page.waitForEvent('download') 再 download.saveAs() |
在 beforeAll 中创建大型测试文件且从不清理 |
CI 中磁盘被占满,拖慢后续运行 | 在 afterAll 中清理或使用 fixture 的拆解逻辑 |
每次上传都使用 page.on('filechooser') |
当 <input type="file"> 存在时带来不必要的复杂性 |
直接使用 setInputFiles() |
| 硬编码绝对文件路径 | 在不同机器和 CI 中失效 | 使用 path.join(__dirname, ...) 或相对于项目根目录的路径 |
| 使用空缓冲区测试文件上传 | 未测试真实的验证行为 | 使用真实的文件内容或最小有效文件大小 |
使用 download.path() 进行永久存储 |
临时文件在测试上下文关闭后被清理 | 使用 download.saveAs() 保存到永久路径 |
| 每次测试运行都上传真实的 100MB 文件 | 拖慢整个测试套件,浪费 CI 资源 | 将大文件测试单独标记;按计划运行,而非每次 PR |
故障排除
"FileChooser event was not emitted"
原因:点击操作并未打开系统原生文件选择器对话框。上传组件可能使用了不同的机制。
// 调试:检查是否存在可以直接定位的隐藏 <input type="file">
const fileInputCount = await page.locator('input[type="file"]').count()
console.log(`Found ${fileInputCount} file inputs`)
// 如果输入元素存在,则完全跳过 file chooser 方式
if (fileInputCount > 0) {
await page.locator('input[type="file"]').setInputFiles("fixtures/file.pdf")
}
"Download event was not emitted"
原因:链接在新标签页中打开或导航到文件 URL,而不是触发下载。
// 修复 1:确保配置中 acceptDownloads 为 true
// 修复 2:检查链接是否打开新标签页——将其作为新页面处理
const pagePromise = page.context().waitForEvent("page")
await page.getByRole("link", { name: "Download" }).click()
const newPage = await pagePromise
// 然后在新页面上等待下载事件
const download = await newPage.waitForEvent("download")
上传在本地正常,但在 CI 中失败
原因:CI 中的文件路径错误,或 fixture 文件未包含在仓库/构建中。
// 修复:始终相对于测试文件解析路径
import path from "path"
const fixturePath = path.join(__dirname, "..", "fixtures", "test-file.pdf")
// 在上传前验证文件是否存在
import fs from "fs"
if (!fs.existsSync(fixturePath)) {
throw new Error(`Fixture file missing: ${fixturePath}`)
}
setInputFiles 无效果——未出现文件
原因:输入元素已分离、位于 Shadow DOM 内部,或位于 iframe 中。
// 检查 iframe
const frame = page.frameLocator('iframe[title="Upload"]')
await frame.locator('input[type="file"]').setInputFiles("fixtures/file.pdf")
// 检查 Shadow DOM——Playwright 会自动穿透开放 shadow root
// 但输入元素可能位于封闭的 shadow root 中(极少见)。改用 file chooser 方式。
相关文档
- core/locators.md —— 查找上传/下载元素的定位器策略
- core/assertions-and-waiting.md —— 验证上传/下载结果的断言模式
- core/fixtures-and-hooks.md —— 创建可复用的下载目录 fixture
- core/network-mocking.md —— 模拟上传端点以加速测试
- core/error-and-edge-cases.md —— 测试上传失败状态和错误处理