Timezone and Locale-Sensitive Testing: Jest, Playwright, and CI Patterns

Timezone and Locale-Sensitive Testing: Jest, Playwright, and CI Patterns

Timezone and locale bugs are notoriously hard to catch: they pass on developer machines and fail in production, or fail in CI only in certain months when DST shifts. This guide covers practical patterns for making timezone and locale-sensitive code testable and deterministic.

Mocking Date in Jest

The most common need is freezing time to a known instant. Jest's fake timers handle this:

describe('formatRelativeTime', () => {
  beforeEach(() => {
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2024-03-15T12:00:00Z'));
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('shows "2 hours ago" for event 2 hours before now', () => {
    const eventTime = new Date('2024-03-15T10:00:00Z');
    expect(formatRelativeTime(eventTime)).toBe('2 hours ago');
  });

  test('shows "yesterday" for event at previous midnight UTC', () => {
    const eventTime = new Date('2024-03-14T23:59:00Z');
    expect(formatRelativeTime(eventTime)).toBe('yesterday');
  });
});

For tests that construct new Date() internally without accepting a clock parameter, fake timers are the only reliable option.

The TZ Environment Variable

For Node.js tests that depend on Intl or Date timezone behavior, set TZ before the process starts:

# Run a specific test file in Tokyo time
TZ=Asia/Tokyo npx jest timezone.test.js

<span class="hljs-comment"># Jest config for all tests
<span class="hljs-comment"># jest.config.js
module.exports = {
  testEnvironment: <span class="hljs-string">'node',
  globalSetup: <span class="hljs-string">'./jest-global-setup.js',
};
// jest-global-setup.js
module.exports = async () => {
  process.env.TZ = 'UTC';
};

Setting TZ=UTC in global setup eliminates flakiness from developer machines in different timezones. All time assertions become deterministic.

For tests that need to exercise a specific timezone, pass it inline:

// In package.json scripts
"test:tokyo": "TZ=Asia/Tokyo jest --testPathPattern=timezone"

Isolating Timezone Logic

The most testable timezone code passes the timezone as a parameter rather than reading it from the environment:

// Hard to test — reads system timezone
function formatMeetingTime(date) {
  return new Intl.DateTimeFormat(undefined, {
    timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    hour: '2-digit',
    minute: '2-digit',
  }).format(date);
}

// Testable — accepts timezone
function formatMeetingTime(date, timeZone = 'UTC') {
  return new Intl.DateTimeFormat('en-US', {
    timeZone,
    hour: '2-digit',
    minute: '2-digit',
    hour12: false,
  }).format(date);
}

Now tests are explicit:

const NOON_UTC = new Date('2024-06-15T12:00:00Z');

test('formats noon UTC correctly in New York (EDT)', () => {
  expect(formatMeetingTime(NOON_UTC, 'America/New_York')).toBe('08:00');
});

test('formats noon UTC correctly in Tokyo (JST)', () => {
  expect(formatMeetingTime(NOON_UTC, 'Asia/Tokyo')).toBe('21:00');
});

test('formats noon UTC correctly in London (BST in June)', () => {
  expect(formatMeetingTime(NOON_UTC, 'Europe/London')).toBe('13:00');
});

DST Edge Cases

Daylight saving transitions are the richest source of timezone bugs. Test the exact transition moments:

describe('DST transition handling', () => {
  // US EDT → EST transition: clocks fall back 2am → 1am
  const DST_END_2024 = new Date('2024-11-03T06:00:00Z'); // 2am ET = 6am UTC

  test('handles the ambiguous hour at DST end', () => {
    // 1:30am ET exists twice on this day
    const ambiguous = new Date('2024-11-03T06:30:00Z'); // first 1:30am
    const result = formatMeetingTime(ambiguous, 'America/New_York');
    expect(result).toBe('01:30');
  });

  test('30-minute offset timezone handles DST correctly', () => {
    // India Standard Time: UTC+5:30, no DST
    const noonUTC = new Date('2024-07-01T12:00:00Z');
    expect(formatMeetingTime(noonUTC, 'Asia/Kolkata')).toBe('17:30');
  });

  test('does not shift date across midnight due to timezone offset', () => {
    // 11pm UTC should be next day in Tokyo
    const lateUTC = new Date('2024-01-15T23:00:00Z');
    const formatted = new Intl.DateTimeFormat('en-US', {
      timeZone: 'Asia/Tokyo',
      dateStyle: 'short',
    }).format(lateUTC);
    expect(formatted).toBe('1/16/2024');
  });
});

Locale-Sensitive Format Testing

Intl.NumberFormat and Intl.DateTimeFormat produce different output depending on locale. Test them explicitly:

describe('locale-sensitive formatting', () => {
  test.each([
    ['en-US', 1234567.89, '$1,234,567.89'],
    ['de-DE', 1234567.89, '1.234.567,89\u00a0€'],
    ['ja-JP', 1234567.89, '¥1,234,568'],
    ['ar-SA', 1234567.89, '١٬٢٣٤٬٥٦٧٫٨٩\u00a0ر.س.'],
  ])('%s formats currency correctly', (locale, amount, expected) => {
    const formatted = new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: locale === 'de-DE' ? 'EUR' : locale === 'ja-JP' ? 'JPY' : locale === 'ar-SA' ? 'SAR' : 'USD',
    }).format(amount);
    expect(formatted).toBe(expected);
  });
});

Note: Intl output can vary between Node.js versions depending on the ICU data version bundled. Pin your Node.js version in CI to avoid flaky format tests.

Database Timezone Testing

If your app stores timestamps in a database, test that the read/write round-trip preserves timezone intent:

// PostgreSQL: ensure timestamps are stored and retrieved in UTC
test('stores meeting time in UTC regardless of server timezone', async () => {
  const meetingTime = new Date('2024-06-15T15:00:00-05:00'); // 3pm CDT
  const id = await db.query(
    'INSERT INTO meetings (start_time) VALUES ($1) RETURNING id',
    [meetingTime]
  );

  const result = await db.query(
    'SELECT start_time AT TIME ZONE \'UTC\' as utc_time FROM meetings WHERE id = $1',
    [id.rows[0].id]
  );

  const stored = new Date(result.rows[0].utc_time);
  expect(stored.toISOString()).toBe('2024-06-15T20:00:00.000Z'); // 3pm CDT = 8pm UTC
});

For SQLite tests, which don't handle timezones natively, always store ISO strings:

test('sqlite stores and retrieves UTC ISO string unchanged', async () => {
  const ts = '2024-06-15T20:00:00.000Z';
  await db.run('INSERT INTO events (ts) VALUES (?)', ts);
  const row = await db.get('SELECT ts FROM events ORDER BY rowid DESC LIMIT 1');
  expect(row.ts).toBe(ts);
});

Playwright Tests with Timezone Emulation

Playwright can emulate timezone in the browser context:

// playwright.config.js
export default {
  projects: [
    { name: 'UTC', use: { timezoneId: 'UTC' } },
    { name: 'New York', use: { timezoneId: 'America/New_York' } },
    { name: 'Tokyo', use: { timezoneId: 'Asia/Tokyo' } },
  ],
};

Or per-test:

test('dashboard shows correct local time in Tokyo', async ({ browser }) => {
  const context = await browser.newContext({ timezoneId: 'Asia/Tokyo' });
  const page = await context.newPage();

  // Freeze time in the browser
  await page.addInitScript(() => {
    const FIXED = new Date('2024-06-15T12:00:00Z').getTime();
    Date.now = () => FIXED;
    globalThis.Date = class extends Date {
      constructor(...args) {
        super(args.length === 0 ? FIXED : ...args);
      }
    };
  });

  await page.goto('/dashboard');
  await expect(page.getByTestId('current-time')).toHaveText('21:00 JST');
  await context.close();
});

CI Matrix for Timezone Coverage

Run tests across multiple timezones in CI to catch environment-dependent failures:

# .github/workflows/timezone-tests.yml
jobs:
  test-timezones:
    strategy:
      matrix:
        tz: [UTC, America/New_York, Europe/London, Asia/Tokyo, Australia/Sydney]
    env:
      TZ: ${{ matrix.tz }}
    steps:
      - run: npx jest --testPathPattern="timezone|date|format"

This surfaces bugs that only occur when the server's local timezone affects date arithmetic — a common failure mode when code uses new Date().toLocaleDateString() instead of explicit timeZone options.

Quick Reference

Scenario Tool
Freeze current time in Jest jest.useFakeTimers() + jest.setSystemTime()
Set Node.js timezone TZ=UTC env var before process start
Parameterize timezone in code Accept timeZone as argument, default to 'UTC'
DST transitions Test the exact UTC instant of each transition
Browser timezone in Playwright browser.newContext({ timezoneId })
CI timezone matrix strategy.matrix.tz with TZ env var
Locale format assertions Pin Node.js version; use test.each for locale table

Read more