Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 057 / 100

Testing time-dependent features: lessons from production

Control the clock.

Expona Admin9 min read

Introduction

Time-dependent features are notoriously difficult to test in automated suites. From subscription expiries and session timeouts to countdown timers and scheduled workflows, real time is the enemy of reliable tests. Testing time-dependent features: lessons from production means embracing the tools that let us control the clock rather than fight it. In this article, we'll explore how Playwright's page.clock API lets us manipulate time programmatically, ensuring our tests run fast, deterministically, and without flaky waits.

Why time-dependent testing matters

Production incidents related to time are more common than we'd like. A payment expired mid-checkout. A session token became invalid. A promotional countdown rendered incorrectly. These bugs often slip past manual testing because they depend on external factors we can't easily control: real time progression, timezone quirks, or edge cases that only manifest hours or days later.

In our experience across e-commerce and SaaS platforms, every team eventually faces this problem. A test passes locally but fails in CI because the system clock differs by a few seconds. A test waits 30 seconds for a real timeout instead of validating the behavior instantly. These aren't just slow tests—they're brittle tests that erode confidence in the suite.

Playwright's page.clock API solves this by letting us freeze, advance, and manipulate time within the browser context. No more await page.waitForTimeout(30000). No more flaky sleep-based assertions. We control the clock.

Using page.clock to fake time

Playwright provides page.clock to intercept and control time within a page. The API works by patching the browser's Date, setTimeout, setInterval, and other timing mechanisms. Your application sees time progressing normally, but we hold the throttle.

Here's the basic workflow:

  1. Install the clock on a page or context
  2. Set an initial time
  3. Advance time as needed
  4. Assert behavior at specific moments

Let's start with a simple example. Imagine a session token that expires after 1 hour:

typescript
import { test, expect } from '@playwright/test';

test('session expires after 1 hour of inactivity', async ({ page }) => {
  // Install the clock and set a starting time
  await page.clock.install({ time: new Date('2024-01-15T10:00:00Z') });

  await page.goto('https://app.example.com/login');
  await page.fill('input[name="email"]', 'user@example.com');
  await page.fill('input[name="password"]', 'secret');
  await page.click('button:has-text("Sign in")');

  // Verify we're logged in
  await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();

  // Advance time by 59 minutes—session should still be valid
  await page.clock.fastForward('59:00');
  await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();

  // Advance 1 more minute to cross the 1-hour threshold
  await page.clock.fastForward('1:00');
  
  // Now the session should be invalidated
  await expect(page.locator('text=Session expired')).toBeVisible();
  await expect(page.url()).toContain('/login');
});

Notice we don't use await page.waitForTimeout(3600000). Instead, we use page.clock.fastForward() to jump time forward instantly. The test runs in milliseconds, not an hour.

The key methods on page.clock are:

  • install(): Activates the clock and optionally sets an initial time
  • setFixedTime(date): Freeze time at a specific moment
  • fastForward(ms | duration): Jump time forward by a duration
  • runFor(ms | duration): Run all pending timers and advance time
  • pauseAt(date): Jump to a time and pause there

For production code, we typically use duration strings like '1:30' for 1 hour 30 minutes or '5d' for 5 days. This makes tests readable and maintainable.

Test expiry and countdowns

Expiry is one of the most common time-dependent features. Discounts expire. Trial periods end. Reservation holds timeout. Let's walk through a real scenario from an e-commerce site: a cart item with a limited-time discount that expires after 24 hours.

typescript
test('limited-time discount expires after 24 hours', async ({ page }) => {
  const startTime = new Date('2024-01-15T14:30:00Z');
  await page.clock.install({ time: startTime });

  // Add an item with active discount to cart
  await page.goto('https://shop.example.com/product/widget-pro');
  await page.click('button:has-text("Add to cart")');

  // Check that discount is applied
  await page.goto('https://shop.example.com/cart');
  await expect(page.locator('[data-testid="discount-badge"]')).toContainText('24h left');
  await expect(page.locator('[data-testid="discount-price"]')).toContainText('$49.99');

  // Fast forward 23 hours—discount should still apply
  await page.clock.fastForward('23:00');
  await page.reload();
  await expect(page.locator('[data-testid="discount-badge"]')).toContainText('1h left');
  await expect(page.locator('[data-testid="discount-price"]')).toContainText('$49.99');

  // Fast forward another hour to exceed the 24-hour window
  await page.clock.fastForward('1:01');
  await page.reload();
  
  // Discount should be gone
  await expect(page.locator('[data-testid="discount-badge"]')).not.toBeVisible();
  const regularPrice = await page.locator('[data-testid="product-price"]').textContent();
  expect(regularPrice).toContain('$69.99');
});

Countdowns are similarly straightforward. A countdown timer that ticks down every second doesn't need to actually tick for a real second if we control time:

typescript
test('auction countdown updates correctly', async ({ page }) => {
  await page.clock.install({ time: new Date('2024-01-15T10:00:00Z') });

  await page.goto('https://auction.example.com/item/12345');
  
  // Countdown should start at 5 minutes
  await expect(page.locator('[data-testid="countdown"]')).toContainText('5:00');

  // Jump forward 2 minutes
  await page.clock.fastForward('2:00');
  await expect(page.locator('[data-testid="countdown"]')).toContainText('3:00');

  // Jump to just before the end
  await page.clock.fastForward('2:59');
  await expect(page.locator('[data-testid="countdown"]')).toContainText('0:01');

  // Jump past the end
  await page.clock.fastForward('0:02');
  await expect(page.locator('text=Auction closed')).toBeVisible();
});

The beauty of this approach is speed and reliability. We test expiry logic without waiting, and the test result doesn't depend on system clock accuracy or timing variance.

Set time zones per project

Timezone handling is a common source of bugs in production. A payment deadline that displays differently in New York vs. Tokyo. A scheduled report that runs at the wrong time for users in different regions. Playwright lets us test timezone-dependent behavior at the project level:

typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'chromium-us-eastern',
      use: {
        ...devices['chromium'],
        locale: 'en-US',
        timezoneId: 'America/New_York',
      },
    },
    {
      name: 'chromium-asia-tokyo',
      use: {
        ...devices['chromium'],
        locale: 'ja-JP',
        timezoneId: 'Asia/Tokyo',
      },
    },
    {
      name: 'chromium-europe-london',
      use: {
        ...devices['chromium'],
        locale: 'en-GB',
        timezoneId: 'Europe/London',
      },
    },
  ],
});

Now when we run tests, they execute across multiple timezones automatically. A payment deadline test can verify that a cutoff time displays correctly for users in each region. The same test file, three different timezone contexts:

typescript
test('payment deadline displays correctly in user timezone', async ({ page }) => {
  const deadlineUtc = new Date('2024-01-20T23:59:00Z');
  await page.clock.install({ time: new Date('2024-01-20T10:00:00Z') });

  await page.goto('https://app.example.com/checkout');
  
  // The deadline should render in the browser's configured timezone
  const displayedTime = await page.locator('[data-testid="deadline"]').textContent();
  
  // In US/Eastern: Jan 20, 6:59 PM
  // In Asia/Tokyo: Jan 21, 8:59 AM
  // In Europe/London: Jan 20, 11:59 PM
  expect(displayedTime).toBeTruthy();
  
  // Advance to just before the deadline
  await page.clock.fastForward('13:59');
  await page.reload();
  
  await expect(page.locator('text=Payment deadline approaching')).toBeVisible();

  // Cross the deadline
  await page.clock.fastForward('0:01');
  await page.reload();
  
  await expect(page.locator('text=Payment window closed')).toBeVisible();
});

By combining page.clock with multi-project timezone configuration, we catch edge cases that only manifest in specific regions. No more "works for me" timezone bugs.

Avoid real waiting

The antipattern we see repeatedly in production test suites is real waiting for time-dependent behavior:

typescript
// ❌ Bad: This actually waits 30 seconds
test('token refresh', async ({ page }) => {
  await page.goto('https://app.example.com');
  await page.waitForTimeout(30000); // Real wait
  await expect(page.locator('[data-testid="refresh-indicator"]')).toBeVisible();
});

This is slow, unreliable, and doesn't scale. A test suite with 50 time-dependent tests becomes a 25-minute ordeal. Worse, if the system clock drifts or the network is slow, tests flake unpredictably.

Instead, use page.clock to let time pass instantly in the browser:

typescript
// ✅ Good: Time advances instantly within the browser
test('token refresh', async ({ page }) => {
  await page.clock.install({ time: new Date() });
  await page.goto('https://app.example.com');
  await page.clock.fastForward('30:01');
  await expect(page.locator('[data-testid="refresh-indicator"]')).toBeVisible();
});

The difference is enormous: the test completes in milliseconds instead of 30 seconds.

There are edge cases where real waiting might seem necessary—for instance, if your application uses fetch or a WebSocket that depends on wall-clock time. In those cases, use page.clock.runFor() to run pending timers without actually waiting:

typescript
test('api retry backoff with exponential delay', async ({ page }) => {
  await page.clock.install({ time: new Date() });

  // Start a long-running operation that retries on failure
  await page.goto('https://api.example.com/long-operation');
  page.click('button:has-text("Start")');

  // First retry after 1 second
  await page.clock.runFor('1:01');
  // Check state without actually waiting
  
  // Second retry after 2 seconds
  await page.clock.runFor('2:01');
  
  // Third retry after 4 seconds
  await page.clock.runFor('4:01');

  await expect(page.locator('text=Operation succeeded')).toBeVisible();
});

runFor() executes all setTimeout and setInterval callbacks without calendar time passing. The entire test still completes in milliseconds.

Common mistakes

Forgetting to install the clock before navigation: If you install the clock after visiting a page, JavaScript that ran during page load won't be affected. Always call page.clock.install() before navigating to the application.

Mixing real and fake time: Once you install the clock, all time operations in that page context are faked. Don't mix await page.waitForTimeout() (which respects real time) with page.clock.fastForward() in the same test. Choose one approach consistently.

Not reloading when testing client-side time state: If your application caches a deadline timestamp in memory, advancing the clock doesn't retroactively update that cache. Reload the page or trigger an update to let the application recalculate based on the new clock value.

Assuming Date.now() and new Date() are always synced: Playwright's clock affects both. However, server-side time isn't affected. If your test depends on server state (like a database timestamp), verify that the server's understanding of time matches your faked clock. Consider using API calls to check server state, not just UI assertions.

Testing with fixed time instead of advancing time: setFixedTime() is useful for reproducibility, but fastForward() is better for testing time-dependent logic. Fixed time can mask bugs where your app fails to handle time progression correctly.

Key takeaways

  • Use page.clock.install() and page.clock.fastForward() to test expiry, countdowns, and timeouts without real waiting
  • Configure timezones at the project level to catch region-specific time bugs across multiple test runs
  • Never use await page.waitForTimeout() for time-dependent behavior—replace it with controlled clock advancement
  • Reload the page or trigger updates when testing client-side time state, since advancing the clock doesn't retroactively update cached values
  • Use page.clock.runFor() when you need timers to execute but still want to avoid real time passage
  • Install the clock before navigation to ensure JavaScript running during page load is affected by your faked time
  • Test timezone-dependent features by running the same test suite across multiple configured timezones
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles