Playwright 日期与时钟模拟实战:在 SurfSense 中测试时间相关功能的完整指南
2026/9/14 18:54:55 网站建设 项目流程

Playwright 日期与时钟模拟实战:在 SurfSense 中测试时间相关功能的完整指南

【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense

本篇技术指南以仓库内 Playwright 测试技能文档(.cursor/skills/playwright-testing/advanced/clock-mocking.md)为核心骨架,系统讲解 Playwright 中page.clockAPI 的安装、固定时间测试、时间推进、时区测试与定时器模拟五大主题,并结合 SurfSense 仓库的实际 E2E 测试结构(配置、fixture 体系与时间相关 UI 工具函数)给出源码级佐证。读完本文,你将掌握用时钟模拟编写确定性测试的完整方法论,能稳定测试订阅到期、倒计时、相对时间显示、防抖搜索、自动刷新、时区渲染等一切依赖时间的 Web 功能。

Clock API 基础:安装时钟是第一步

Playwright 内置的时钟工具允许测试在完全受控的时间维度中运行页面代码。其核心 API 为page.clock,最重要的方法是install()——它在当前页面上下文中替换DatesetTimeoutsetIntervalrequestAnimationFrame等时间相关实现,使页面"看到的"时间完全由测试控制。

在导航前安装时钟

时钟模拟的关键前提是:必须在页面导航(goto)之前完成安装,否则页面脚本已经捕获了真实时间,安装为时已晚。

test("mock current time", async ({ page }) => { // 在导航前安装时钟 await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); await page.goto("/dashboard"); // 页面此时看到的当前时间是 2025 年 1 月 15 日 await expect(page.getByText("January 15, 2025")).toBeVisible(); });

封装为可复用的时钟 Fixture

当多个测试都需要"先固定时间再进入页面"时,应把时钟安装逻辑封装为 Playwright fixture。SurfSense 仓库的 E2E 测试正是采用这一理念组织共享逻辑——tests/fixtures/index.ts 是所有 fixture 的中央出口,spec 统一从这里导入testexpect,而不是直接使用@playwright/test,这样新增 fixture 只需一行改动即可惠及所有测试。下面是与仓库模式一致的时钟 fixture 写法:

// fixtures/clock.fixture.ts import { test as base } from "@playwright/test"; type ClockFixtures = { mockTime: (date: Date | string) => Promise<void>; }; export const test = base.extend<ClockFixtures>({ mockTime: async ({ page }, use) => { await use(async (date) => { const time = typeof date === "string" ? new Date(date) : date; await page.clock.install({ time }); }); }, }); // 使用示例 test("subscription expiry", async ({ page, mockTime }) => { await mockTime("2025-12-31T23:59:00"); await page.goto("/subscription"); await expect(page.getByText("Expires today")).toBeVisible(); });

参考仓库中 workspace.fixture.ts 的写法可见,仓库的 fixture 遵循"worker 级缓存 + 测试级自动清理"的模式——例如apiTokenWorker以 worker 作用域缓存登录令牌,workspace则在use()结束后通过finally自动删除 workspace。自定义时钟 fixture 同样应在use()完成后清理(如关闭临时创建的 context),避免跨测试泄漏。

固定时间测试:让日期相关功能可预测

很多业务功能的行为取决于"当前是哪一天"。固定时间测试的核心思路是:把当前时间固定到目标日期,再断言页面渲染结果。

测试日期相关功能

test("show holiday banner in December", async ({ page }) => { await page.clock.install({ time: new Date("2025-12-20T10:00:00") }); await page.goto("/"); await expect(page.getByRole("banner", { name: /holiday/i })).toBeVisible(); }); test("no holiday banner in January", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15T10:00:00") }); await page.goto("/"); await expect(page.getByRole("banner", { name: /holiday/i })).toBeHidden(); });

同一功能在不同时间点下的行为对比,正是时钟模拟的典型用例——两个测试使用相同断言目标,仅通过修改install的时间参数验证分支逻辑。

测试相对时间显示

相对时间("2 hours ago")是 Web 应用中最常见也最容易产生时间相关缺陷的 UI 元素。它的难点在于:now是不断流动的真实时间,若不固定,断言几乎必然抖动。固定时间 + 用page.routemock API 返回已知时间戳,即可精确验证相对时间计算。

test("shows relative time correctly", async ({ page }) => { // 固定当前时间,从而控制 "posted 2 hours ago" 文案 await page.clock.install({ time: new Date("2025-06-15T14:00:00") }); // Mock API,返回带有已知时间戳的帖子 await page.route("**/api/posts/1", (route) => route.fulfill({ json: { id: 1, title: "Test Post", createdAt: "2025-06-15T12:00:00Z", // 比 mock 时间早 2 小时 }, }), ); await page.goto("/posts/1"); await expect(page.getByText("2 hours ago")).toBeVisible(); });

这一模式与 SurfSense 前端的时间格式化工具高度对应。仓库的 lib/format-date.ts 中,formatRelativeDate()基于 date-fns 计算分钟/小时/天差,并输出 "15 minutes ago"、"21 hours ago"、"2 days ago" 等文案,超过 7 天则退化为 "Jan 15" 或 "Jan 15, 2026";formatRelativeFutureDate()则用于未来时刻的倒计时显示(如 "in 15m"、"Today, 2:30 PM"、"Tomorrow, 2:30 PM"),它内部还防御性地回退到过去式格式化,防止出现陈旧的next_fire_at数据。要测试这类函数渲染出的文案,固定时间 + 已知输入时间戳是唯一稳定的方案——如果不固定时间,测试执行时刻的毫秒级差异都会导致断言不稳定。

测试日期边界

月末、年末、闰日等边界日期是时间逻辑出错的高发区。使用test.describe将同一功能的不同时间点组织成一组测试,既清晰又便于扩展:

test.describe("end of month billing", () => { test("shows billing on last day of month", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-31T10:00:00") }); await page.goto("/billing"); await expect(page.getByText("Payment due today")).toBeVisible(); }); test("shows days remaining mid-month", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15T10:00:00") }); await page.goto("/billing"); await expect(page.getByText("16 days until payment")).toBeVisible(); }); });

时间推进:让倒计时与超时在秒级完成

固定时间解决的是"页面看到哪个时间点"的问题,而时间推进解决的是"如何快进到下一个状态"。page.clock.fastForward()会同步快进时钟并触发所有到期定时器,让原本需要等待数分钟甚至数小时的状态转换在测试中瞬间完成。

手动推进时间

test("session timeout warning", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); await page.goto("/dashboard"); // 推进 25 分钟(会话超时阈值是 30 分钟) await page.clock.fastForward("25:00"); await expect(page.getByText("Session expires in 5 minutes")).toBeVisible(); // 再推进 5 分钟 await page.clock.fastForward("05:00"); await expect(page.getByText("Session expired")).toBeVisible(); });

fastForward接受两种参数形式:毫秒数字(如300表示 300ms)和HH:MM:SS 时间字符串(如"25:00""01:00:00")。对于分钟、小时级的推进,字符串形式可读性明显更好。

暂停与恢复时间

时钟安装后默认会随时间流动,但配合pause()可以完全冻结时间,再配合fastForward()精确控制每个时间步。典型的倒计时测试流程如下:

test("countdown timer", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); await page.goto("/sale"); // 初始状态 await expect(page.getByText("Sale ends in 2:00:00")).toBeVisible(); // 推进 1 小时 await page.clock.fastForward("01:00:00"); await expect(page.getByText("Sale ends in 1:00:00")).toBeVisible(); // 推进到结束时刻之后 await page.clock.fastForward("01:00:01"); await expect(page.getByText("Sale ended")).toBeVisible(); });

这类倒计时场景在 SurfSense 中有真实的业务对应物:前端hooks/use-announcements.ts通过setTimeout实现公告轮询与 tick 刷新,hooks/use-folder-sync.tssetTimeout做文件夹同步的防抖批量提交(DEBOUNCE_MS),hooks/use-documents-processing.tssetTimeout管理文档处理成功的提示定时器。所有这类"延迟执行"逻辑,都可以用时钟模拟把真实等待压缩到毫秒级。

运行挂起的定时器

对于防抖(debounce)这类"故意延迟"的逻辑,时钟模拟能精确验证"未到触发时刻不执行、到达触发时刻立即执行":

test("debounced search", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); await page.goto("/search"); await page.getByLabel("Search").fill("playwright"); // 搜索被防抖 300ms,此刻还不会触发 await expect(page.getByTestId("search-results")).toBeHidden(); // 快进越过防抖窗口 await page.clock.fastForward(300); // 搜索现在应该已执行 await expect(page.getByTestId("search-results")).toBeVisible(); });

仓库前端确实存在多处真实的防抖实现,例如 use-debounce.ts 与 use-debounced-value.ts 都基于setTimeout实现,use-folder-sync.ts中还有以Map<string, ReturnType<typeof setTimeout>>组织的多 key 防抖定时器表。用fastForward而非真实等待去测试它们,可以完全消除测试时长与偶发超时问题。

时区测试:多时区渲染的正确姿势

时区是时间测试中最隐蔽的坑:同一时刻在不同时区下渲染出的本地时间完全不同。Playwright 通过browser.newContext({ timezoneId })控制页面所在时区,配合page.clock.install()的绝对时间(UTC),可以实现"同一时刻、多时区"的确定性验证。

测试不同时区下的时间显示

test.describe("timezone display", () => { test("shows correct time in PST", async ({ browser }) => { const context = await browser.newContext({ timezoneId: "America/Los_Angeles", }); const page = await context.newPage(); await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC await page.goto("/schedule"); // 应显示 9 AM PST await expect(page.getByText("9:00 AM")).toBeVisible(); await context.close(); }); test("shows correct time in JST", async ({ browser }) => { const context = await browser.newContext({ timezoneId: "Asia/Tokyo", }); const page = await context.newPage(); await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC await page.goto("/schedule"); // 应显示次日凌晨 2 点 JST await expect(page.getByText("2:00 AM")).toBeVisible(); await context.close(); }); });

这里的关键点是:install的时间参数使用 UTC 绝对时刻(带Z后缀),页面显示的本地时间则由timezoneId决定。这样测试既不依赖运行机器的时区,又能精确断言每个目标时区的渲染结果。

时区 Fixture

时区测试常常需要为多个时区创建多个 context,封装成 fixture 可以避免样板代码,同时通过finally-like 清理保证 context 不泄漏:

// fixtures/timezone.fixture.ts import { test as base } from "@playwright/test"; type TimezoneFixtures = { pageInTimezone: (timezone: string) => Promise<Page>; }; export const test = base.extend<TimezoneFixtures>({ pageInTimezone: async ({ browser }, use) => { const pages: Page[] = []; await use(async (timezone) => { const context = await browser.newContext({ timezoneId: timezone }); const page = await context.newPage(); pages.push(page); return page; }); // 清理:关闭本 fixture 创建的所有 context for (const page of pages) { await page.context().close(); } }, });

这与 SurfSense 仓库的 fixture 设计哲学一致:从 tests/fixtures/index.ts 可以看到,仓库通过base.extend(...)逐层组合出workspaceFixtureschatThreadFixtures以及各连接器 fixture(composioDriveFixturesnativeGmailFixtures等),形成一条清晰的继承链,且每个 fixture 都负责自身资源的创建与回收。时钟/时区 fixture 完全可以并入这条链。

定时器模拟:setInterval、setTimeout 链与动画帧

页面中大量逻辑由setInterval(轮询刷新)、setTimeout(延迟队列)、requestAnimationFrame(动画)驱动。时钟模拟安装后,这些定时器全部被劫持为"虚拟定时器",从而可以用fastForward精确驱动。

模拟 setInterval 轮询

test("auto-refresh data", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); let apiCalls = 0; await page.route("**/api/data", (route) => { apiCalls++; route.fulfill({ json: { value: apiCalls } }); }); await page.goto("/live-data"); // 页面设置了 30s 刷新间隔 expect(apiCalls).toBe(1); // 首次加载 // 推进 30 秒 await page.clock.fastForward("00:30"); expect(apiCalls).toBe(2); // 第一次刷新 // 再推进 30 秒 await page.clock.fastForward("00:30"); expect(apiCalls).toBe(3); // 第二次刷新 });

通过统计page.route的拦截次数,可以精确断言"每推进一个周期就多触发一次轮询",这是验证轮询间隔是否正确的确定性强方法。

模拟 setTimeout 链

依次延迟出现的通知队列是典型的 setTimeout 链场景:

test("notification queue", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); await page.goto("/notifications"); // 触发 3 条依次出现的通知 await page.getByRole("button", { name: "Show All" }).click(); // 第一条通知立即出现 await expect(page.getByText("Notification 1")).toBeVisible(); // 第二条在 2 秒后出现 await page.clock.fastForward("00:02"); await expect(page.getByText("Notification 2")).toBeVisible(); // 第三条再过 2 秒出现 await page.clock.fastForward("00:02"); await expect(page.getByText("Notification 3")).toBeVisible(); });

测试动画帧

requestAnimationFrame驱动的动画同样受时钟控制。测试时先断言动画起始状态,再fastForward越过动画时长,最后断言结束状态:

test("animation completes", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15T09:00:00") }); await page.goto("/animation-demo"); await page.getByRole("button", { name: "Animate" }).click(); // 动画持续 500ms const element = page.getByTestId("animated-box"); await expect(element).toHaveCSS("opacity", "0"); // 快进穿过整个动画 await page.clock.fastForward(500); await expect(element).toHaveCSS("opacity", "1"); });

最佳实践

始终在导航之前安装时钟

时钟必须在页面脚本捕获时间之前生效,这是时钟模拟唯一不可妥协的顺序约束:

// 正确:先安装时钟,再导航 test("date test", async ({ page }) => { await page.clock.install({ time: new Date("2025-01-15") }); await page.goto("/"); // 页面加载时即使用 mock 时间 }); // 错误:导航后安装已经太晚 test("date test", async ({ page }) => { await page.goto("/"); await page.clock.install({ time: new Date("2025-01-15") }); // 太迟了! });

使用 ISO 字符串保持清晰

带显式时区偏移的 ISO 字符串没有歧义,是首选写法:

// 推荐:显式 UTC 时区 await page.clock.install({ time: new Date("2025-01-15T09:00:00Z") }); // 有歧义:使用运行环境的本地时区解释 await page.clock.install({ time: new Date("2025-01-15T09:00:00") });

这一原则与 format-date.ts 中formatRelativeDate对时间戳的处理逻辑呼应:函数内部先new Date(dateString)解析时间戳,再与new Date()(当前真实时间)比较。如果测试不固定时钟且不控制时区,"现在"与时间戳的解释基准就不可控;固定 UTC 时间 + 显式timezoneId后,比较结果才完全确定。

需要避免的反模式

反模式问题解决方案
在导航之后安装时钟页面已经捕获真实时间goto()之前安装时钟
硬编码相对日期测试随时间推移而失效使用固定日期配合时钟 mock
不考虑时区测试在不同地区运行结果不同使用显式 UTC 时间或设置timezoneId
在 mock 时钟下使用waitForTimeout与 mock 定时器冲突改用fastForward

最后一条反模式尤其值得注意:一旦page.clock.install()生效,页面内的定时器全部虚拟化,此时用waitForTimeout做真实等待不仅慢,还可能与被 mock 的定时器机制产生冲突,导致行为不符合预期。统一使用fastForward是正确做法。

在 SurfSense E2E 测试体系中落地时钟模拟

若要在 SurfSense 的 E2E 套件中启用时钟模拟,先要了解其测试运行环境:playwright.config.ts 中testDir指向./tests,默认timeout: 30_000expect.timeout: 15_000,使用chromium项目并通过setup项目的storageStateplaywright/.auth/user.json)复用登录态;auth.setup.ts 会为预置的 e2e 用户获取 bearer token 并写入 session cookie,同时用addInitScript预置 localStorage 标记(如surfsense_announcements_statesurfsense-tour-<userId>),屏蔽新用户引导弹层对旅程测试的干扰。

落地时钟模拟时需注意两点兼容性:

  1. 登录与存储状态不受时钟影响auth.setup.ts的运行不依赖页面时间,但storageState中的 cookie 若带过期时间,应以真实时间计算;时钟 mock 只应在具体功能测试的页面会话内使用,不要在 setup 阶段全局安装。
  2. 初始化脚本与时钟共存:仓库依赖addInitScript写入 localStorage,而page.clock.install()同样作用于页面初始化阶段。若需在 init script 中读取时间(如公告过期判断),应保证 init script 在install之后执行、或在断言中把时间因素固定下来;否则用page.route将公告数据源固定为已知时间戳即可,参照文档中"测试相对时间显示"的 route + 固定时间戳组合。

对于"会话超时"、"订阅到期"、"相对时间显示"、"公告轮询"(use-announcements.ts 中的setTimeouttick 刷新)、"文件夹同步防抖"(use-folder-sync.ts)等 SurfSense 真实功能,时钟模拟是唯一能将测试时间从"等待真实时间流逝"压缩到"毫秒级确定性断言"的方案。仓库的 fixture 继承链(tests/fixtures/index.ts)提供了现成的扩展点,只需把本文的clock.fixture.tstimezone.fixture.ts并入该链即可全局复用。

延伸阅读

  • 基于时间的断言:参见 assertions-waiting.md,其中包含时间相关断言的等待与重试策略
  • Fixture 与钩子:参见 fixtures-hooks.md,了解时钟 fixture 与生命周期钩子的组合方式
  • 仓库 E2E 实测:参见 playwright.config.ts 了解测试环境,tests/fixtures/index.ts 了解 fixture 继承链,tests/auth.setup.ts 了解登录态准备,tests/smoke/dashboard.spec.ts 查看最简冒烟测试样例

【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询