18 KiB
18 KiB
时钟与时间模拟
使用场景:测试依赖于时间的功能——倒计时、定时事件、过期日期、年龄限制、会话超时,或任何根据当前时间行为不同的 UI。Playwright 的
page.clockAPI 让你无需实时等待即可控制时间。 前置知识:core/assertions-and-waiting.md、core/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() 会替换页面中的 Date、setTimeout、setInterval 和 requestAnimationFrame。请在 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:00(UTC+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() |
不影响 setTimeout、setInterval 或 requestAnimationFrame |
使用 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() |
此为预期行为;如有需要可模拟服务端响应 |
相关文档
- core/i18n-and-localization.md——时区和本地化测试模式
- core/assertions-and-waiting.md——自动等待与基于时间的断言
- core/error-and-edge-cases.md——测试超时和过期边界情况
- core/performance-testing.md——与时间相关的性能测量
- core/websockets-and-realtime.md——依赖于时序的实时功能