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

60 KiB
Raw Permalink Blame History

Mobile and Responsive Testing(移动端与响应式测试)

使用时机:测试应用在手机、平板电脑以及不同视口尺寸下的表现。涵盖设备模拟、触摸交互、地理位置、屏幕方向变化以及移动端特有的 UI 模式。 前置要求core/configuration.mdcore/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,向左 touchmovetouchend
    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. 移动端特有 UIMobile-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)

    // 模拟慢速 3G500kbps 下载,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(),用鼠标手势模拟滑动
需要测试地理位置? 在上下文选项中设置 geolocationpermissions: ['geolocation']
需要像素级完美的移动端测试? 否——使用真实设备 Playwright 模拟是近似的;字体渲染和原生 UI 与真实硬件不同
默认应测试哪些设备? 从 3 个开始 Desktop Chrome + Pixel 7Android+ iPhone 14(iOS)。如果应用有平板布局,则添加平板。
何时添加更多设备项目? 当有 Bug 逃逸时 如果用户报告特定设备的 Bug,则永久添加该设备配置
每次 PR 都运行所有设备? PR 上运行桌面端 + 一个移动端。主分支合并时运行所有设备。
设备模拟 vs 自定义视口? 取决于目标 模拟:测试真实设备行为。自定义视口:测试 CSS 断点。
我应该测试每一种屏幕尺寸吗? 测试你实际的 CSS 断点,加上最小(320px)和最大(1440px+)支持尺寸

反模式(Anti-Patterns

不要这样做 问题 改为这样做
仅在 1280x720 桌面端测试 遗漏所有移动端和平板端布局 Bug;大多数网络流量来自移动端 至少添加 Pixel 7iPhone 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: nonetransform: 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 媒体查询)的框架中,不会触发 resizeorientationchange 事件。

// 如果 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 默认的 baseURLhttp://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