438 lines
18 KiB
Markdown
438 lines
18 KiB
Markdown
# 时钟与时间模拟
|
||
|
||
> **使用场景**:测试依赖于时间的功能——倒计时、定时事件、过期日期、年龄限制、会话超时,或任何根据当前时间行为不同的 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: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**
|
||
|
||
```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)——依赖于时序的实时功能
|