Playwright Clock API 实战:在 Web 测试中精确模拟时间、定时器与页面老化
Playwright Clock API 实战在 Web 测试中精确模拟时间、定时器与页面老化【免费下载链接】playwrightPlaywright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.项目地址: https://gitcode.com/GitHub_Trending/pl/playwrightPlaywright 的page.clock允许测试脚本对页面时间拥有完全的控制权可以把Date.now()固定在某一刻也可以暂停、快进、逐毫秒推进时间从而在毫秒级成本内验证“倒计时到期”“空闲超时登出”“定时轮询”等依赖时间的行为。本文以官方文档 clock.md 为主线完整覆盖setFixedTime、install/pauseAt/fastForward/runFor/resume、setSystemTime三类 API 的用法与多语言示例并结合仓库中注入脚本、客户端与服务器端的时钟实现源码解释这些方法在浏览器内部究竟做了什么。一、Clock API 总览能控制哪些时间函数文档指出准确模拟时间相关行为对验证应用正确性至关重要。Clock API 提供以下方法来控制时间setFixedTime将Date.now()和new Date()固定为某一时间值install初始化安装时钟安装后可以使用pauseAt将时间暂停在指定时刻fastForward将时间快进到未来某一刻runFor让时间“真实地”流逝指定时长沿途触发所有定时器resume恢复时间自然流动setSystemTime直接设置当前系统时间仅推荐高级场景使用。文档给出的推荐策略是优先使用setFixedTime把时间固定到某个值如果该方案不满足需求再使用install来获得暂停、快进、逐刻推进的能力setSystemTime仅面向高级用例。Page.clock会覆盖页面中与时间相关的原生全局类和函数使其可被手动控制被替换的对象包括DatesetTimeout/clearTimeoutsetInterval/clearIntervalrequestAnimationFrame/cancelAnimationFramerequestIdleCallback/cancelIdleCallbackperformanceEvent.timeStamp调用顺序约束文档中的 warning一旦在测试中调用了install它必须先于其他所有时钟相关调用发生。乱序调用例如先setInterval、再install、最后clearInterval会导致未定义行为因为install会覆盖这些时钟函数的原生定义。从类型定义看types.d.ts时钟是安装在整个BrowserContext上的也就是说该上下文内所有页面、所有 iframe 共享同一个时钟fastForward的官方释义是“相当于用户合上笔记本电脑盖子一段时间后再打开”。1.1 源码实现架构三层时钟从源码结构看Playwright 的时钟由三层协作完成客户端层packages/playwright-core/src/client/clock.tsClock类的install、fastForward、pauseAt、resume、runFor、setFixedTime、setSystemTime都通过this._browserContext._channel转发为协议消息clockInstall、clockFastForward等见 channels.d.ts并使用kNoTimeout表示这些操作不受常规操作超时限制。时间参数由parseTime统一解析接受number毫秒时间戳、string可被new Date()解析的字符串或Date对象非法日期直接抛出Invalid date错误fastForward/runFor的刻度由parseTicks解析同时支持毫秒数和字符串两种形态。服务器层packages/playwright-core/src/server/clock.ts这是理解“为什么时钟对页面导航仍然有效”的关键。_installIfNeeded()会把编译好的时钟注入脚本generated/clockSource通过addInitScript注册为初始化脚本并立即在当前所有 frame 执行脚本会在globalThis.__pwClock上构建ClockController。此后每一次时钟操作如fastForward都会做两件事通过addInitScript追加一段controller.log(fastForward, 调用时刻, 毫秒数)—— 这样之后新建的页面或 frame新文档、iframe 重载执行初始化脚本时会先重放这份操作日志保证跨导航的时间状态一致通过safeNonStallingEvaluateInAllFrames在当前所有 frame 中真正执行controller.fastForward(...)。服务器端的parseTicksserver/clock.ts揭示了文档示例中30:00、05:00这类字符串的解析规则支持数字毫秒以及088 秒、01:001 分钟、02:34:102 小时 34 分 10 秒两种人类可读格式超过 3 段或某段 ≥ 60 会抛出Clock only understands numbers, mm:ss and hh:mm:ss错误。注入层packages/injected/src/clock.tsClockController是页面内真正的时间引擎值得注意的实现细节有时间模型由带品牌类型brand type的WallTime墙钟毫秒与Ticks单调时钟刻度组成setFixedTime会置位isFixedTime标志此后续刻推进只更新ticks而不更新墙钟时间这正是“固定时间但定时器照常走”的底层机制install()用假实现逐一替换全局对象setTimeout/clearTimeout/setInterval/clearInterval、requestAnimationFrame/cancelAnimationFrame、requestIdleCallback/cancelIdleCallback、Date构造 0 参时返回new NativeDate(clock.now())Date.now委托给假时钟、performancenow()返回假时钟刻度并伪造timeOrigin、getEntries等、Intl.DateTimeFormat无参format()使用假时钟此外还会改写Event.prototype.timeStamp的 getter并用Object.defineProperty替换AbortSignal.timeout使其走假定时器同一全局对象上重复install会抛出Cant install fake timers twice on the same global object.这与文档的调用顺序警告相呼应fastForward与runFor的本质区别在_innerFastForwardTo与_runTo快进时所有到期时间早于目标时刻的定时器其callAt被直接搬到目标时刻只在终点触发一次“每个到期定时器最多触发一次”而runFor走_runTo按callAt排序逐个触发沿途的每一个定时器compareTimers先比触发时刻再比 Immediate 优先、创建顺序、ID期间Date也会随刻度的推进而更新定时器回调抛出的错误不会丢失_runTo记录第一个异常并在推进结束后重新抛出仓库测试 page-clock.spec.ts 中 “triggers event when some throw” 用例验证了runFor会rejects.toThrow()。仓库中的 tests/library/page-clock.spec.ts 与 tests/library/page-clock.frozen.spec.ts 覆盖了runFor、fastForward等行为的详细回归例如“同时到期定时器全部触发”“跨文档导航后状态可重放”等可作为验证自身用法的参照。二、场景一用固定时间测试setFixedTime很多时候你只需要伪造Date.now而让定时器继续自然流动。这样时间照常流逝但Date.now始终返回固定值。被测页面HTML 示例来自原文档div idcurrent-time>await page.clock.setFixedTime(new Date(2024-02-02T10:00:00)); await page.goto(http://localhost:3333); await expect(page.getByTestId(current-time)).toHaveText(2/2/2024, 10:00:00 AM); await page.clock.setFixedTime(new Date(2024-02-02T10:30:00)); // We know that the page has a timer that updates the time every second. await expect(page.getByTestId(current-time)).toHaveText(2/2/2024, 10:30:00 AM);Pythonasyncawait page.clock.set_fixed_time(datetime.datetime(2024, 2, 2, 10, 0, 0)) await page.goto(http://localhost:3333) await expect(page.get_by_test_id(current-time)).to_have_text(2/2/2024, 10:00:00 AM) await page.clock.set_fixed_time(datetime.datetime(2024, 2, 2, 10, 30, 0)) # We know that the page has a timer that updates the time every second. await expect(page.get_by_test_id(current-time)).to_have_text(2/2/2024, 10:30:00 AM)Pythonsyncpage.clock.set_fixed_time(datetime.datetime(2024, 2, 2, 10, 0, 0)) page.goto(http://localhost:3333) expect(page.get_by_test_id(current-time)).to_have_text(2/2/2024, 10:00:00 AM) page.clock.set_fixed_time(datetime.datetime(2024, 2, 2, 10, 30, 0)) # We know that the page has a timer that updates the time every second. expect(page.get_by_test_id(current-time)).to_have_text(2/2/2024, 10:30:00 AM)JavaSimpleDateFormat format new SimpleDateFormat(yyyy-MM-ddTHH:mm:ss); page.clock().setFixedTime(format.parse(2024-02-02T10:00:00)); page.navigate(http://localhost:3333); Locator locator page.getByTestId(current-time); assertThat(locator).hasText(2/2/2024, 10:00:00 AM); page.clock().setFixedTime(format.parse(2024-02-02T10:30:00)); // We know that the page has a timer that updates the time every second. assertThat(locator).hasText(2/2/2024, 10:30:00 AM);C#// Set the fixed time for the clock. await Page.Clock.SetFixedTimeAsync(new DateTime(2024, 2, 2, 10, 0, 0)); await Page.GotoAsync(http://localhost:3333); await Expect(Page.GetByTestId(current-time)).ToHaveTextAsync(2/2/2024, 10:00:00 AM); // Set the fixed time for the clock. await Page.Clock.SetFixedTimeAsync(new DateTime(2024, 2, 2, 10, 30, 0)); // We know that the page has a timer that updates the time every second. await Expect(Page.GetByTestId(current-time)).ToHaveTextAsync(2/2/2024, 10:30:00 AM);示例中先固定到 10:00再固定到 10:30由于页面每秒用setInterval重渲染一次new Date()当假时钟被拨到 10:30 后下一次秒级定时回调就会把新时间画到页面上——断言由 Playwright 自动重试因此无需显式sleep。对应到实现层setFixedTime走 client/clock.ts 的clockSetFixedTime通道最终调用注入层ClockController.setFixedTime置位isFixedTime后所有Date.now()/new Date()都返回固定值而ticks仍随真实时间同步推进定时器行为不受影响。三、场景二时间与定时器保持一致install pauseAt fastForward有些场景里定时器逻辑依赖Date.now()的差值来计算剩余时间当Date.now被固定不变时这类代码会“懵掉”。这时应安装完整时钟让时间先自然流动再在需要时快进。被测页面与场景一相同每秒渲染一次时间的setInterval页面。JavaScript// Initialize clock with some time before the test time and let the page load // naturally. Date.now will progress as the timers fire. await page.clock.install({ time: new Date(2024-02-02T08:00:00) }); await page.goto(http://localhost:3333); // Pretend that the user closed the laptop lid and opened it again at 10am, // Pause the time once reached that point. await page.clock.pauseAt(new Date(2024-02-02T10:00:00)); // Assert the page state. await expect(page.getByTestId(current-time)).toHaveText(2/2/2024, 10:00:00 AM); // Close the laptop lid again and open it at 10:30am. await page.clock.fastForward(30:00); await expect(page.getByTestId(current-time)).toHaveText(2/2/2024, 10:30:00 AM);Pythonasync# Initialize clock with some time before the test time and let the page load # naturally. Date.now will progress as the timers fire. await page.clock.install(timedatetime.datetime(2024, 2, 2, 8, 0, 0)) await page.goto(http://localhost:3333) # Pretend that the user closed the laptop lid and opened it again at 10am. # Pause the time once reached that point. await page.clock.pause_at(datetime.datetime(2024, 2, 2, 10, 0, 0)) # Assert the page state. await expect(page.get_by_test_id(current-time)).to_have_text(2/2/2024, 10:00:00 AM) # Close the laptop lid again and open it at 10:30am. await page.clock.fast_forward(30:00) await expect(page.get_by_test_id(current-time)).to_have_text(2/2/2024, 10:30:00 AM)Pythonsync# Initialize clock with some time before the test time and let the page load # naturally. Date.now will progress as the timers fire. page.clock.install(timedatetime.datetime(2024, 2, 2, 8, 0, 0)) page.goto(http://localhost:3333) # Pretend that the user closed the laptop lid and opened it again at 10am. # Pause the time once reached that point. page.clock.pause_at(datetime.datetime(2024, 2, 2, 10, 0, 0)) # Assert the page state. expect(page.get_by_test_id(current-time)).to_have_text(2/2/2024, 10:00:00 AM) # Close the laptop lid again and open it at 10:30am. page.clock.fast_forward(30:00) expect(page.get_by_test_id(current-time)).to_have_text(2/2/2024, 10:30:00 AM)Java// Initialize clock with some time before the test time and let the page load // naturally. Date.now will progress as the timers fire. SimpleDateFormat format new SimpleDateFormat(yyyy-MM-ddTHH:mm:ss); page.clock().install(new Clock.InstallOptions().setTime(format.parse(2024-02-02T08:00:00))); page.navigate(http://localhost:3333); Locator locator page.getByTestId(current-time); // Pretend that the user closed the laptop lid and opened it again at 10am. // Pause the time once reached that point. page.clock().pauseAt(format.parse(2024-02-02T10:00:00)); // Assert the page state. assertThat(locator).hasText(2/2/2024, 10:00:00 AM); // Close the laptop lid again and open it at 10:30am. page.clock().fastForward(30:00); assertThat(locator).hasText(2/2/2024, 10:30:00 AM);C#// Initialize clock with some time before the test time and let the page load naturally. // Date.now will progress as the timers fire. await Page.Clock.InstallAsync(new() { TimeDate new DateTime(2024, 2, 2, 8, 0, 0) }); await Page.GotoAsync(http://localhost:3333); // Pretend that the user closed the laptop lid and opened it again at 10am. // Pause the time once reached that point. await Page.Clock.PauseAtAsync(new DateTime(2024, 2, 2, 10, 0, 0)); // Assert the page state. await Expect(Page.GetByTestId(current-time)).ToHaveTextAsync(2/2/2024, 10:00:00 AM); // Close the laptop lid again and open it at 10:30am. await Page.Clock.FastForwardAsync(30:00); await Expect(Page.GetByTestId(current-time)).ToHaveTextAsync(2/2/2024, 10:30:00 AM);这一步“把install的时间设到略早于测试目标时间、让页面自然加载完再pauseAt”正是类型定义中pauseAt文档给出的最佳实践types.d.ts目的是保证页面加载期间的定时器正常运转避免页面卡死在某个等待状态。从注入层源码看pauseAt(time)先执行_innerPause()停掉与真实时间的同步再调用_innerFastForwardTo一次性快进到目标时刻——途中所有早于该时刻到期的定时器只会各触发一次fastForward(30:00)中的30:00字符串由 server/clock.ts 的 parseTicks 解析为 30 分钟对应的毫秒数。四、场景三测试空闲超时登出install fastForward“无操作一段时间自动登出”是 Web 应用常见功能真等超时既慢又不可靠。利用时钟可以把 5 分钟压缩成一次调用。被测页面div idremaining-time>// Initial time does not matter for the test, so we can pick current time. await page.clock.install(); await page.goto(http://localhost:3333); // Interact with the page await page.getByRole(button).click(); // Fast forward time 5 minutes as if the user did not do anything. // Fast forward is like closing the laptop lid and opening it after 5 minutes. // All the timers due will fire once immediately, as in the real browser. await page.clock.fastForward(05:00); // Check that the user was logged out automatically. await expect(page.getByText(You have been logged out due to inactivity.)).toBeVisible();Pythonasync# Initial time does not matter for the test, so we can pick current time. await page.clock.install() await page.goto(http://localhost:3333) # Interact with the page await page.get_by_role(button).click() # Fast forward time 5 minutes as if the user did not do anything. # Fast forward is like closing the laptop lid and opening it after 5 minutes. # All the timers due will fire once immediately, as in the real browser. await page.clock.fast_forward(05:00) # Check that the user was logged out automatically. await expect(page.getByText(You have been logged out due to inactivity.)).toBeVisible()Pythonsync# Initial time does not matter for the test, so we can pick current time. page.clock.install() page.goto(http://localhost:3333) # Interact with the page page.get_by_role(button).click() # Fast forward time 5 minutes as if the user did not do anything. # Fast forward is like closing the laptop lid and opening it after 5 minutes. # All the timers due will fire once immediately, as in the real browser. page.clock.fast_forward(05:00) # Check that the user was logged out automatically. expect(page.getByText(You have been logged out due to inactivity.)).to_be_visible()Java// Initial time does not matter for the test, so we can pick current time. page.clock().install(); page.navigate(http://localhost:3333); Locator locator page.getByRole(button); // Interact with the page locator.click(); // Fast forward time 5 minutes as if the user did not do anything. // Fast forward is like closing the laptop lid and opening it after 5 minutes. // All the timers due will fire once immediately, as in the real browser. page.clock().fastForward(05:00); // Check that the user was logged out automatically. assertThat(page.getByText(You have been logged out due to inactivity.)).isVisible();C#// Initial time does not matter for the test, so we can pick current time. await Page.Clock.InstallAsync(); await page.GotoAsync(http://localhost:3333); // Interact with the page await page.GetByRole(button).ClickAsync(); // Fast forward time 5 minutes as if the user did not do anything. // Fast forward is like closing the laptop lid and opening it after 5 minutes. // All the timers due will fire once immediately, as in the real browser. await Page.Clock.FastForwardAsync(05:00); // Check that the user was logged out automatically. await Expect(Page.GetByText(You have been logged out due to inactivity.)).ToBeVisibleAsync();这里install()不带参数默认以“当前系统时间”初始化见 types.d.ts 中install(options?: { time?: number|string|Date })的说明Time to initialize with, current system time by defaultserver/clock.ts 中time undefined时取Date.now()。fastForward(05:00)之后所有到期定时器会像真实浏览器一样各触发一次递归的setTimeout(renderTime, 1000)链被“压缩”执行Date.now也随之推进 5 分钟diffInSeconds变为负数页面显示已登出。五、场景四手动逐刻推进时间pauseAt runFor少数场景需要细粒度控制时间的流逝过程——手动拨动时钟让途中每一个定时器和动画帧按顺序触发。runFor与fastForward的关键区别就在于此runFor沿途触发每一个到期定时器fastForward只把每个到期定时器在终点触发一次。被测页面仍为每秒渲染时间的setInterval页面同场景一、二的 HTML。JavaScript// Initialize clock with a specific time, let the page load naturally. await page.clock.install({ time: new Date(2024-02-02T08:00:00) }); await page.goto(http://localhost:3333); // Pause the time flow, stop the timers, you now have manual control // over the page time. await page.clock.pauseAt(new Date(2024-02-02T10:00:00)); await expect(page.getByTestId(current-time)).toHaveText(2/2/2024, 10:00:00 AM); // Tick through time manually, firing all timers in the process. // In this case, time will be updated in the screen 2 times. await page.clock.runFor(2000); await expect(page.getByTestId(current-time)).toHaveText(2/2/2024, 10:00:02 AM);Pythonasync# Initialize clock with a specific time, let the page load naturally. await page.clock.install(time datetime.datetime(2024, 2, 2, 8, 0, 0, tzinfodatetime.timezone.pst), ) await page.goto(http://localhost:3333) locator page.get_by_test_id(current-time) # Pause the time flow, stop the timers, you now have manual control # over the page time. await page.clock.pause_at(datetime.datetime(2024, 2, 2, 10, 0, 0)) await expect(locator).to_have_text(2/2/2024, 10:00:00 AM) # Tick through time manually, firing all timers in the process. # In this case, time will be updated in the screen 2 times. await page.clock.run_for(2000) await expect(locator).to_have_text(2/2/2024, 10:00:02 AM)Pythonsync# Initialize clock with a specific time, let the page load naturally. page.clock.install( timedatetime.datetime(2024, 2, 2, 8, 0, 0, tzinfodatetime.timezone.pst), ) page.goto(http://localhost:3333) locator page.get_by_test_id(current-time) # Pause the time flow, stop the timers, you now have manual control # over the page time. page.clock.pause_at(datetime.datetime(2024, 2, 2, 10, 0, 0)) expect(locator).to_have_text(2/2/2024, 10:00:00 AM) # Tick through time manually, firing all timers in the process. # In this case, time will be updated in the screen 2 times. page.clock.run_for(2000) expect(locator).to_have_text(2/2/2024, 10:00:02 AM)JavaSimpleDateFormat format new SimpleDateFormat(yyyy-MM-ddTHH:mm:ss); // Initialize clock with a specific time, let the page load naturally. page.clock().install(new Clock.InstallOptions() .setTime(format.parse(2024-02-02T08:00:00))); page.navigate(http://localhost:3333); Locator locator page.getByTestId(current-time); // Pause the time flow, stop the timers, you now have manual control // over the page time. page.clock().pauseAt(format.parse(2024-02-02T10:00:00)); assertThat(locator).hasText(2/2/2024, 10:00:00 AM); // Tick through time manually, firing all timers in the process. // In this case, time will be updated in the screen 2 times. page.clock().runFor(2000); assertThat(locator).hasText(2/2/2024, 10:00:02 AM);C#// Initialize clock with a specific time, let the page load naturally. await Page.Clock.InstallAsync(new() { TimeDate new DateTime(2024, 2, 2, 8, 0, 0, DateTimeKind.Pst) }); await page.GotoAsync(http://localhost:3333); var locator page.GetByTestId(current-time); // Pause the time flow, stop the timers, you now have manual control // over the page time. await Page.Clock.PauseAtAsync(new DateTime(2024, 2, 2, 10, 0, 0)); await Expect(locator).ToHaveTextAsync(2/2/2024, 10:00:00 AM); // Tick through time manually, firing all timers in the process. // In this case, time will be updated in the screen 2 times. await Page.Clock.RunForAsync(2000); await Expect(locator).ToHaveTextAsync(2/2/2024, 10:00:02 AM);pauseAt之后时间冻结、定时器全部停摆页面时间完全由测试掌控runFor(2000)手动拨过 2000 毫秒1 秒间隔的setInterval在其中触发 2 次屏幕上的时间因此更新了 2 次。对应到注入层这正是ClockController._runTo的循环每轮取出下一个最早到期的定时器、推进now到其callAt、执行回调Interval类型触发后重新排期Timeout类型触发后删除直到没有更早的定时器为止——仓库测试 page-clock.spec.ts 中 “creates updated Date while ticking” 用例还验证了runFor过程中new Date().getTime()会随每一次setInterval回调同步更新10ms 间隔 100ms 内依次得到 10…100。若只想让时间自然恢复流动可调用resume()server/clock.ts 中服务器端会同时写入可重放的log(resume, ...)初始化脚本保证后续新文档中时间继续自然流动。六、选型建议与使用边界结合文档推荐与源码实现可以把选择策略总结为需求推荐方法时间行为只需固定Date.now/new Date()定时器照常走setFixedTime墙钟固定单调时钟随真实时间同步页面逻辑依赖Date.now差值需要快进/暂停installpauseAt/fastForward安装后可暂停快进时每个到期定时器只触发一次需要精细复现时间流逝过程逐触发每个定时器installpauseAtrunFor手动拨刻沿途每个定时器按序触发直接改写系统时间高级场景setSystemTime设置系统时间后按真实节奏流动需要牢记的边界条件作用域是整个BrowserContext同一上下文的所有页面和 iframe 共享一个时钟不要误以为它是 Page 级的独立开关调用顺序install或任何时钟方法触发的自动安装见 server/clock.ts 的_installIfNeeded之后再进行其他时间相关调用同一全局对象上重复安装会直接抛错时间字符串格式fastForward/runFor接受毫秒数或mm:ss/hh:mm:ss如05:00、02:34:10格式不合法会抛出明确错误fastForward不能快进到过去注入层_innerFastForwardTo对to 当前刻度抛出Cannot fast-forward to the past错误不会被吞定时器回调抛出的异常会由runFor/fastForward的 Promise 重新抛出测试会失败并暴露页面内真实的 JS 错误。以上示例中的页面均假设由本地测试服务器如 Playwright Test 的webServer配置在http://localhost:3333提供时钟 API 对 Chromium、Firefox、WebKit 三个受支持内核均通过同一套注入脚本生效注入脚本中的browserName参数仅用于让AbortSignal.timeout的超时错误文案贴近各浏览器原生日志。【免费下载链接】playwrightPlaywright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.项目地址: https://gitcode.com/GitHub_Trending/pl/playwright创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考