SurfSense/.cursor/skills/playwright-testing/clock-and-time-mocking.md
2026-05-04 13:54:13 +05:30

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.clock API 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