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

18 KiB
Raw Permalink Blame History

时钟与时间模拟

使用场景:测试依赖于时间的功能——倒计时、定时事件、过期日期、年龄限制、会话超时,或任何根据当前时间行为不同的 UI。Playwright 的 page.clock API 让你无需实时等待即可控制时间。 前置知识core/assertions-and-waiting.mdcore/configuration.md

快速参考

// 将时间冻结在特定时刻
await page.clock.install({ time: new Date("2025-03-15T10:00:00Z") })
await page.goto("/dashboard")

// 快进 5 分钟
await page.clock.fastForward("05:00")

// 将时间设置为特定时间点(跳转,不会逐秒走过)
await page.clock.setFixedTime(new Date("2025-12-31T23:59:59Z"))

// 从当前模拟时间点开始让时间正常流逝
await page.clock.resume()

核心概念page.clock.install() 会替换页面中的 DatesetTimeoutsetIntervalrequestAnimationFrame。请在 page.goto() 之前调用,这样页面从加载起就使用模拟时间。

模式

使用 install()setFixedTime() 冻结时间

适用场景:测试需要将时间固定在某个特定时刻——验证 UI 在特定日期/时间显示的内容。 避免场景:被测功能依赖于定时器触发(应改用 fastForward)。

TypeScript

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

test("仪表盘根据一天中的时间显示正确的问候语", async ({ page }) => {
  // 在导航前安装时钟
  await page.clock.install({ time: new Date("2025-06-15T08:30:00") })
  await page.goto("/dashboard")
  await expect(page.getByText("Good morning")).toBeVisible()

  // 跳到下午
  await page.clock.setFixedTime(new Date("2025-06-15T14:00:00"))
  await page.reload()
  await expect(page.getByText("Good afternoon")).toBeVisible()

  // 跳到晚上
  await page.clock.setFixedTime(new Date("2025-06-15T20:00:00"))
  await page.reload()
  await expect(page.getByText("Good evening")).toBeVisible()
})

test("订阅显示正确的过期状态", async ({ page }) => {
  // 将时间冻结在订阅有效期内
  await page.clock.install({ time: new Date("2025-06-01T12:00:00Z") })
  await page.goto("/account")

  await expect(page.getByTestId("subscription-status")).toHaveText("Active")
  await expect(page.getByTestId("days-remaining")).toContainText("29")

  // 跳到过期日期
  await page.clock.setFixedTime(new Date("2025-06-30T12:00:00Z"))
  await page.reload()

  await expect(page.getByTestId("subscription-status")).toHaveText("Expiring today")
})

test("内容在特定节日正确显示", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-12-25T10:00:00") })
  await page.goto("/")

  await expect(page.getByText("Happy Holidays")).toBeVisible()
  await expect(page.getByTestId("holiday-banner")).toBeVisible()
})

JavaScript

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

test("仪表盘根据一天中的时间显示正确的问候语", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-06-15T08:30:00") })
  await page.goto("/dashboard")
  await expect(page.getByText("Good morning")).toBeVisible()

  await page.clock.setFixedTime(new Date("2025-06-15T14:00:00"))
  await page.reload()
  await expect(page.getByText("Good afternoon")).toBeVisible()
})

test("订阅显示正确的过期状态", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-06-01T12:00:00Z") })
  await page.goto("/account")

  await expect(page.getByTestId("subscription-status")).toHaveText("Active")
})

使用 fastForward() 快进时间

适用场景:测试定时器、倒计时、防抖操作,或任何响应经过时间的功能。fastForward 会触发在指定时长内所有待处理的定时器。 避免场景:只需检查静态的时间相关显示内容——应改用 setFixedTime

TypeScript

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

test("倒计时归零", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-03-15T10:00:00Z") })
  await page.goto("/sale")

  // 促销倒计时从 2 小时开始
  await expect(page.getByTestId("countdown")).toContainText("2:00:00")

  // 快进 1 小时
  await page.clock.fastForward("01:00:00")
  await expect(page.getByTestId("countdown")).toContainText("1:00:00")

  // 快进剩余时间
  await page.clock.fastForward("01:00:00")
  await expect(page.getByTestId("countdown")).toContainText("0:00:00")
  await expect(page.getByText("Sale ended")).toBeVisible()
})

test("30 秒无操作后触发自动保存", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-03-15T10:00:00Z") })
  await page.goto("/editor")

  // 输入一些内容
  await page.getByRole("textbox", { name: "Content" }).fill("Draft content")

  // 快进超过自动保存间隔
  await page.clock.fastForward("00:30")

  await expect(page.getByText("Saved")).toBeVisible()
})

test("25 分钟后出现会话超时警告", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-03-15T10:00:00Z") })
  await page.goto("/dashboard")

  // 快进到警告出现前(25 分钟)
  await page.clock.fastForward("24:59")
  await expect(page.getByRole("dialog", { name: "Session timeout" })).not.toBeVisible()

  // 再过一分钟触发警告
  await page.clock.fastForward("00:01")
  await expect(page.getByRole("dialog", { name: "Session timeout" })).toBeVisible()
  await expect(page.getByText("Your session will expire in 5 minutes")).toBeVisible()
})

JavaScript

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

test("倒计时归零", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-03-15T10:00:00Z") })
  await page.goto("/sale")

  await expect(page.getByTestId("countdown")).toContainText("2:00:00")

  await page.clock.fastForward("01:00:00")
  await expect(page.getByTestId("countdown")).toContainText("1:00:00")

  await page.clock.fastForward("01:00:00")
  await expect(page.getByText("Sale ended")).toBeVisible()
})

test("无操作后触发自动保存", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-03-15T10:00:00Z") })
  await page.goto("/editor")

  await page.getByRole("textbox", { name: "Content" }).fill("Draft content")
  await page.clock.fastForward("00:30")

  await expect(page.getByText("Saved")).toBeVisible()
})

使用 resume() 恢复时间

适用场景:需要从模拟时间开始,然后让时间正常流逝以测试依赖于交互的行为。 避免场景:整个测试都应使用模拟时间。

TypeScript

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

test("通知在定时触发后实时出现", async ({ page }) => {
  // 从已知时间开始
  await page.clock.install({ time: new Date("2025-03-15T09:59:55Z") })
  await page.goto("/dashboard")

  // 通知计划在 10:00:00 触发——前进到 5 秒前
  await expect(page.getByTestId("notification-bell")).not.toHaveAttribute("data-count")

  // 从此时间点开始让真实时间流逝
  await page.clock.resume()

  // 通知应在几秒内出现
  await expect(page.getByTestId("notification-bell")).toHaveAttribute("data-count", "1", {
    timeout: 10000,
  })
})

JavaScript

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

test("通知在定时触发后出现", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-03-15T09:59:55Z") })
  await page.goto("/dashboard")

  await page.clock.resume()

  await expect(page.getByTestId("notification-bell")).toHaveAttribute("data-count", "1", {
    timeout: 10000,
  })
})

测试依赖日期的 UI

适用场景:根据当前日期变化的功能——年龄验证、过期警告、季节性内容、日期选择器。 避免场景:日期由服务端传入且不依赖于客户端 Date

TypeScript

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

test("年龄限制阻止 18 岁以下用户", async ({ page }) => {
  // 用户出生于 2010-01-15——截至 2025-06-01 未满 18 岁
  await page.clock.install({ time: new Date("2025-06-01T12:00:00") })
  await page.goto("/age-restricted")

  await page.getByLabel("Date of birth").fill("2010-01-15")
  await page.getByRole("button", { name: "Verify age" }).click()

  await expect(page.getByText("You must be 18 or older")).toBeVisible()
})

test("年龄限制允许 18 岁及以上用户", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-06-01T12:00:00") })
  await page.goto("/age-restricted")

  await page.getByLabel("Date of birth").fill("2005-01-15")
  await page.getByRole("button", { name: "Verify age" }).click()

  await expect(page.getByText("Welcome")).toBeVisible()
})

test("试用过期横幅在正确时间显示", async ({ page }) => {
  // 14 天试用的第 1 天
  await page.clock.install({ time: new Date("2025-03-01T12:00:00Z") })
  await page.goto("/dashboard")
  await expect(page.getByTestId("trial-banner")).toContainText("13 days remaining")

  // 第 12 天——警告状态
  await page.clock.setFixedTime(new Date("2025-03-12T12:00:00Z"))
  await page.reload()
  await expect(page.getByTestId("trial-banner")).toContainText("2 days remaining")
  await expect(page.getByTestId("trial-banner")).toHaveCSS("background-color", /rgb\(255/) // 红色/警告

  // 第 15 天——已过期
  await page.clock.setFixedTime(new Date("2025-03-15T12:00:00Z"))
  await page.reload()
  await expect(page.getByText("Your trial has expired")).toBeVisible()
  await expect(page.getByRole("button", { name: "Upgrade now" })).toBeVisible()
})

test("日期选择器默认显示当前模拟日期", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-07-04T12:00:00") })
  await page.goto("/booking")

  await page.getByLabel("Check-in date").click()

  // 日历应打开到 2025 年 7 月
  await expect(page.getByText("July 2025")).toBeVisible()

  // 今天(7 月 4 日)应高亮显示
  const today = page.locator('[aria-current="date"]')
  await expect(today).toHaveText("4")
})

JavaScript

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

test("年龄限制阻止 18 岁以下用户", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-06-01T12:00:00") })
  await page.goto("/age-restricted")

  await page.getByLabel("Date of birth").fill("2010-01-15")
  await page.getByRole("button", { name: "Verify age" }).click()

  await expect(page.getByText("You must be 18 or older")).toBeVisible()
})

test("试用过期显示正确的剩余天数", async ({ page }) => {
  await page.clock.install({ time: new Date("2025-03-01T12:00:00Z") })
  await page.goto("/dashboard")
  await expect(page.getByTestId("trial-banner")).toContainText("13 days remaining")

  await page.clock.setFixedTime(new Date("2025-03-15T12:00:00Z"))
  await page.reload()
  await expect(page.getByText("Your trial has expired")).toBeVisible()
})

时区相关功能

适用场景:测试将模拟时间与特定时区结合的功能。 避免场景:功能仅使用 UTC 且不渲染本地时间。

TypeScript

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

test("营业时间横幅显示营业/打烊状态", async ({ browser }) => {
  // 营业时间:东部时间上午 9 点至下午 5 点
  const context = await browser.newContext({ timezoneId: "America/New_York" })
  const page = await context.newPage()

  // 东部时间上午 10 点——应显示"营业中"
  await page.clock.install({ time: new Date("2025-03-15T14:00:00Z") }) // 东部时间上午 10 点
  await page.goto("/contact")
  await expect(page.getByTestId("business-hours")).toContainText("Open")

  // 东部时间下午 6 点——应显示"已打烊"
  await page.clock.setFixedTime(new Date("2025-03-15T22:00:00Z")) // 东部时间下午 6 点
  await page.reload()
  await expect(page.getByTestId("business-hours")).toContainText("Closed")

  await context.close()
})

test("定时事件显示正确的本地时间", async ({ browser }) => {
  // 事件时间:2025-03-20T18:00:00Z

  // 东京用户(UTC+9
  const tokyoCtx = await browser.newContext({ timezoneId: "Asia/Tokyo" })
  const tokyoPage = await tokyoCtx.newPage()
  await tokyoPage.clock.install({ time: new Date("2025-03-20T10:00:00Z") })
  await tokyoPage.goto("/events/upcoming")
  // 18:00 UTC = 东京第二天凌晨 3:00UTC+9
  await expect(tokyoPage.getByTestId("event-time")).toContainText("3:00 AM")
  await tokyoCtx.close()

  // 伦敦用户(3 月份为 UTC+0,夏令时之前)
  const londonCtx = await browser.newContext({ timezoneId: "Europe/London" })
  const londonPage = await londonCtx.newPage()
  await londonPage.clock.install({ time: new Date("2025-03-20T10:00:00Z") })
  await londonPage.goto("/events/upcoming")
  await expect(londonPage.getByTestId("event-time")).toContainText("6:00 PM")
  await londonCtx.close()
})

JavaScript

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

test("营业时间横幅显示营业/打烊状态", async ({ browser }) => {
  const context = await browser.newContext({ timezoneId: "America/New_York" })
  const page = await context.newPage()

  await page.clock.install({ time: new Date("2025-03-15T14:00:00Z") })
  await page.goto("/contact")
  await expect(page.getByTestId("business-hours")).toContainText("Open")

  await page.clock.setFixedTime(new Date("2025-03-15T22:00:00Z"))
  await page.reload()
  await expect(page.getByTestId("business-hours")).toContainText("Closed")

  await context.close()
})

决策指南

场景 API 原因
检查特定日期/时间的 UI clock.install() + clock.setFixedTime() 时间被冻结;没有定时器在运行
测试倒计时或定时器行为 clock.install() + clock.fastForward() 时间前进时触发定时器,无需真实等待
测试长时间空闲后的行为 clock.install() + clock.fastForward('30:00') 模拟 30 分钟,无需等待 30 分钟
先模拟再正常流逝 clock.install() + clock.resume() 在设置完成后需要真实的 requestAnimationFrame 时有用
不同时区显示 browser.newContext({ timezoneId }) 影响 Date 的时区渲染
时区 + 模拟时间 newContext({ timezoneId }) + clock.install() 时区和绝对时间都被控制
测试日期选择器默认值 clock.install() 带目标日期 日历打开到模拟的"今天"
测试夏令时转换 设置 timezoneId + 在 DST 边界 install 测试最常见的时区 bug

反模式

不要这样做 问题 正确的做法
page.goto() 之后调用 clock.install() 页面已使用真实的 Date 加载;定时器已触发 page.goto() 之前调用 clock.install()
使用 page.waitForTimeout(30000) 测试 30 秒定时器 每次测试浪费 30 秒真实时间 page.clock.fastForward('00:30') 瞬间完成
测试时间功能时不模拟时钟 结果取决于测试运行的时间(上午 vs 晚上,周一 vs 周日) 时间相关的断言始终模拟时间
在需要触发定时器时使用 setFixedTime setFixedTime 冻结时间;setInterval/setTimeout 不会触发 使用 fastForward 来推进时间并触发待处理的定时器
通过 page.evaluate 仅模拟 Date.now() 不影响 setTimeoutsetIntervalrequestAnimationFrame 使用 page.clock.install(),它会模拟所有时间 API
测试日期时忘记设置时区 测试本地通过但 CI 中失败(不同时区) 始终在上下文中设置 timezoneId 或使用 UTC 日期
以非常小的增量推进时间 测试缓慢;大量不必要的定时器触发 一次推进到感兴趣的确切时间点
在依赖实时时间的断言之前未调用 resume() 模拟的定时器不会自然触发;断言超时 当需要真实时间流逝时调用 clock.resume()

故障排除

症状 可能的原因 修复方法
clock.install() 无效 page.goto() 之后调用;页面已具有真实的 Date clock.install() 移到导航之前
定时器回调从不触发 时间被 setFixedTime 冻结;定时器需要推进 使用 fastForward() 推进超过定时器延迟时间
fastForward 不触发定时器 定时器注册时的延迟大于快进的时长 快进至少达到定时器的延迟长度
日期正确但时区显示错误 clock.install 以 UTC 设置时间;上下文中未设置 timezoneId 使用 { timezoneId: 'Your/Timezone' } 创建上下文
模拟时钟导致动画中断 requestAnimationFrame 被模拟且不会自然触发 在依赖动画的断言之前调用 clock.resume()
测试本地通过但 CI 中失败 本地时区与 CI 时区不同 始终在上下文中显式设置 timezoneId
setFixedTime 抛出"时钟未安装"错误 未先调用 install() 在任何其他时钟方法之前先调用 page.clock.install()
页面发出的 fetch 请求带有错误的时间戳 服务端在请求载荷中看到模拟的 Date.now() 此为预期行为;如有需要可模拟服务端响应

相关文档