ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

Playwright 日期与时钟模拟实战:在 SurfSense 中测试时间相关功能的完整指南 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()——它在当前页面上下文中替换Date、setTimeout、setInterval、requestAnimationFrame等时间相关实现使页面看到的时间完全由测试控制。在导航前安装时钟时钟模拟的关键前提是必须在页面导航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 统一从这里导入test与expect而不是直接使用playwright/test这样新增 fixture 只需一行改动即可惠及所有测试。下面是与仓库模式一致的时钟 fixture 写法// fixtures/clock.fixture.ts import { test as base } from playwright/test; type ClockFixtures { mockTime: (date: Date | string) Promisevoid; }; export const test base.extendClockFixtures({ 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, 2026formatRelativeFutureDate()则用于未来时刻的倒计时显示如 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.ts用setTimeout做文件夹同步的防抖批量提交DEBOUNCE_MShooks/use-documents-processing.ts用setTimeout管理文档处理成功的提示定时器。所有这类延迟执行逻辑都可以用时钟模拟把真实等待压缩到毫秒级。运行挂起的定时器对于防抖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中还有以Mapstring, ReturnTypetypeof 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) PromisePage; }; export const test base.extendTimezoneFixtures({ 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(...)逐层组合出workspaceFixtures、chatThreadFixtures以及各连接器 fixturecomposioDriveFixtures、nativeGmailFixtures等形成一条清晰的继承链且每个 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_000、expect.timeout: 15_000使用chromium项目并通过setup项目的storageStateplaywright/.auth/user.json复用登录态auth.setup.ts 会为预置的 e2e 用户获取 bearer token 并写入 session cookie同时用addInitScript预置 localStorage 标记如surfsense_announcements_state与surfsense-tour-userId屏蔽新用户引导弹层对旅程测试的干扰。落地时钟模拟时需注意两点兼容性登录与存储状态不受时钟影响auth.setup.ts的运行不依赖页面时间但storageState中的 cookie 若带过期时间应以真实时间计算时钟 mock 只应在具体功能测试的页面会话内使用不要在 setup 阶段全局安装。初始化脚本与时钟共存仓库依赖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.ts与timezone.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),仅供参考
返回列表