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

438 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 时钟与时间模拟
> **使用场景**:测试依赖于时间的功能——倒计时、定时事件、过期日期、年龄限制、会话超时,或任何根据当前时间行为不同的 UI。Playwright 的 `page.clock` API 让你无需实时等待即可控制时间。
> **前置知识**[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/configuration.md](configuration.md)
## 快速参考
```typescript
// 将时间冻结在特定时刻
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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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](i18n-and-localization.md)——时区和本地化测试模式
- [core/assertions-and-waiting.md](assertions-and-waiting.md)——自动等待与基于时间的断言
- [core/error-and-edge-cases.md](error-and-edge-cases.md)——测试超时和过期边界情况
- [core/performance-testing.md](performance-testing.md)——与时间相关的性能测量
- [core/websockets-and-realtime.md](websockets-and-realtime.md)——依赖于时序的实时功能