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

25 KiB
Raw Permalink Blame History

Browser APIs

适用场景:当测试的功能依赖浏览器原生 API 时使用——地理定位(geolocation)、权限(permissions)、剪贴板(clipboard)、通知(notifications)、摄像头/麦克风、localStorage、sessionStorage、IndexedDB。 前置条件core/configuration.mdcore/fixtures-and-hooks.md

快速参考

// 地理定位 —— 通过上下文选项设置
const context = await browser.newContext({
  geolocation: { latitude: 40.7128, longitude: -74.006 },
  permissions: ["geolocation"],
})

// 权限 —— 在上下文层面授予
const context = await browser.newContext({
  permissions: ["clipboard-read", "clipboard-write", "notifications"],
})

// localStorage / sessionStorage —— 通过 page.evaluate 访问
await page.evaluate(() => localStorage.setItem("theme", "dark"))
const value = await page.evaluate(() => localStorage.getItem("theme"))

// 剪贴板 —— 在页面上下文中读写
await page.evaluate(() => navigator.clipboard.writeText("copied text"))

模式

地理定位

适用场景:你的应用使用 navigator.geolocation 来实现地图、门店定位器、配送跟踪或基于位置的功能。 避免场景:你的应用从不读取用户位置。

地理定位在上下文级别设置。你也可以在测试过程中使用 context.setGeolocation() 更新它。

TypeScript

import { test, expect } from "@playwright/test"

test("根据地理定位显示最近的门店", async ({ browser }) => {
  const context = await browser.newContext({
    geolocation: { latitude: 40.7128, longitude: -74.006 }, // 纽约
    permissions: ["geolocation"],
  })
  const page = await context.newPage()

  await page.goto("/store-locator")
  await page.getByRole("button", { name: "Find nearby stores" }).click()

  await expect(page.getByText("Manhattan Store")).toBeVisible()
  await context.close()
})

test("在测试中途更新用户位置", async ({ browser }) => {
  const context = await browser.newContext({
    geolocation: { latitude: 37.7749, longitude: -122.4194 }, // 旧金山
    permissions: ["geolocation"],
  })
  const page = await context.newPage()

  await page.goto("/delivery-tracker")
  await expect(page.getByTestId("current-city")).toHaveText("San Francisco")

  // 模拟用户移动到新位置
  await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 }) // 洛杉矶

  // 触发位置刷新
  await page.getByRole("button", { name: "Update location" }).click()
  await expect(page.getByTestId("current-city")).toHaveText("Los Angeles")

  await context.close()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("根据地理定位显示最近的门店", async ({ browser }) => {
  const context = await browser.newContext({
    geolocation: { latitude: 40.7128, longitude: -74.006 },
    permissions: ["geolocation"],
  })
  const page = await context.newPage()

  await page.goto("/store-locator")
  await page.getByRole("button", { name: "Find nearby stores" }).click()

  await expect(page.getByText("Manhattan Store")).toBeVisible()
  await context.close()
})

test("在测试中途更新位置", async ({ browser }) => {
  const context = await browser.newContext({
    geolocation: { latitude: 37.7749, longitude: -122.4194 },
    permissions: ["geolocation"],
  })
  const page = await context.newPage()

  await page.goto("/delivery-tracker")
  await expect(page.getByTestId("current-city")).toHaveText("San Francisco")

  await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 })
  await page.getByRole("button", { name: "Update location" }).click()
  await expect(page.getByTestId("current-city")).toHaveText("Los Angeles")

  await context.close()
})

你也可以在 playwright.config 中全局设置地理定位:

// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  use: {
    geolocation: { latitude: 51.5074, longitude: -0.1278 }, // 伦敦
    permissions: ["geolocation"],
  },
})

权限

适用场景:你的应用请求浏览器权限——通知、摄像头、麦克风、地理定位、剪贴板。 避免场景:你的应用不使用 Permissions API。

在创建上下文时授予权限。Playwright 不显示权限对话框;你可以预先授予或拒绝它们。

TypeScript

import { test, expect } from "@playwright/test"

test("授予通知权限后显示通知 UI", async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ["notifications"],
  })
  const page = await context.newPage()

  await page.goto("/settings/notifications")

  // 应用检查 Notification.permission 并显示开关
  await expect(page.getByRole("switch", { name: "Enable notifications" })).toBeEnabled()

  await context.close()
})

test("拒绝通知权限后显示升级提示", async ({ browser }) => {
  // permissions 中不含 'notifications' = 已拒绝
  const context = await browser.newContext({
    permissions: [],
  })
  const page = await context.newPage()

  await page.goto("/settings/notifications")

  await expect(page.getByText("Notifications are blocked")).toBeVisible()
  await context.close()
})

test("在测试中途授予权限", async ({ browser }) => {
  const context = await browser.newContext()
  const page = await context.newPage()
  await page.goto("/camera-app")

  // 初始状态下没有摄像头权限
  await expect(page.getByText("Camera access needed")).toBeVisible()

  // 动态授予权限
  await context.grantPermissions(["camera"], { origin: "https://localhost:3000" })

  await page.getByRole("button", { name: "Enable camera" }).click()
  await expect(page.getByTestId("camera-preview")).toBeVisible()

  // 撤销所有权限
  await context.clearPermissions()
  await context.close()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("授予通知权限后显示通知 UI", async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ["notifications"],
  })
  const page = await context.newPage()

  await page.goto("/settings/notifications")
  await expect(page.getByRole("switch", { name: "Enable notifications" })).toBeEnabled()
  await context.close()
})

test("在测试中途授予权限", async ({ browser }) => {
  const context = await browser.newContext()
  const page = await context.newPage()
  await page.goto("/camera-app")

  await context.grantPermissions(["camera"], { origin: "https://localhost:3000" })
  await page.getByRole("button", { name: "Enable camera" }).click()
  await expect(page.getByTestId("camera-preview")).toBeVisible()

  await context.clearPermissions()
  await context.close()
})

Clipboard API

适用场景:测试复制/粘贴功能、"复制到剪贴板"按钮或从剪贴板粘贴的功能。 避免场景:你的应用不操作剪贴板。

TypeScript

import { test, expect } from "@playwright/test"

test("复制按钮将文本放入剪贴板", async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ["clipboard-read", "clipboard-write"],
  })
  const page = await context.newPage()

  await page.goto("/share")
  await page.getByRole("button", { name: "Copy link" }).click()

  // 读取剪贴板内容
  const clipboardText = await page.evaluate(() => navigator.clipboard.readText())
  expect(clipboardText).toContain("https://example.com/share/")

  await context.close()
})

test("从剪贴板粘贴到编辑器", async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ["clipboard-read", "clipboard-write"],
  })
  const page = await context.newPage()

  await page.goto("/editor")

  // 以编程方式写入剪贴板
  await page.evaluate(() => navigator.clipboard.writeText("Pasted content from clipboard"))

  // 聚焦编辑器并触发粘贴
  const editor = page.getByRole("textbox", { name: "Editor" })
  await editor.focus()
  await page.keyboard.press("ControlOrMeta+v")

  await expect(editor).toContainText("Pasted content from clipboard")

  await context.close()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("复制按钮将文本放入剪贴板", async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ["clipboard-read", "clipboard-write"],
  })
  const page = await context.newPage()

  await page.goto("/share")
  await page.getByRole("button", { name: "Copy link" }).click()

  const clipboardText = await page.evaluate(() => navigator.clipboard.readText())
  expect(clipboardText).toContain("https://example.com/share/")
  await context.close()
})

摄像头与麦克风模拟

适用场景:测试视频通话、二维码扫描器、语音录制,或任何使用 getUserMedia 的功能。 避免场景:你的应用不使用摄像头或麦克风。

Chromium 可以使用虚拟媒体设备。此功能在 Firefox 或 WebKit 中不可用。

TypeScript

import { test, expect, chromium } from "@playwright/test"

test("视频通话使用虚拟摄像头显示本地预览", async () => {
  // 使用虚拟媒体流启动 Chromium
  const browser = await chromium.launch({
    args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
  })

  const context = await browser.newContext({
    permissions: ["camera", "microphone"],
  })
  const page = await context.newPage()

  await page.goto("/video-call")
  await page.getByRole("button", { name: "Start camera" }).click()

  // 验证视频元素正在播放
  const isPlaying = await page.evaluate(() => {
    const video = document.querySelector("video#local-preview") as HTMLVideoElement
    return video && !video.paused && video.readyState >= 2
  })
  expect(isPlaying).toBe(true)

  await context.close()
  await browser.close()
})

JavaScript

const { test, expect, chromium } = require("@playwright/test")

test("视频通话使用虚拟摄像头显示本地预览", async () => {
  const browser = await chromium.launch({
    args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
  })

  const context = await browser.newContext({
    permissions: ["camera", "microphone"],
  })
  const page = await context.newPage()

  await page.goto("/video-call")
  await page.getByRole("button", { name: "Start camera" }).click()

  const isPlaying = await page.evaluate(() => {
    const video = document.querySelector("video#local-preview")
    return video && !video.paused && video.readyState >= 2
  })
  expect(isPlaying).toBe(true)

  await context.close()
  await browser.close()
})

localStorage 与 sessionStorage

适用场景:你的应用在 Web 存储中持久化状态、令牌、偏好设置或功能开关。 避免场景:你可以通过 UI 或 API 设置状态。为真实性考虑,优先采用这些方式。

TypeScript

import { test, expect } from "@playwright/test"

test("应用从 localStorage 偏好设置加载深色主题", async ({ page }) => {
  // 在导航前设置 localStorage
  await page.goto("/")
  await page.evaluate(() => localStorage.setItem("theme", "dark"))

  // 重新加载以应用存储的偏好
  await page.reload()

  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")
})

test("在场景之间清除 localStorage", async ({ page }) => {
  await page.goto("/")

  // 写入一些数据
  await page.evaluate(() => {
    localStorage.setItem("cart", JSON.stringify([{ id: 1, qty: 2 }]))
    localStorage.setItem("user_prefs", JSON.stringify({ currency: "EUR" }))
  })

  // 读取并验证
  const cart = await page.evaluate(() => JSON.parse(localStorage.getItem("cart") || "[]"))
  expect(cart).toHaveLength(1)

  // 清除特定键
  await page.evaluate(() => localStorage.removeItem("cart"))

  // 或者清除所有内容
  await page.evaluate(() => localStorage.clear())
})

test("sessionStorage 在会话内的导航中保持不变", async ({ page }) => {
  await page.goto("/step-1")
  await page.evaluate(() => sessionStorage.setItem("wizard_step", "1"))

  await page.goto("/step-2")
  const step = await page.evaluate(() => sessionStorage.getItem("wizard_step"))
  expect(step).toBe("1")
})

JavaScript

const { test, expect } = require("@playwright/test")

test("应用从 localStorage 偏好设置加载深色主题", async ({ page }) => {
  await page.goto("/")
  await page.evaluate(() => localStorage.setItem("theme", "dark"))
  await page.reload()

  await expect(page.locator("html")).toHaveAttribute("data-theme", "dark")
})

test("sessionStorage 在会话内的导航中保持不变", async ({ page }) => {
  await page.goto("/step-1")
  await page.evaluate(() => sessionStorage.setItem("wizard_step", "1"))

  await page.goto("/step-2")
  const step = await page.evaluate(() => sessionStorage.getItem("wizard_step"))
  expect(step).toBe("1")
})

IndexedDB 测试

适用场景:你的应用使用 IndexedDB 进行离线存储、缓存或大数据集(渐进式 Web 应用、离线优先应用)。 避免场景:你的应用仅使用 localStorage 或服务端存储。

TypeScript

import { test, expect } from "@playwright/test"

test("离线优先应用将数据存储在 IndexedDB 中", async ({ page }) => {
  await page.goto("/notes")

  // 通过 UI 创建笔记
  await page.getByRole("button", { name: "New note" }).click()
  await page.getByRole("textbox", { name: "Title" }).fill("Test Note")
  await page.getByRole("textbox", { name: "Content" }).fill("This is stored in IndexedDB")
  await page.getByRole("button", { name: "Save" }).click()

  // 验证它已存储在 IndexedDB 中
  const storedNotes = await page.evaluate(() => {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open("NotesDB", 1)
      request.onsuccess = () => {
        const db = request.result
        const tx = db.transaction("notes", "readonly")
        const store = tx.objectStore("notes")
        const getAll = store.getAll()
        getAll.onsuccess = () => resolve(getAll.result)
        getAll.onerror = () => reject(getAll.error)
      }
      request.onerror = () => reject(request.error)
    })
  })

  expect(storedNotes).toHaveLength(1)
  expect(storedNotes[0]).toMatchObject({
    title: "Test Note",
    content: "This is stored in IndexedDB",
  })
})

test("清除 IndexedDB 以获取干净的测试状态", async ({ page }) => {
  await page.goto("/")

  // 删除整个数据库
  await page.evaluate(() => {
    return new Promise((resolve, reject) => {
      const request = indexedDB.deleteDatabase("NotesDB")
      request.onsuccess = () => resolve(undefined)
      request.onerror = () => reject(request.error)
    })
  })

  await page.reload()
  await expect(page.getByText("No notes yet")).toBeVisible()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("离线优先应用将数据存储在 IndexedDB 中", async ({ page }) => {
  await page.goto("/notes")

  await page.getByRole("button", { name: "New note" }).click()
  await page.getByRole("textbox", { name: "Title" }).fill("Test Note")
  await page.getByRole("textbox", { name: "Content" }).fill("This is stored in IndexedDB")
  await page.getByRole("button", { name: "Save" }).click()

  const storedNotes = await page.evaluate(() => {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open("NotesDB", 1)
      request.onsuccess = () => {
        const db = request.result
        const tx = db.transaction("notes", "readonly")
        const store = tx.objectStore("notes")
        const getAll = store.getAll()
        getAll.onsuccess = () => resolve(getAll.result)
        getAll.onerror = () => reject(getAll.error)
      }
      request.onerror = () => reject(request.error)
    })
  })

  expect(storedNotes).toHaveLength(1)
  expect(storedNotes[0]).toMatchObject({
    title: "Test Note",
    content: "This is stored in IndexedDB",
  })
})

通知

适用场景:你的应用使用浏览器 Notification API 显示桌面通知。 避免场景:通知纯粹是服务端触发的(不涉及 Notification API 的推送)。

Playwright 无法捕获实际的系统通知。取而代之的是,模拟 Notification 构造函数并验证应用是否正确调用了它。

TypeScript

import { test, expect } from "@playwright/test"

test("应用在收到新消息时触发浏览器通知", async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ["notifications"],
  })
  const page = await context.newPage()

  // 拦截 Notification 构造函数以捕获调用
  await page.evaluate(() => {
    ;(window as any).__notifications = []
    const OriginalNotification = window.Notification
    ;(window as any).Notification = class MockNotification {
      constructor(title: string, options?: NotificationOptions) {
        ;(window as any).__notifications.push({ title, ...options })
      }
      static get permission() {
        return "granted"
      }
      static requestPermission() {
        return Promise.resolve("granted" as NotificationPermission)
      }
    }
  })

  await page.goto("/chat")

  // 模拟收到一条会触发通知的消息
  await page.evaluate(() => {
    window.dispatchEvent(
      new CustomEvent("new-message", {
        detail: { from: "Alice", text: "Hey there!" },
      })
    )
  })

  // 检查通知是否以正确的内容创建
  const notifications = await page.evaluate(() => (window as any).__notifications)
  expect(notifications).toHaveLength(1)
  expect(notifications[0].title).toBe("New message from Alice")
  expect(notifications[0].body).toBe("Hey there!")

  await context.close()
})

JavaScript

const { test, expect } = require("@playwright/test")

test("应用在收到新消息时触发浏览器通知", async ({ browser }) => {
  const context = await browser.newContext({
    permissions: ["notifications"],
  })
  const page = await context.newPage()

  await page.evaluate(() => {
    window.__notifications = []
    window.Notification = class MockNotification {
      constructor(title, options) {
        window.__notifications.push({ title, ...options })
      }
      static get permission() {
        return "granted"
      }
      static requestPermission() {
        return Promise.resolve("granted")
      }
    }
  })

  await page.goto("/chat")

  await page.evaluate(() => {
    window.dispatchEvent(
      new CustomEvent("new-message", {
        detail: { from: "Alice", text: "Hey there!" },
      })
    )
  })

  const notifications = await page.evaluate(() => window.__notifications)
  expect(notifications).toHaveLength(1)
  expect(notifications[0].title).toBe("New message from Alice")
  await context.close()
})

决策指南

浏览器 API 测试方法 关键配置
地理定位(Geolocation geolocation 上下文选项 + permissions: ['geolocation'] context.setGeolocation() 用于测试中途变更
权限(任意) 上下文选项中的 permissions 数组 context.grantPermissions() / context.clearPermissions()
剪贴板(Clipboard 授予 clipboard-read/clipboard-write + page.evaluate(navigator.clipboard...) 需要安全上下文(HTTPS 或 localhost
通知(Notifications 通过 page.evaluate 模拟 Notification 构造函数 授予 notifications 权限;捕获构造函数调用
摄像头/麦克风 Chromium 启动参数 --use-fake-device-for-media-stream 仅在 Chromium 中有效;授予 camera/microphone 权限
localStorage page.evaluate(() => localStorage.getItem/setItem(...)) 在导航或重新加载前设置才能生效
sessionStorage page.evaluate(() => sessionStorage.getItem/setItem(...)) 作用域限定于当前浏览会话;上下文关闭时清除
IndexedDB 使用 indexedDB.open()page.evaluate 对异步操作需用 Promise 包裹

反模式

不要这样做 问题 应这样做
设置 geolocation 但不授予权限 getCurrentPosition 返回权限错误 始终将 geolocationpermissions: ['geolocation'] 配对
在非安全上下文中测试剪贴板 navigator.clipboard 在非 HTTPS 上下文中抛出异常 使用 localhost 或在测试服务器中配置 HTTPS
在导航到目标源之前访问 localStorage page.evaluate 最初在 about:blank 上下文中执行 先执行 page.goto('/'),然后设置 localStorage,再重新加载
直接在 localStorage 中存储测试令牌 绕过认证流程;可能掩盖真实的登录缺陷 使用 storageState 或合适的认证 fixture
依赖上一个测试的 IndexedDB 状态 测试必须相互独立 在测试设置中清除或删除数据库
不清除注入的模拟(Notification 等) 如果使用同一个上下文,模拟会泄漏到后续测试中 默认每个测试使用新的上下文;仅在使用共享上下文时需要注意

故障排查

症状 原因 修复方法
应用中 geolocation 返回 undefined 未授予权限 在上下文选项中添加 permissions: ['geolocation']
剪贴板 readText() 抛出 DOMException 缺少剪贴板权限或非安全上下文 授予 clipboard-read;确保使用 HTTPS 或 localhost
localStorage.getItem()setItem 后返回 null 设置操作在不同的源上执行,或在导航之前执行 验证你已在调用 setItem 之前导航到正确的源
摄像头在 Firefox/WebKit 中无法工作 虚拟媒体设备仅 Chromium 支持 在非 Chromium 浏览器上跳过摄像头测试,或通过 page.evaluate 模拟 getUserMedia
IndexedDB 的 evaluate 返回 undefined 忘记返回 Promise 确保 page.evaluate 回调返回 new Promise(...)
权限变更未生效 应用在加载时缓存了权限状态 grantPermissions()clearPermissions() 之后重新加载页面

相关文档