18 KiB
Executable file
Clock and Time Mocking
When to use: Testing time-dependent features -- countdown timers, scheduled events, expiration dates, age gates, session timeouts, or any UI that behaves differently based on the current time. Playwright's
page.clockAPI lets you control time without waiting in real-time. Prerequisites: core/assertions-and-waiting.md, core/configuration.md
Quick Reference
// Freeze time at a specific moment
await page.clock.install({ time: new Date('2025-03-15T10:00:00Z') });
await page.goto('/dashboard');
// Advance time by 5 minutes
await page.clock.fastForward('05:00');
// Set time to a specific point (jumps, does not tick through)
await page.clock.setFixedTime(new Date('2025-12-31T23:59:59Z'));
// Let time resume ticking from current mocked point
await page.clock.resume();
Key concept: page.clock.install() replaces Date, setTimeout, setInterval, and requestAnimationFrame in the page. Call it before page.goto() so the page loads with mocked time from the start.
Patterns
Frozen Time with install() and setFixedTime()
Use when: Your test needs time to stand still at a specific moment -- verifying what the UI shows at a particular date/time.
Avoid when: The feature under test depends on timers ticking (use fastForward instead).
TypeScript
import { test, expect } from '@playwright/test';
test('dashboard shows correct greeting based on time of day', async ({ page }) => {
// Install clock BEFORE navigating
await page.clock.install({ time: new Date('2025-06-15T08:30:00') });
await page.goto('/dashboard');
await expect(page.getByText('Good morning')).toBeVisible();
// Jump to afternoon
await page.clock.setFixedTime(new Date('2025-06-15T14:00:00'));
await page.reload();
await expect(page.getByText('Good afternoon')).toBeVisible();
// Jump to evening
await page.clock.setFixedTime(new Date('2025-06-15T20:00:00'));
await page.reload();
await expect(page.getByText('Good evening')).toBeVisible();
});
test('subscription shows correct expiration status', async ({ page }) => {
// Freeze time to a date when subscription is active
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');
// Jump to the expiration date
await page.clock.setFixedTime(new Date('2025-06-30T12:00:00Z'));
await page.reload();
await expect(page.getByTestId('subscription-status')).toHaveText('Expiring today');
});
test('content displays correctly on a specific holiday', 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
const { test, expect } = require('@playwright/test');
test('dashboard shows correct greeting based on time of day', 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('subscription shows correct expiration status', 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');
});
Fast-Forwarding Time with fastForward()
Use when: Testing timers, countdowns, debounced actions, or any feature that reacts to elapsed time. fastForward fires all pending timers up to the specified duration.
Avoid when: You just need to check a static time-dependent display -- use setFixedTime instead.
TypeScript
import { test, expect } from '@playwright/test';
test('countdown timer reaches zero', async ({ page }) => {
await page.clock.install({ time: new Date('2025-03-15T10:00:00Z') });
await page.goto('/sale');
// Sale countdown starts at 2 hours
await expect(page.getByTestId('countdown')).toContainText('2:00:00');
// Fast-forward 1 hour
await page.clock.fastForward('01:00:00');
await expect(page.getByTestId('countdown')).toContainText('1:00:00');
// Fast-forward remaining time
await page.clock.fastForward('01:00:00');
await expect(page.getByTestId('countdown')).toContainText('0:00:00');
await expect(page.getByText('Sale ended')).toBeVisible();
});
test('auto-save triggers after 30 seconds of inactivity', async ({ page }) => {
await page.clock.install({ time: new Date('2025-03-15T10:00:00Z') });
await page.goto('/editor');
// Type something
await page.getByRole('textbox', { name: 'Content' }).fill('Draft content');
// Fast-forward past the auto-save interval
await page.clock.fastForward('00:30');
await expect(page.getByText('Saved')).toBeVisible();
});
test('session timeout warning appears after 25 minutes', async ({ page }) => {
await page.clock.install({ time: new Date('2025-03-15T10:00:00Z') });
await page.goto('/dashboard');
// Fast-forward to just before the warning (25 min)
await page.clock.fastForward('24:59');
await expect(page.getByRole('dialog', { name: 'Session timeout' })).not.toBeVisible();
// One more minute triggers the warning
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
const { test, expect } = require('@playwright/test');
test('countdown timer reaches zero', 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('auto-save triggers after inactivity', 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();
});
Resuming Time with resume()
Use when: You need to start with a mocked time, then let time flow normally for interaction-dependent behavior. Avoid when: The entire test should use mocked time.
TypeScript
import { test, expect } from '@playwright/test';
test('notification appears in real-time after scheduled trigger', async ({ page }) => {
// Start at a known time
await page.clock.install({ time: new Date('2025-03-15T09:59:55Z') });
await page.goto('/dashboard');
// Notification is scheduled for 10:00:00 — advance to 5 seconds before
await expect(page.getByTestId('notification-bell')).not.toHaveAttribute('data-count');
// Let real time tick from this point
await page.clock.resume();
// The notification should appear within a few seconds
await expect(page.getByTestId('notification-bell')).toHaveAttribute('data-count', '1', {
timeout: 10000,
});
});
JavaScript
const { test, expect } = require('@playwright/test');
test('notification appears after scheduled trigger', 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,
});
});
Testing Date-Dependent UI
Use when: Features that change based on the current date -- age verification, expiration warnings, seasonal content, date pickers.
Avoid when: The date is passed from the server and does not depend on client-side Date.
TypeScript
import { test, expect } from '@playwright/test';
test('age gate blocks users under 18', async ({ page }) => {
// User born on 2010-01-15 — under 18 as of 2025-06-01
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('age gate allows users 18 and older', 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('trial expiration banner shows at correct times', async ({ page }) => {
// Day 1 of 14-day trial
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');
// Day 12 — warning state
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/); // Red/warning
// Day 15 — expired
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('date picker defaults to current mocked date', 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();
// Calendar should open to July 2025
await expect(page.getByText('July 2025')).toBeVisible();
// Today (July 4) should be highlighted
const today = page.locator('[aria-current="date"]');
await expect(today).toHaveText('4');
});
JavaScript
const { test, expect } = require('@playwright/test');
test('age gate blocks users under 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('trial expiration shows correct days remaining', 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();
});
Timezone-Dependent Features
Use when: Testing features that combine mocked time with specific timezones. Avoid when: The feature uses only UTC and does not render local times.
TypeScript
import { test, expect } from '@playwright/test';
test('business hours banner shows open/closed status', async ({ browser }) => {
// Business hours: 9 AM - 5 PM Eastern
const context = await browser.newContext({ timezoneId: 'America/New_York' });
const page = await context.newPage();
// 10 AM Eastern — should show "Open"
await page.clock.install({ time: new Date('2025-03-15T14:00:00Z') }); // 10 AM ET
await page.goto('/contact');
await expect(page.getByTestId('business-hours')).toContainText('Open');
// 6 PM Eastern — should show "Closed"
await page.clock.setFixedTime(new Date('2025-03-15T22:00:00Z')); // 6 PM ET
await page.reload();
await expect(page.getByTestId('business-hours')).toContainText('Closed');
await context.close();
});
test('scheduled event shows correct local time', async ({ browser }) => {
// Event at 2025-03-20T18:00:00Z
// User in Tokyo (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 = 03:00 AM next day in Tokyo (UTC+9)
await expect(tokyoPage.getByTestId('event-time')).toContainText('3:00 AM');
await tokyoCtx.close();
// User in London (UTC+0 in March, before DST)
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
const { test, expect } = require('@playwright/test');
test('business hours banner shows open/closed status', 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();
});
Decision Guide
| Scenario | API | Why |
|---|---|---|
| Check UI at a specific date/time | clock.install() + clock.setFixedTime() |
Time is frozen; no timer ticking |
| Test countdown or timer behavior | clock.install() + clock.fastForward() |
Fires timers as time advances without real waiting |
| Test after a long idle period | clock.install() + clock.fastForward('30:00') |
Simulates 30 minutes without waiting 30 minutes |
| Start mocked, then tick normally | clock.install() + clock.resume() |
Useful when you need real requestAnimationFrame after setup |
| Different timezone display | browser.newContext({ timezoneId }) |
Affects Date timezone rendering |
| Timezone + mocked time | newContext({ timezoneId }) + clock.install() |
Both timezone and absolute time are controlled |
| Test date picker defaults | clock.install() with target date |
Calendar opens to the mocked "today" |
| Test DST transitions | Set timezoneId + install at DST boundary |
Tests the most common timezone bugs |
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
Calling clock.install() after page.goto() |
Page already loaded with real Date; timers already fired |
Call clock.install() BEFORE page.goto() |
Using page.waitForTimeout(30000) to test a 30-second timer |
Wastes 30 real seconds per test run | page.clock.fastForward('00:30') completes instantly |
| Testing time without mocking the clock | Results depend on when the test runs (morning vs evening, Monday vs Sunday) | Always mock time for time-dependent assertions |
Using setFixedTime when you need timers to fire |
setFixedTime freezes time; setInterval/setTimeout will not trigger |
Use fastForward to advance time and fire pending timers |
Mocking only Date.now() via page.evaluate |
Does not affect setTimeout, setInterval, or requestAnimationFrame |
Use page.clock.install() which mocks all time APIs |
| Forgetting timezone when testing dates | Test passes locally but fails in CI (different timezone) | Always set timezoneId in context or use UTC dates |
| Advancing time in very small increments | Slow test; many unnecessary timer firings | Advance to the exact time of interest in one call |
Not calling resume() before real-time-dependent assertions |
Mocked timers will not fire naturally; assertions time out | Call clock.resume() when you need real time to flow |
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
clock.install() has no effect |
Called after page.goto(); page already has real Date |
Move clock.install() before navigation |
| Timer callbacks never fire | Time is frozen with setFixedTime; timers need advancing |
Use fastForward() to advance past the timer delay |
fastForward does not trigger timer |
Timer was registered with a delay longer than the fast-forward amount | Fast-forward by at least the timer's delay |
| Date is correct but timezone display is wrong | clock.install sets time in UTC; timezoneId not set on context |
Create context with { timezoneId: 'Your/Timezone' } |
| Animations break with mocked clock | requestAnimationFrame is mocked and does not fire naturally |
Call clock.resume() before animation-dependent assertions |
| Test passes locally but fails in CI | Local timezone differs from CI timezone | Always set timezoneId explicitly in the context |
setFixedTime throws "clock not installed" |
install() was not called first |
Call page.clock.install() before any other clock methods |
| Page makes fetch requests with wrong timestamps | Server sees mocked Date.now() in request payloads |
This is expected; mock server responses if needed |
Related
- core/i18n-and-localization.md -- timezone and locale testing patterns
- core/assertions-and-waiting.md -- auto-waiting vs time-based assertions
- core/error-and-edge-cases.md -- testing timeout and expiration edge cases
- core/performance-testing.md -- timing-related performance measurement
- core/websockets-and-realtime.md -- real-time features that depend on timing