25 KiB
Browser APIs
适用场景:当测试的功能依赖浏览器原生 API 时使用——地理定位(geolocation)、权限(permissions)、剪贴板(clipboard)、通知(notifications)、摄像头/麦克风、localStorage、sessionStorage、IndexedDB。 前置条件:core/configuration.md、core/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 返回权限错误 |
始终将 geolocation 与 permissions: ['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() 之后重新加载页面 |
相关文档
- core/configuration.md —— 配置中的全局地理定位/权限设置
- core/service-workers-and-pwa.md —— 使用 IndexedDB 和服务工作线程进行离线与缓存测试
- core/fixtures-and-hooks.md —— 将浏览器 API 设置封装为可复用的 fixture
- core/debugging.md —— 在 Playwright 追踪中检查存储和权限