60 KiB
Mobile and Responsive Testing(移动端与响应式测试)
使用时机:测试应用在手机、平板电脑以及不同视口尺寸下的表现。涵盖设备模拟、触摸交互、地理位置、屏幕方向变化以及移动端特有的 UI 模式。 前置要求:core/configuration.md、core/locators.md
快速参考(Quick Reference)
import { devices } from "@playwright/test"
// 预定义的设备配置文件(viewport、userAgent、touch、deviceScaleFactor)
devices["iPhone 14"] // 390x844,触屏,Safari 移动端 UA
devices["iPhone 14 Pro Max"] // 430x932,触屏,Safari 移动端 UA
devices["Pixel 7"] // 412x915,触屏,Chrome 移动端 UA
devices["iPad Pro 11"] // 834x1194,触屏,Safari 平板端 UA
devices["Galaxy S9+"] // 320x658,触屏,Chrome 移动端 UA
devices["Desktop Chrome"] // 1280x720,非触屏,Chrome 桌面端 UA
devices["Desktop Safari"] // 1280x720,非触屏,Safari 桌面端 UA
// 横屏变体
devices["iPhone 14 landscape"] // 844x390,触屏,Safari 移动端 UA
devices["iPad Pro 11 landscape"] // 1194x834,触屏,Safari 平板端 UA
# 仅运行移动端项目
npx playwright test --project=mobile-chrome
npx playwright test --project=mobile-safari
# 运行所有项目(桌面端 + 移动端并行)
npx playwright test
# 列出可用的设备名称
npx playwright test --list-devices 2>/dev/null || node -e "const {devices}=require('@playwright/test');console.log(Object.keys(devices).join('\n'))"
模式(Patterns)
1. 设备模拟(Device Emulation)
使用时机:测试你的应用在特定真实设备(如 iPhone、Pixel、iPad)上的显示效果。一次性应用正确的视口、用户代理、设备像素比和触屏支持。 避免时机:你只需要测试某个特定的视口宽度(此时应使用自定义视口)。当像素级渲染效果至关重要时,设备模拟不能替代真实设备测试。
TypeScript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./tests",
projects: [
{
name: "Desktop Chrome",
use: { ...devices["Desktop Chrome"] },
},
{
name: "Mobile Chrome",
use: { ...devices["Pixel 7"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 14"] },
},
{
name: "Tablet",
use: { ...devices["iPad Pro 11"] },
},
],
})
// tests/mobile-navigation.spec.ts
import { test, expect } from "@playwright/test"
test("mobile user can navigate via hamburger menu", async ({ page, isMobile }) => {
await page.goto("/")
if (isMobile) {
// 移动端:汉堡菜单可见,桌面导航隐藏
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible()
await expect(page.getByRole("navigation", { name: "Main" })).toBeHidden()
// 打开汉堡菜单
await page.getByRole("button", { name: "Menu" }).click()
await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("**/products")
} else {
// 桌面端:导航链接直接可见
await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("**/products")
}
await expect(page.getByRole("heading", { name: "Products", level: 1 })).toBeVisible()
})
JavaScript
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")
module.exports = defineConfig({
testDir: "./tests",
projects: [
{
name: "Desktop Chrome",
use: { ...devices["Desktop Chrome"] },
},
{
name: "Mobile Chrome",
use: { ...devices["Pixel 7"] },
},
{
name: "Mobile Safari",
use: { ...devices["iPhone 14"] },
},
{
name: "Tablet",
use: { ...devices["iPad Pro 11"] },
},
],
})
// tests/mobile-navigation.spec.js
const { test, expect } = require("@playwright/test")
test("mobile user can navigate via hamburger menu", async ({ page, isMobile }) => {
await page.goto("/")
if (isMobile) {
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible()
await expect(page.getByRole("navigation", { name: "Main" })).toBeHidden()
await page.getByRole("button", { name: "Menu" }).click()
await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("**/products")
} else {
await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("**/products")
}
await expect(page.getByRole("heading", { name: "Products", level: 1 })).toBeVisible()
})
2. 自定义视口(Custom Viewports)
使用时机:在不进行完整设备模拟的情况下,测试响应式布局在特定断点下的表现。非常适合验证 CSS 媒体查询是否在正确的宽度下触发。 避免时机:你需要真实的移动端行为(触摸事件、移动端用户代理、设备像素比)。此时应使用设备模拟。
TypeScript
// tests/responsive-layout.spec.ts
import { test, expect } from "@playwright/test"
const breakpoints = [
{ name: "mobile-small", width: 320, height: 568 },
{ name: "mobile", width: 375, height: 667 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1024, height: 768 },
{ name: "desktop-large", width: 1440, height: 900 },
]
for (const bp of breakpoints) {
test(`layout adapts correctly at ${bp.name} (${bp.width}px)`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: bp.height })
await page.goto("/")
if (bp.width < 768) {
// 移动端布局:堆叠排列,汉堡菜单可见
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible()
await expect(page.getByTestId("sidebar")).toBeHidden()
} else if (bp.width < 1024) {
// 平板端布局:可折叠侧边栏
await expect(page.getByRole("button", { name: "Menu" })).toBeHidden()
await expect(page.getByTestId("sidebar")).toBeVisible()
} else {
// 桌面端布局:完整侧边栏,无汉堡菜单
await expect(page.getByRole("button", { name: "Menu" })).toBeHidden()
await expect(page.getByTestId("sidebar")).toBeVisible()
await expect(page.getByTestId("sidebar")).toHaveCSS("width", "280px")
}
})
}
// 按文件设置视口覆盖(适用于该文件中的所有测试)
import { test, expect } from "@playwright/test"
test.use({ viewport: { width: 375, height: 667 } })
test("mobile checkout flow fits on small screen", async ({ page }) => {
await page.goto("/checkout")
// 验证没有水平滚动条
const hasHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
)
expect(hasHorizontalScroll).toBe(false)
// 验证所有表单字段在无水平滚动的情况下可见
await expect(page.getByLabel("Card number")).toBeVisible()
await expect(page.getByRole("button", { name: "Pay now" })).toBeVisible()
})
JavaScript
// tests/responsive-layout.spec.js
const { test, expect } = require("@playwright/test")
const breakpoints = [
{ name: "mobile-small", width: 320, height: 568 },
{ name: "mobile", width: 375, height: 667 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1024, height: 768 },
{ name: "desktop-large", width: 1440, height: 900 },
]
for (const bp of breakpoints) {
test(`layout adapts correctly at ${bp.name} (${bp.width}px)`, async ({ page }) => {
await page.setViewportSize({ width: bp.width, height: bp.height })
await page.goto("/")
if (bp.width < 768) {
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible()
await expect(page.getByTestId("sidebar")).toBeHidden()
} else if (bp.width < 1024) {
await expect(page.getByRole("button", { name: "Menu" })).toBeHidden()
await expect(page.getByTestId("sidebar")).toBeVisible()
} else {
await expect(page.getByRole("button", { name: "Menu" })).toBeHidden()
await expect(page.getByTestId("sidebar")).toBeVisible()
await expect(page.getByTestId("sidebar")).toHaveCSS("width", "280px")
}
})
}
// 按文件设置视口覆盖
const { test, expect } = require("@playwright/test")
test.use({ viewport: { width: 375, height: 667 } })
test("mobile checkout flow fits on small screen", async ({ page }) => {
await page.goto("/checkout")
const hasHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
)
expect(hasHorizontalScroll).toBe(false)
await expect(page.getByLabel("Card number")).toBeVisible()
await expect(page.getByRole("button", { name: "Pay now" })).toBeVisible()
})
3. 触摸事件(Touch Events)
使用时机:测试触摸特有的交互——点击、滑动、捏合。对于没有鼠标等效操作的移动端手势是必需的。
避免时机:该功能在使用鼠标点击时行为完全相同。Playwright 的 click() 在启用触屏的设备配置上会自动派发触摸事件。
TypeScript
// tests/touch-interactions.spec.ts
import { test, expect, devices } from "@playwright/test"
test.use({ ...devices["iPhone 14"] })
test("tap to select an item", async ({ page }) => {
await page.goto("/gallery")
// tap() 会派发 touchstart → touchend → click
// 仅在 hasTouch 为 true 时可用(设备配置会设置此项)
await page.getByRole("img", { name: "Sunset photo" }).tap()
await expect(page.getByText("Selected: Sunset photo")).toBeVisible()
})
test("swipe to dismiss a card", async ({ page }) => {
await page.goto("/notifications")
const card = page.getByTestId("notification-card").first()
const box = await card.boundingBox()
if (box) {
// 模拟向左滑动:在右侧 touchstart,向左 touchmove,touchend
await page.touchscreen.tap(box.x + box.width - 20, box.y + box.height / 2)
// 使用鼠标模拟滑动手势(在模拟设备上,触摸事件由鼠标事件合成)
await card.hover({ position: { x: box.width - 20, y: box.height / 2 } })
await page.mouse.down()
await page.mouse.move(box.x - 100, box.y + box.height / 2, { steps: 10 })
await page.mouse.up()
await expect(card).toBeHidden()
}
})
test("long press to open context menu", async ({ page }) => {
await page.goto("/files")
const file = page.getByRole("listitem").filter({ hasText: "Report.pdf" })
// 长按:派发 pointerdown,等待,然后 pointerup
await file.click({ delay: 800 }) // delay 参数以毫秒为单位模拟长按
await expect(page.getByRole("menu")).toBeVisible()
await page.getByRole("menuitem", { name: "Delete" }).click()
})
test("pinch to zoom on a map", async ({ page }) => {
await page.goto("/map")
// 捏合缩放需要通过 JavaScript 派发多点触摸事件
const mapElement = page.getByTestId("map-container")
await mapElement.evaluate((el) => {
// 模拟双指外扩(放大),两个触点向外移动
const center = { x: el.clientWidth / 2, y: el.clientHeight / 2 }
const touch1 = new Touch({
identifier: 0,
target: el,
clientX: center.x - 50,
clientY: center.y,
})
const touch2 = new Touch({
identifier: 1,
target: el,
clientX: center.x + 50,
clientY: center.y,
})
el.dispatchEvent(
new TouchEvent("touchstart", {
touches: [touch1, touch2],
changedTouches: [touch1, touch2],
bubbles: true,
})
)
const touch1Moved = new Touch({
identifier: 0,
target: el,
clientX: center.x - 120,
clientY: center.y,
})
const touch2Moved = new Touch({
identifier: 1,
target: el,
clientX: center.x + 120,
clientY: center.y,
})
el.dispatchEvent(
new TouchEvent("touchmove", {
touches: [touch1Moved, touch2Moved],
changedTouches: [touch1Moved, touch2Moved],
bubbles: true,
})
)
el.dispatchEvent(
new TouchEvent("touchend", {
touches: [],
changedTouches: [touch1Moved, touch2Moved],
bubbles: true,
})
)
})
// 验证缩放级别已更改
await expect(page.getByTestId("zoom-level")).not.toHaveText("1x")
})
JavaScript
// tests/touch-interactions.spec.js
const { test, expect, devices } = require("@playwright/test")
test.use({ ...devices["iPhone 14"] })
test("tap to select an item", async ({ page }) => {
await page.goto("/gallery")
await page.getByRole("img", { name: "Sunset photo" }).tap()
await expect(page.getByText("Selected: Sunset photo")).toBeVisible()
})
test("swipe to dismiss a card", async ({ page }) => {
await page.goto("/notifications")
const card = page.getByTestId("notification-card").first()
const box = await card.boundingBox()
if (box) {
await card.hover({ position: { x: box.width - 20, y: box.height / 2 } })
await page.mouse.down()
await page.mouse.move(box.x - 100, box.y + box.height / 2, { steps: 10 })
await page.mouse.up()
await expect(card).toBeHidden()
}
})
test("long press to open context menu", async ({ page }) => {
await page.goto("/files")
const file = page.getByRole("listitem").filter({ hasText: "Report.pdf" })
await file.click({ delay: 800 })
await expect(page.getByRole("menu")).toBeVisible()
await page.getByRole("menuitem", { name: "Delete" }).click()
})
4. 移动端特有 UI(Mobile-Specific UI)
使用时机:测试仅在移动端出现的 UI 组件——汉堡菜单、底部弹出面板、下拉刷新、粘性移动端页眉、浮动操作按钮。 避免时机:该组件在桌面端和移动端渲染完全一致。
TypeScript
// tests/mobile-ui.spec.ts
import { test, expect, devices } from "@playwright/test"
test.use({ ...devices["iPhone 14"] })
test("hamburger menu opens and closes", async ({ page }) => {
await page.goto("/")
const menuButton = page.getByRole("button", { name: "Menu" })
const nav = page.getByRole("navigation", { name: "Main" })
// 菜单初始为关闭状态
await expect(nav).toBeHidden()
// 打开菜单
await menuButton.click()
await expect(nav).toBeVisible()
// 验证所有导航项可见
await expect(nav.getByRole("link", { name: "Home" })).toBeVisible()
await expect(nav.getByRole("link", { name: "Products" })).toBeVisible()
await expect(nav.getByRole("link", { name: "Account" })).toBeVisible()
// 点击外部(遮罩层)关闭菜单
await page.getByTestId("menu-overlay").click()
await expect(nav).toBeHidden()
})
test("bottom sheet slides up on mobile", async ({ page }) => {
await page.goto("/products/1")
await page.getByRole("button", { name: "Add to cart" }).click()
// 底部弹出面板显示购物车摘要
const bottomSheet = page.getByTestId("bottom-sheet")
await expect(bottomSheet).toBeVisible()
await expect(bottomSheet).toContainText("Added to cart")
// 向下滑动关闭
const box = await bottomSheet.boundingBox()
if (box) {
const startX = box.x + box.width / 2
const startY = box.y + 20
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX, startY + 300, { steps: 10 })
await page.mouse.up()
}
await expect(bottomSheet).toBeHidden()
})
test("pull to refresh reloads content", async ({ page }) => {
await page.goto("/feed")
// 保存第一个条目的初始文本
const firstItem = page.getByRole("listitem").first()
const initialText = await firstItem.textContent()
// 下拉刷新:从可滚动区域顶部向下滑动
const feed = page.getByTestId("feed-container")
const box = await feed.boundingBox()
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + 10)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2, box.y + 250, { steps: 15 })
await page.mouse.up()
}
// 等待刷新指示器出现,然后内容重新加载
await expect(page.getByTestId("refresh-spinner")).toBeVisible()
await expect(page.getByTestId("refresh-spinner")).toBeHidden()
})
test("sticky mobile header remains visible on scroll", async ({ page }) => {
await page.goto("/products")
const header = page.getByRole("banner")
// 大幅向下滚动
await page.evaluate(() => window.scrollTo(0, 2000))
// 页眉应保持可见(粘性定位)
await expect(header).toBeVisible()
await expect(header).toBeInViewport()
})
JavaScript
// tests/mobile-ui.spec.js
const { test, expect, devices } = require("@playwright/test")
test.use({ ...devices["iPhone 14"] })
test("hamburger menu opens and closes", async ({ page }) => {
await page.goto("/")
const menuButton = page.getByRole("button", { name: "Menu" })
const nav = page.getByRole("navigation", { name: "Main" })
await expect(nav).toBeHidden()
await menuButton.click()
await expect(nav).toBeVisible()
await expect(nav.getByRole("link", { name: "Home" })).toBeVisible()
await expect(nav.getByRole("link", { name: "Products" })).toBeVisible()
await expect(nav.getByRole("link", { name: "Account" })).toBeVisible()
await page.getByTestId("menu-overlay").click()
await expect(nav).toBeHidden()
})
test("bottom sheet slides up on mobile", async ({ page }) => {
await page.goto("/products/1")
await page.getByRole("button", { name: "Add to cart" }).click()
const bottomSheet = page.getByTestId("bottom-sheet")
await expect(bottomSheet).toBeVisible()
await expect(bottomSheet).toContainText("Added to cart")
const box = await bottomSheet.boundingBox()
if (box) {
const startX = box.x + box.width / 2
const startY = box.y + 20
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX, startY + 300, { steps: 10 })
await page.mouse.up()
}
await expect(bottomSheet).toBeHidden()
})
test("sticky mobile header remains visible on scroll", async ({ page }) => {
await page.goto("/products")
const header = page.getByRole("banner")
await page.evaluate(() => window.scrollTo(0, 2000))
await expect(header).toBeVisible()
await expect(header).toBeInViewport()
})
5. 地理位置(Geolocation)
使用时机:测试与位置相关的功能——门店查找器、配送区域、天气组件、基于位置的定价。 避免时机:该功能未使用 Geolocation API。如果是基于 IP 的定位,则应模拟 API 响应。
TypeScript
// playwright.config.ts -- 为每个项目设置地理位置
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./tests",
projects: [
{
name: "mobile-nyc",
use: {
...devices["iPhone 14"],
geolocation: { latitude: 40.7128, longitude: -74.006 },
permissions: ["geolocation"],
},
},
{
name: "mobile-london",
use: {
...devices["iPhone 14"],
geolocation: { latitude: 51.5074, longitude: -0.1278 },
permissions: ["geolocation"],
locale: "en-GB",
timezoneId: "Europe/London",
},
},
],
})
// tests/store-locator.spec.ts
import { test, expect } from "@playwright/test"
test("shows nearby stores based on geolocation", async ({ page, context }) => {
// 地理位置已通过上述项目配置设置。
// 若要在特定测试中覆盖:
await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 }) // Los Angeles
await page.goto("/store-locator")
await page.getByRole("button", { name: "Use my location" }).click()
// 验证应用使用了模拟的位置
await expect(page.getByText("Stores near Los Angeles")).toBeVisible()
await expect(page.getByRole("listitem")).toHaveCount(5) // 最近的 5 家门店
})
test("geolocation updates in real time", async ({ page, context }) => {
await context.setGeolocation({ latitude: 40.7128, longitude: -74.006 })
await page.goto("/delivery-tracker")
await expect(page.getByText("New York")).toBeVisible()
// 模拟用户移动到新位置
await context.setGeolocation({ latitude: 40.758, longitude: -73.9855 }) // Times Square
// 触发位置刷新(应用特定行为)
await page.getByRole("button", { name: "Refresh location" }).click()
await expect(page.getByText("Times Square")).toBeVisible()
})
test("handles geolocation permission denied", async ({ page, context }) => {
// 清除地理位置权限以模拟拒绝
await context.clearPermissions()
await page.goto("/store-locator")
await page.getByRole("button", { name: "Use my location" }).click()
// 应用应显示后备 UI
await expect(page.getByText("Location access denied")).toBeVisible()
await expect(page.getByLabel("Enter your zip code")).toBeVisible()
})
JavaScript
// tests/store-locator.spec.js
const { test, expect } = require("@playwright/test")
test("shows nearby stores based on geolocation", async ({ page, context }) => {
await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 })
await page.goto("/store-locator")
await page.getByRole("button", { name: "Use my location" }).click()
await expect(page.getByText("Stores near Los Angeles")).toBeVisible()
await expect(page.getByRole("listitem")).toHaveCount(5)
})
test("geolocation updates in real time", async ({ page, context }) => {
await context.setGeolocation({ latitude: 40.7128, longitude: -74.006 })
await page.goto("/delivery-tracker")
await expect(page.getByText("New York")).toBeVisible()
await context.setGeolocation({ latitude: 40.758, longitude: -73.9855 })
await page.getByRole("button", { name: "Refresh location" }).click()
await expect(page.getByText("Times Square")).toBeVisible()
})
test("handles geolocation permission denied", async ({ page, context }) => {
await context.clearPermissions()
await page.goto("/store-locator")
await page.getByRole("button", { name: "Use my location" }).click()
await expect(page.getByText("Location access denied")).toBeVisible()
await expect(page.getByLabel("Enter your zip code")).toBeVisible()
})
6. 多项目响应式测试(Multi-Project Responsive Testing)
使用时机:在桌面端和移动端浏览器上并行运行相同的测试。适用于响应式应用的标准方案。 避免时机:你的应用仅限桌面端或仅限移动端。
TypeScript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
projects: [
// ── 桌面端 ────────────────────────────────────────────
{
name: "desktop-chrome",
use: { ...devices["Desktop Chrome"] },
},
{
name: "desktop-firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "desktop-safari",
use: { ...devices["Desktop Safari"] },
},
// ── 移动端 ─────────────────────────────────────────────
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
{
name: "mobile-safari",
use: { ...devices["iPhone 14"] },
},
// ── 平板端 ─────────────────────────────────────────────
{
name: "tablet",
use: { ...devices["iPad Pro 11"] },
},
],
})
// tests/responsive-checkout.spec.ts
import { test, expect } from "@playwright/test"
test("checkout works across all viewports", async ({ page, isMobile }) => {
await page.goto("/products")
// 添加商品——在所有视口上操作相同
await page.getByRole("button", { name: "Add to cart" }).first().click()
// 导航至购物车
if (isMobile) {
// 移动端:购物车链接可能在汉堡菜单或底部导航中
await page.getByRole("link", { name: "Cart" }).click()
} else {
// 桌面端:购物车图标在页眉中
await page.getByRole("link", { name: /Cart \(\d+\)/ }).click()
}
await page.waitForURL("**/cart")
await expect(page.getByRole("heading", { name: "Your cart" })).toBeVisible()
// 进入结算
await page.getByRole("link", { name: "Checkout" }).click()
await page.waitForURL("**/checkout")
// 填写表单——所有视口上的字段相同
await page.getByLabel("Email").fill("test@example.com")
await page.getByLabel("Card number").fill("4242424242424242")
await page.getByRole("button", { name: "Pay now" }).click()
await expect(page.getByText("Order confirmed")).toBeVisible()
})
# 仅运行桌面端项目
npx playwright test --project=desktop-chrome --project=desktop-firefox --project=desktop-safari
# 仅运行移动端项目
npx playwright test --project=mobile-chrome --project=mobile-safari
# 在所有项目上运行特定测试文件
npx playwright test tests/responsive-checkout.spec.ts
# CI 优化:PR 上运行移动端 + desktop-chrome,主分支运行所有浏览器
# 在 CI 配置中:
# PR: npx playwright test --project=desktop-chrome --project=mobile-chrome --project=mobile-safari
# Main: npx playwright test
JavaScript
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")
module.exports = defineConfig({
testDir: "./tests",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
projects: [
{
name: "desktop-chrome",
use: { ...devices["Desktop Chrome"] },
},
{
name: "desktop-firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "desktop-safari",
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 7"] },
},
{
name: "mobile-safari",
use: { ...devices["iPhone 14"] },
},
{
name: "tablet",
use: { ...devices["iPad Pro 11"] },
},
],
})
// tests/responsive-checkout.spec.js
const { test, expect } = require("@playwright/test")
test("checkout works across all viewports", async ({ page, isMobile }) => {
await page.goto("/products")
await page.getByRole("button", { name: "Add to cart" }).first().click()
if (isMobile) {
await page.getByRole("link", { name: "Cart" }).click()
} else {
await page.getByRole("link", { name: /Cart \(\d+\)/ }).click()
}
await page.waitForURL("**/cart")
await expect(page.getByRole("heading", { name: "Your cart" })).toBeVisible()
await page.getByRole("link", { name: "Checkout" }).click()
await page.waitForURL("**/checkout")
await page.getByLabel("Email").fill("test@example.com")
await page.getByLabel("Card number").fill("4242424242424242")
await page.getByRole("button", { name: "Pay now" }).click()
await expect(page.getByText("Order confirmed")).toBeVisible()
})
7. 响应式断点测试(Responsive Breakpoint Testing)
使用时机:在每个主要的 CSS 断点上系统地验证布局行为。最适合与视觉回归测试结合使用。
避免时机:你只需要一两个视口尺寸——直接使用 test.use() 覆盖即可。
TypeScript
// tests/fixtures/responsive.fixture.ts
import { test as base, expect } from "@playwright/test"
type Breakpoint = {
name: string
width: number
height: number
isMobileExpected: boolean
}
const BREAKPOINTS: Breakpoint[] = [
{ name: "xs", width: 320, height: 568, isMobileExpected: true },
{ name: "sm", width: 640, height: 800, isMobileExpected: true },
{ name: "md", width: 768, height: 1024, isMobileExpected: false },
{ name: "lg", width: 1024, height: 768, isMobileExpected: false },
{ name: "xl", width: 1280, height: 800, isMobileExpected: false },
{ name: "2xl", width: 1440, height: 900, isMobileExpected: false },
]
// 自定义 fixture,在每个断点上运行测试
export const test = base.extend<{ forEachBreakpoint: void }>({
forEachBreakpoint: [
async ({ page }, use, testInfo) => {
// 此 fixture 用作标记——实际的断点迭代通过 test.describe 完成
await use()
},
{ auto: false },
],
})
// 辅助函数:创建一个测试每个断点的 describe 块
export function describeBreakpoints(title: string, fn: (bp: Breakpoint) => void) {
for (const bp of BREAKPOINTS) {
base.describe(`${title} @ ${bp.name} (${bp.width}px)`, () => {
base.use({ viewport: { width: bp.width, height: bp.height } })
fn(bp)
})
}
}
export { expect, BREAKPOINTS }
// tests/responsive-grid.spec.ts
import { test, expect, describeBreakpoints } from "./fixtures/responsive.fixture"
describeBreakpoints("product grid", (bp) => {
test("shows correct number of columns", async ({ page }) => {
await page.goto("/products")
const grid = page.getByTestId("product-grid")
const columns = await grid.evaluate((el) => {
const style = window.getComputedStyle(el)
return style.gridTemplateColumns.split(" ").length
})
if (bp.width < 640) {
expect(columns).toBe(1) // xs:单列
} else if (bp.width < 1024) {
expect(columns).toBe(2) // sm-md:两列
} else {
expect(columns).toBe(4) // lg+:四列
}
})
test("no content overflow", async ({ page }) => {
await page.goto("/products")
const hasOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
)
expect(hasOverflow).toBe(false)
})
})
JavaScript
// tests/fixtures/responsive.fixture.js
const { test: base, expect } = require("@playwright/test")
const BREAKPOINTS = [
{ name: "xs", width: 320, height: 568, isMobileExpected: true },
{ name: "sm", width: 640, height: 800, isMobileExpected: true },
{ name: "md", width: 768, height: 1024, isMobileExpected: false },
{ name: "lg", width: 1024, height: 768, isMobileExpected: false },
{ name: "xl", width: 1280, height: 800, isMobileExpected: false },
{ name: "2xl", width: 1440, height: 900, isMobileExpected: false },
]
function describeBreakpoints(title, fn) {
for (const bp of BREAKPOINTS) {
base.describe(`${title} @ ${bp.name} (${bp.width}px)`, () => {
base.use({ viewport: { width: bp.width, height: bp.height } })
fn(bp)
})
}
}
module.exports = { test: base, expect, BREAKPOINTS, describeBreakpoints }
// tests/responsive-grid.spec.js
const { test, expect, describeBreakpoints } = require("./fixtures/responsive.fixture")
describeBreakpoints("product grid", (bp) => {
test("shows correct number of columns", async ({ page }) => {
await page.goto("/products")
const grid = page.getByTestId("product-grid")
const columns = await grid.evaluate((el) => {
const style = window.getComputedStyle(el)
return style.gridTemplateColumns.split(" ").length
})
if (bp.width < 640) {
expect(columns).toBe(1)
} else if (bp.width < 1024) {
expect(columns).toBe(2)
} else {
expect(columns).toBe(4)
}
})
test("no content overflow", async ({ page }) => {
await page.goto("/products")
const hasOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
)
expect(hasOverflow).toBe(false)
})
})
8. 屏幕方向测试(Orientation Testing)
使用时机:测试竖屏与横屏布局。对于平板应用、媒体播放器、仪表盘以及任何根据屏幕方向调整布局的应用至关重要。 避免时机:你的应用不根据屏幕方向改变布局(仅对宽度做响应式适配——使用自定义视口测试即可)。
TypeScript
// tests/orientation.spec.ts
import { test, expect, devices } from "@playwright/test"
test.describe("iPad orientation changes", () => {
test.use({ ...devices["iPad Pro 11"] })
test("dashboard adjusts layout in landscape", async ({ page }) => {
// 以竖屏模式开始(834x1194——iPad Pro 11 的默认值)
await page.goto("/dashboard")
// 竖屏:侧边栏折叠,图表垂直堆叠
await expect(page.getByTestId("sidebar")).toHaveCSS("position", "fixed")
// 通过更改视口切换到横屏
await page.setViewportSize({ width: 1194, height: 834 })
// 横屏:侧边栏内联显示,图表并排排列
await expect(page.getByTestId("sidebar")).toHaveCSS("position", "relative")
})
test("video player switches to fullscreen in landscape", async ({ page }) => {
await page.goto("/videos/1")
// 竖屏:视频在内联播放器中
const player = page.getByTestId("video-player")
const portraitBox = await player.boundingBox()
// 切换到横屏
await page.setViewportSize({ width: 1194, height: 834 })
// 视频应展开以填满宽度
const landscapeBox = await player.boundingBox()
expect(landscapeBox!.width).toBeGreaterThan(portraitBox!.width)
})
})
test.describe("iPhone orientation changes", () => {
test("portrait mode", () => {
test.use({ ...devices["iPhone 14"] }) // 390x844
})
test("landscape mode", () => {
test.use({ ...devices["iPhone 14 landscape"] }) // 844x390
})
})
// 使用循环测试两种屏幕方向
const orientations = [
{ name: "portrait", device: devices["iPad Pro 11"] },
{ name: "landscape", device: devices["iPad Pro 11 landscape"] },
]
for (const { name, device } of orientations) {
test.describe(`form usability in ${name}`, () => {
test.use({ ...device })
test("all form fields are visible without scrolling", async ({ page }) => {
await page.goto("/contact")
const form = page.getByRole("form")
await expect(form).toBeVisible()
// 检查表单是否适合视口
const formBox = await form.boundingBox()
const viewport = page.viewportSize()!
expect(formBox!.width).toBeLessThanOrEqual(viewport.width)
})
})
}
JavaScript
// tests/orientation.spec.js
const { test, expect, devices } = require("@playwright/test")
test.describe("iPad orientation changes", () => {
test.use({ ...devices["iPad Pro 11"] })
test("dashboard adjusts layout in landscape", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.getByTestId("sidebar")).toHaveCSS("position", "fixed")
await page.setViewportSize({ width: 1194, height: 834 })
await expect(page.getByTestId("sidebar")).toHaveCSS("position", "relative")
})
test("video player switches to fullscreen in landscape", async ({ page }) => {
await page.goto("/videos/1")
const player = page.getByTestId("video-player")
const portraitBox = await player.boundingBox()
await page.setViewportSize({ width: 1194, height: 834 })
const landscapeBox = await player.boundingBox()
expect(landscapeBox.width).toBeGreaterThan(portraitBox.width)
})
})
const orientations = [
{ name: "portrait", device: devices["iPad Pro 11"] },
{ name: "landscape", device: devices["iPad Pro 11 landscape"] },
]
for (const { name, device } of orientations) {
test.describe(`form usability in ${name}`, () => {
test.use({ ...device })
test("all form fields are visible without scrolling", async ({ page }) => {
await page.goto("/contact")
const form = page.getByRole("form")
await expect(form).toBeVisible()
const formBox = await form.boundingBox()
const viewport = page.viewportSize()
expect(formBox.width).toBeLessThanOrEqual(viewport.width)
})
})
}
9. 移动端性能(Mobile Performance)
使用时机:模拟真实移动端网络条件——慢速 3G 网络、低性能 CPU。对于测试加载状态、骨架屏和超时处理至关重要。 避免时机:仅测试功能正确性时。性能节流会显著减慢测试套件的运行速度。
TypeScript
// tests/mobile-performance.spec.ts
import { test, expect, devices } from "@playwright/test"
test.describe("mobile on slow network", () => {
test.use({ ...devices["Pixel 7"] })
test("shows skeleton loader on slow 3G", async ({ page }) => {
// 获取用于网络节流的 CDP 会话(仅 Chromium)
const cdpSession = await page.context().newCDPSession(page)
// 模拟慢速 3G:500kbps 下载,500kbps 上传,400ms RTT
await cdpSession.send("Network.emulateNetworkConditions", {
offline: false,
downloadThroughput: (500 * 1024) / 8, // 字节/秒
uploadThroughput: (500 * 1024) / 8,
latency: 400, // 毫秒
})
await page.goto("/products")
// 内容加载时应显示骨架屏
await expect(page.getByTestId("product-skeleton")).toBeVisible()
// 最终,真实内容替换骨架屏
await expect(page.getByRole("listitem")).toHaveCount(12, { timeout: 30_000 })
await expect(page.getByTestId("product-skeleton")).toBeHidden()
})
test("shows offline message when network drops", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
// 完全断开网络
await page.context().setOffline(true)
// 尝试导航——应显示离线消息
await page.getByRole("link", { name: "Settings" }).click()
await expect(page.getByText(/you.*offline|no.*connection/i)).toBeVisible()
// 恢复网络
await page.context().setOffline(false)
await page.reload()
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
})
test("images lazy-load on slow connections", async ({ page }) => {
const cdpSession = await page.context().newCDPSession(page)
await cdpSession.send("Network.emulateNetworkConditions", {
offline: false,
downloadThroughput: (1000 * 1024) / 8,
uploadThroughput: (500 * 1024) / 8,
latency: 200,
})
await page.goto("/gallery")
// 视口以下的图片应具有 loading="lazy" 属性,且不会立即加载
const belowFoldImages = page.locator('img[loading="lazy"]')
const count = await belowFoldImages.count()
expect(count).toBeGreaterThan(0)
// 滚动以触发懒加载
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await expect(belowFoldImages.first()).toHaveJSProperty("complete", true)
})
test("CPU throttling shows performance impact", async ({ page }) => {
const cdpSession = await page.context().newCDPSession(page)
// 模拟 4 倍 CPU 降速(典型中端移动设备)
await cdpSession.send("Emulation.setCPUThrottlingRate", { rate: 4 })
await page.goto("/")
// 测量可交互时间(TTI)
const tti = await page.evaluate(() => {
const entries = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[]
return entries[0]?.domInteractive ?? 0
})
// 断言 TTI 在节流移动设备的可接受范围内
expect(tti).toBeLessThan(5000)
// 重置节流
await cdpSession.send("Emulation.setCPUThrottlingRate", { rate: 1 })
})
})
JavaScript
// tests/mobile-performance.spec.js
const { test, expect, devices } = require("@playwright/test")
test.describe("mobile on slow network", () => {
test.use({ ...devices["Pixel 7"] })
test("shows skeleton loader on slow 3G", async ({ page }) => {
const cdpSession = await page.context().newCDPSession(page)
await cdpSession.send("Network.emulateNetworkConditions", {
offline: false,
downloadThroughput: (500 * 1024) / 8,
uploadThroughput: (500 * 1024) / 8,
latency: 400,
})
await page.goto("/products")
await expect(page.getByTestId("product-skeleton")).toBeVisible()
await expect(page.getByRole("listitem")).toHaveCount(12, { timeout: 30_000 })
await expect(page.getByTestId("product-skeleton")).toBeHidden()
})
test("shows offline message when network drops", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
await page.context().setOffline(true)
await page.getByRole("link", { name: "Settings" }).click()
await expect(page.getByText(/you.*offline|no.*connection/i)).toBeVisible()
await page.context().setOffline(false)
await page.reload()
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
})
test("CPU throttling shows performance impact", async ({ page }) => {
const cdpSession = await page.context().newCDPSession(page)
await cdpSession.send("Emulation.setCPUThrottlingRate", { rate: 4 })
await page.goto("/")
const tti = await page.evaluate(() => {
const entries = performance.getEntriesByType("navigation")
return entries[0]?.domInteractive ?? 0
})
expect(tti).toBeLessThan(5000)
await cdpSession.send("Emulation.setCPUThrottlingRate", { rate: 1 })
})
})
10. PWA 移动端测试(PWA Mobile Testing)
使用时机:测试渐进式 Web 应用(PWA)功能——Service Worker、安装提示、离线模式、移动端推送通知。 避免时机:你的应用不是 PWA。对于一般的离线测试,请参见模式 9 中的网络离线模式。
TypeScript
// tests/pwa-mobile.spec.ts
import { test, expect, devices } from "@playwright/test"
test.use({ ...devices["Pixel 7"] })
test("service worker registers and caches resources", async ({ page }) => {
await page.goto("/")
// 等待 Service Worker 注册完成
const swRegistered = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready
return registration.active?.state === "activated"
})
expect(swRegistered).toBe(true)
// 验证关键资源已被缓存
const cacheContents = await page.evaluate(async () => {
const cache = await caches.open("app-shell-v1")
const keys = await cache.keys()
return keys.map((req) => new URL(req.url).pathname)
})
expect(cacheContents).toContain("/")
expect(cacheContents).toContain("/offline.html")
})
test("app works offline after initial load", async ({ page }) => {
// 首先在线加载应用——Service Worker 缓存资源
await page.goto("/")
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible()
// 等待 Service Worker 完成缓存
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready
// 等待 Service Worker 激活
if (registration.active?.state !== "activated") {
await new Promise<void>((resolve) => {
registration.active?.addEventListener("statechange", () => {
if (registration.active?.state === "activated") resolve()
})
})
}
})
// 切换到离线状态
await page.context().setOffline(true)
// 导航到已缓存的页面
await page.goto("/about")
// 缓存的页面应正常渲染
await expect(page.getByRole("heading", { name: "About" })).toBeVisible()
})
test("offline fallback page shows when uncached route is accessed", async ({ page }) => {
await page.goto("/")
// 等待 Service Worker
await page.evaluate(() => navigator.serviceWorker.ready)
// 切换到离线状态并导航到未缓存的路径
await page.context().setOffline(true)
await page.goto("/uncached-page")
// 应显示离线后备页面
await expect(page.getByText(/offline|no.*connection/i)).toBeVisible()
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})
test("app prompts to install on mobile", async ({ page, context }) => {
// 监听 beforeinstallprompt 事件
await page.goto("/")
const installPromptFired = await page.evaluate(() => {
return new Promise<boolean>((resolve) => {
// 检查事件是否已触发
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault() // 阻止自动弹出提示
resolve(true)
})
// 如果满足 PWA 条件,事件会自动触发
// 如果 5 秒后未触发,则超时
setTimeout(() => resolve(false), 5000)
})
})
// 注意:beforeinstallprompt 仅在 Chromium 中触发,且需满足 PWA 条件
// 此测试验证事件处理程序,而非浏览器 Chrome 界面
if (installPromptFired) {
// 应用应显示自定义安装横幅
await expect(page.getByTestId("install-banner")).toBeVisible()
await page.getByRole("button", { name: "Install app" }).click()
}
})
test("push notification permission request on mobile", async ({ page, context }) => {
// 授予通知权限
await context.grantPermissions(["notifications"])
await page.goto("/settings/notifications")
await page.getByRole("button", { name: "Enable notifications" }).click()
// 验证应用已注册推送
const pushSubscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
return subscription !== null
})
expect(pushSubscription).toBe(true)
await expect(page.getByText("Notifications enabled")).toBeVisible()
})
JavaScript
// tests/pwa-mobile.spec.js
const { test, expect, devices } = require("@playwright/test")
test.use({ ...devices["Pixel 7"] })
test("service worker registers and caches resources", async ({ page }) => {
await page.goto("/")
const swRegistered = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready
return registration.active?.state === "activated"
})
expect(swRegistered).toBe(true)
const cacheContents = await page.evaluate(async () => {
const cache = await caches.open("app-shell-v1")
const keys = await cache.keys()
return keys.map((req) => new URL(req.url).pathname)
})
expect(cacheContents).toContain("/")
expect(cacheContents).toContain("/offline.html")
})
test("app works offline after initial load", async ({ page }) => {
await page.goto("/")
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible()
await page.evaluate(async () => {
await navigator.serviceWorker.ready
})
await page.context().setOffline(true)
await page.goto("/about")
await expect(page.getByRole("heading", { name: "About" })).toBeVisible()
})
test("offline fallback page shows when uncached route is accessed", async ({ page }) => {
await page.goto("/")
await page.evaluate(() => navigator.serviceWorker.ready)
await page.context().setOffline(true)
await page.goto("/uncached-page")
await expect(page.getByText(/offline|no.*connection/i)).toBeVisible()
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible()
})
test("push notification permission request on mobile", async ({ page, context }) => {
await context.grantPermissions(["notifications"])
await page.goto("/settings/notifications")
await page.getByRole("button", { name: "Enable notifications" }).click()
const pushSubscription = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
return subscription !== null
})
expect(pushSubscription).toBe(true)
await expect(page.getByText("Notifications enabled")).toBeVisible()
})
决策指南(Decision Guide)
| 问题 | 答案 | 方法 |
|---|---|---|
| 需要测试应用在 iPhone 14 上的显示效果? | 是 | 使用 devices['iPhone 14']——一次获得视口、UA、触屏、缩放因子 |
| 需要测试 768px 处的 CSS 断点? | 是 | 使用 test.use({ viewport: { width: 768, height: 1024 } })——更简单,无需更改 UA |
| 需要同时测试竖屏和横屏? | 是 | 使用命名设备 + 横屏变体:devices['iPad Pro 11'] 和 devices['iPad Pro 11 landscape'] |
| 需要真实的移动端性能测试? | 是 | 使用 CDP Network.emulateNetworkConditions + Emulation.setCPUThrottlingRate(仅 Chromium) |
| 需要测试触摸手势? | 是 | 使用 hasTouch: true 的设备配置,然后使用 tap(),用鼠标手势模拟滑动 |
| 需要测试地理位置? | 是 | 在上下文选项中设置 geolocation 和 permissions: ['geolocation'] |
| 需要像素级完美的移动端测试? | 否——使用真实设备 | Playwright 模拟是近似的;字体渲染和原生 UI 与真实硬件不同 |
| 默认应测试哪些设备? | 从 3 个开始 | Desktop Chrome + Pixel 7(Android)+ iPhone 14(iOS)。如果应用有平板布局,则添加平板。 |
| 何时添加更多设备项目? | 当有 Bug 逃逸时 | 如果用户报告特定设备的 Bug,则永久添加该设备配置 |
| 每次 PR 都运行所有设备? | 否 | PR 上运行桌面端 + 一个移动端。主分支合并时运行所有设备。 |
| 设备模拟 vs 自定义视口? | 取决于目标 | 模拟:测试真实设备行为。自定义视口:测试 CSS 断点。 |
| 我应该测试每一种屏幕尺寸吗? | 否 | 测试你实际的 CSS 断点,加上最小(320px)和最大(1440px+)支持尺寸 |
反模式(Anti-Patterns)
| 不要这样做 | 问题 | 改为这样做 |
|---|---|---|
| 仅在 1280x720 桌面端测试 | 遗漏所有移动端和平板端布局 Bug;大多数网络流量来自移动端 | 至少添加 Pixel 7 和 iPhone 14 项目 |
| 使用设备模拟进行像素级完美测试 | 模拟只是近似真实设备——字体渲染、次像素抗锯齿和原生浏览器 Chrome 界面存在差异 | 使用模拟进行布局和交互测试;使用真实设备实验室(BrowserStack、Sauce Labs)进行像素级完美验证 |
| 忽略触摸交互 | 移动端用户会点击、滑动和长按;仅 click() 可能不会触发触摸特有的事件处理程序 |
在触屏设备配置上使用 tap();用鼠标移动序列测试滑动手势 |
| 不测试屏幕方向变化 | 用户会旋转平板和手机;横屏下布局可能出错 | 使用设备变体或 page.setViewportSize() 同时测试竖屏和横屏 |
使用 page.setViewportSize() 但未设置 hasTouch: true |
视口很小,但浏览器报告不支持触屏——@media (hover: hover) 仍然匹配桌面端 |
使用设备配置或在 test.use() 中显式设置 hasTouch: true |
在每个测试中硬编码 isMobile 检查 |
逻辑重复,难以维护;测试变得脆弱 | 使用页面对象,将移动端与桌面端行为抽象在方法之后 |
仅用 visibility: hidden 检查来测试移动端布局 |
元素可能仍占据空间;CSS 可能使用 display: none 或 transform: translateX(-100%) |
使用 toBeHidden()(检查是否不可见)或使用 toHaveCSS('display', 'none') 检查特定 CSS 行为 |
| 在每次 CI 运行中运行 10+ 个设备项目 | 巨大的 CI 时间和成本,超出 3-4 个设备后回报递减 | 选择代表性设备:一部 Android 手机、一部 iPhone、一部平板和桌面端浏览器 |
| 在每个测试中进行网络节流 | 大幅拖慢整个测试套件,覆盖率提升微乎其微 | 创建一个独立的 mobile-perf 项目,或用 @slow 标记性能测试并单独运行 |
在方向更改后使用 await page.waitForTimeout(2000) |
任意延迟;布局重排可能更快或更慢 | await expect(locator).toHaveCSS('property', 'value')——断言会自动重试直到布局稳定 |
| 测试地理位置时不设置权限 | 浏览器静默阻止地理位置;测试看不到位置数据,错误地通过 | 始终在设置 geolocation 坐标的同时设置 permissions: ['geolocation'] |
| 在 Firefox 或 WebKit 上使用 CDP 节流 | CDP 会话仅限 Chromium;测试会在其他浏览器上抛出异常 | 使用浏览器名称检查保护 CDP 调用,或仅对性能测试使用 Chromium 项目 |
故障排查(Troubleshooting)
tap() 抛出 "Page.tap: Not supported" 错误
原因:浏览器上下文创建时未设置 hasTouch: true。设备配置会自动设置此属性,但自定义视口配置不会。
// 错误——自定义视口未启用触屏支持
test.use({ viewport: { width: 375, height: 667 } })
test("tap fails", async ({ page }) => {
await page.goto("/")
await page.getByRole("button").tap() // 错误:Not supported
})
// 修复——显式启用触屏
test.use({
viewport: { width: 375, height: 667 },
hasTouch: true,
})
test("tap works", async ({ page }) => {
await page.goto("/")
await page.getByRole("button").tap() // 正常工作
})
// 或者使用包含 hasTouch 的设备配置
test.use({ ...devices["iPhone 14"] })
isMobile 始终为 false
原因:isMobile 由设备配置的 isMobile 属性设置,而非视口尺寸。自定义视口不会设置它。
// 此处 isMobile 为 false——未使用设备配置
test.use({ viewport: { width: 375, height: 667 } })
test("isMobile is false", async ({ page, isMobile }) => {
console.log(isMobile) // false
})
// 修复:使用设备配置,或显式设置 isMobile
test.use({
viewport: { width: 375, height: 667 },
isMobile: true,
hasTouch: true,
})
test("isMobile is true", async ({ page, isMobile }) => {
console.log(isMobile) // true
})
地理位置不起作用——位置保持默认值
原因:上下文选项中缺少 permissions: ['geolocation']。没有此权限,浏览器会静默拒绝 Geolocation API。
// 错误——已设置地理位置但未授予权限
test.use({
geolocation: { latitude: 40.7128, longitude: -74.006 },
// 缺少:permissions: ['geolocation']
})
// 修复
test.use({
geolocation: { latitude: 40.7128, longitude: -74.006 },
permissions: ["geolocation"],
})
CDP 会话在 Firefox/WebKit 上抛出异常
原因:page.context().newCDPSession(page) 仅适用于 Chromium。Firefox 和 WebKit 不支持 CDP。
// 错误——在 Firefox 和 WebKit 上会崩溃
test("throttle network", async ({ page }) => {
const cdp = await page.context().newCDPSession(page) // 非 Chromium 上抛出异常
})
// 修复——使用浏览器名称检查进行保护
test("throttle network", async ({ page, browserName }) => {
test.skip(browserName !== "chromium", "CDP throttling is Chromium-only")
const cdp = await page.context().newCDPSession(page)
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
downloadThroughput: (500 * 1024) / 8,
uploadThroughput: (500 * 1024) / 8,
latency: 400,
})
})
// 替代方案——使用 context.setOffline(),在所有浏览器上均可工作
test("offline mode", async ({ page }) => {
await page.context().setOffline(true) // 所有浏览器均可用
})
视口更改未触发 CSS 媒体查询
原因:page.setViewportSize() 会更改视口,但在某些依赖基于 JavaScript 的响应式逻辑(而非 CSS 媒体查询)的框架中,不会触发 resize 或 orientationchange 事件。
// 如果 setViewportSize 后媒体查询未触发,手动派发
await page.setViewportSize({ width: 375, height: 667 })
await page.evaluate(() => window.dispatchEvent(new Event("resize")))
// 更好:在 test.use() 中设置视口,使其在页面加载前生效
test.use({ viewport: { width: 375, height: 667 } })
Service Worker 在测试中未注册
原因:Service Worker 需要 HTTPS 或 localhost。Playwright 默认的 baseURL 为 http://localhost:3000 可以工作,但其他 HTTP 来源不行。
// 确保你的 baseURL 是 localhost 或 HTTPS
// playwright.config.ts
export default defineConfig({
use: {
baseURL: "http://localhost:3000", // 对 Service Worker 有效
// baseURL: 'http://192.168.1.100:3000', // 不起作用——不是 localhost
},
})
// 如果针对非 localhost 服务器进行测试,使用 HTTPS
// 或在上下文选项中使用 serviceWorkers: 'allow'(默认值)
相关文档(Related)
- core/configuration.md——使用设备配置和多项目配置进行项目设置
- core/visual-regression.md——将响应式测试与跨视口的截图对比结合使用
- core/network-mocking.md——在移动端测试的同时模拟 API 响应
- core/service-workers-and-pwa.md——超越移动端上下文的深入 PWA 测试
- core/performance-testing.md——全面的性能测试,包括移动端指标
- core/browser-apis.md——地理位置、权限及其他浏览器 API 的详细信息
- ci/projects-and-dependencies.md——用于响应式测试矩阵的高级多项目模式