Files
2026-07-13 21:36:47 +08:00

26 KiB
Raw Permalink Blame History

文件操作

使用时机:测试文件上传、下载、拖放文件交互、文件类型验证及下载验证。 前置要求core/locators.mdcore/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])
内存中测试文件(无需磁盘) 传入包含 namemimeTypebuffer 的对象 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 方式。

相关文档