# Mobile and Responsive Testing(移动端与响应式测试) > **使用时机**:测试应用在手机、平板电脑以及不同视口尺寸下的表现。涵盖设备模拟、触摸交互、地理位置、屏幕方向变化以及移动端特有的 UI 模式。 > **前置要求**:[core/configuration.md](configuration.md)、[core/locators.md](locators.md) ## 快速参考(Quick Reference) ```typescript 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 ``` ```bash # 仅运行移动端项目 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 ```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"] }, }, ], }) ``` ```typescript // 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 ```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"] }, }, ], }) ``` ```javascript // 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 ```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") } }) } ``` ```typescript // 按文件设置视口覆盖(适用于该文件中的所有测试) 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 ```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") } }) } ``` ```javascript // 按文件设置视口覆盖 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 ```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 ```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 ```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 ```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 ```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", }, }, ], }) ``` ```typescript // 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 ```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 ```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"] }, }, ], }) ``` ```typescript // 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() }) ``` ```bash # 仅运行桌面端项目 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 ```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"] }, }, ], }) ``` ```javascript // 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 ```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 } ``` ```typescript // 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 ```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 } ``` ```javascript // 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 ```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 ```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 ```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 ```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 ```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((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((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 ```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`。设备配置会自动设置此属性,但自定义视口配置不会。 ```typescript // 错误——自定义视口未启用触屏支持 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` 属性设置,而非视口尺寸。自定义视口不会设置它。 ```typescript // 此处 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。 ```typescript // 错误——已设置地理位置但未授予权限 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。 ```typescript // 错误——在 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` 事件。 ```typescript // 如果 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 来源不行。 ```typescript // 确保你的 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](configuration.md)——使用设备配置和多项目配置进行项目设置 - [core/visual-regression.md](visual-regression.md)——将响应式测试与跨视口的截图对比结合使用 - [core/network-mocking.md](network-mocking.md)——在移动端测试的同时模拟 API 响应 - [core/service-workers-and-pwa.md](service-workers-and-pwa.md)——超越移动端上下文的深入 PWA 测试 - [core/performance-testing.md](performance-testing.md)——全面的性能测试,包括移动端指标 - [core/browser-apis.md](browser-apis.md)——地理位置、权限及其他浏览器 API 的详细信息 - [ci/projects-and-dependencies.md](../ci/projects-and-dependencies.md)——用于响应式测试矩阵的高级多项目模式