Testing Timezone-Dependent Code with Fake Timers

A test that asserts a formatted date passes on your laptop and fails in CI for one reason: the two machines run in different timezones. Freezing the clock makes the instant deterministic, but the rendering of that instant — toLocaleString, Intl.DateTimeFormat, Date#getHours, Temporal.Now — still depends on the host timezone and locale. This guide shows frontend, full-stack, and platform engineers how to pin both the clock and the zone so timezone-sensitive code is reproducible across machines. Vitest is the primary runner (Jest notes inline), targeting Vitest 1.x/2.x, Node 20+, and the Temporal proposal where available. It extends the foundational time & date control strategies with the zone dimension.

Root Cause Analysis

Freezing time with vi.setSystemTime() fixes the underlying UTC instant, but local-time methods still consult the runtime’s resolved timezone, which comes from the TZ environment variable (or, if unset, the OS setting). So new Date('2026-01-01T00:00:00Z').getHours() returns 0 in UTC, 19 the previous day in America/New_York, and 9 in Asia/Tokyo — three different assertions from one frozen instant. The same divergence hits Intl.DateTimeFormat, which additionally varies by ICU locale data baked into the Node build.

There are therefore two axes of non-determinism, and fake timers only pin one of them. The instant is controlled by the fake clock; the zone and locale are controlled by environment configuration. This mirrors the broader discipline in Advanced Mocking & Service Isolation Patterns: every uncontrolled input — clock, zone, locale — must be replaced by an explicit, pinned stand-in. Teams that pin only the clock see a suite that is green locally and red in CI, the classic environment-mismatch flake tracked under flaky-test mitigation.

A third, easily missed axis is the ICU data set. Intl formatting is driven by the Unicode CLDR locale tables compiled into the runtime, and a Node binary built with small-icu ships only English data while a full-icu build carries every locale. The same Intl.DateTimeFormat('de-DE', …) call can therefore produce German month names on one machine and silently fall back to English on another, even with the instant frozen and the zone pinned. The defensive posture is to treat the runtime as untrusted: pass an explicit locale and timeZone to every formatter, pin one Node version in CI, and never let an assertion depend on a host default you did not set yourself.

Reproducible Setup

Pin the timezone before the runtime starts, because Node resolves TZ once at process boot. Set it in the npm script and in CI, not inside a test file.

# package.json scripts
# "test": "TZ=UTC vitest run"
npm install -D vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    setupFiles: ['./vitest.setup.ts'],
    // env applies before the test module loads
    env: { TZ: 'UTC' },
  },
});
// vitest.setup.ts
import { beforeEach, afterEach, vi } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
});

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

For Jest, set TZ=UTC in the test script identically and use jest.useFakeTimers() / jest.setSystemTime() — the lifecycle is covered in controlling Date.now and setTimeout in Jest.

Implementation

1. Assert in UTC, never in local time. With TZ=UTC and a frozen instant, local-time methods become deterministic because local is UTC.

it('reads the frozen instant in UTC', () => {
  const d = new Date();
  expect(d.toISOString()).toBe('2026-01-01T00:00:00.000Z');
  expect(d.getUTCHours()).toBe(0);
});

2. Pin the zone explicitly inside Intl instead of trusting the host. Passing timeZone to the formatter makes the test robust even if TZ is misconfigured on a runner.

it('formats in a fixed zone regardless of host TZ', () => {
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone: 'America/New_York',
    dateStyle: 'short',
    timeStyle: 'short',
  });
  // 2026-01-01T00:00:00Z is 7:00 PM on 2025-12-31 in New York
  expect(fmt.format(new Date())).toBe('12/31/25, 7:00 PM');
});

3. Always pass an explicit locale. Omitting it falls back to the host locale, which differs across machines. Pinning 'en-US' (or whichever you target) removes that axis.

const labels = new Intl.DateTimeFormat('en-GB', {
  timeZone: 'UTC', month: 'long', day: 'numeric',
});
expect(labels.format(new Date())).toBe('1 January');

4. Test Temporal with an explicit zone. The Temporal API forces you to name a zone, which is exactly the determinism you want; freeze the clock and pass the zone you assert against.

it('derives a zoned datetime from the frozen instant', () => {
  const instant = Temporal.Now.instant();           // frozen by the fake clock
  const zoned = instant.toZonedDateTimeISO('Asia/Tokyo');
  expect(zoned.hour).toBe(9);                         // 00:00 UTC → 09:00 JST
  expect(zoned.toPlainDate().toString()).toBe('2026-01-01');
});

5. Make business logic accept an injected zone. Code that hard-codes the host zone cannot be tested across regions; thread the zone through as a parameter so tests can pin it.

export function startOfBusinessDay(now: Date, timeZone: string): string {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
  }).format(now); // 'YYYY-MM-DD' for that zone's calendar day
}

expect(startOfBusinessDay(new Date(), 'Pacific/Auckland')).toBe('2026-01-01');

6. Cover a daylight-saving transition explicitly. Bugs hide where civil days are not 24 hours long. Freeze an instant near a known DST boundary and assert the wall-clock offset changes as expected, which catches naive + 86_400_000 arithmetic.

it('handles a spring-forward day in a zoned datetime', () => {
  // 2026-03-08 is the US spring-forward date; 07:00 UTC is during the gap
  vi.setSystemTime(new Date('2026-03-08T07:00:00.000Z'));
  const ny = Temporal.Now.instant().toZonedDateTimeISO('America/New_York');
  expect(ny.offset).toBe('-04:00'); // already shifted to EDT, not EST's -05:00
});

Because the clock is frozen at a fixed instant, this assertion is stable forever — the DST rules for a past or pinned date never change, so the test documents the boundary behaviour without ever flaking.

Verification

A correctly pinned suite produces the same output on every machine:

✓ reads the frozen instant in UTC
✓ formats in a fixed zone regardless of host TZ
✓ derives a zoned datetime from the frozen instant

Test Files  1 passed (1)
     Tests  5 passed (5)

Prove the pin holds by reading the resolved zone at runtime — it should equal what you configured, not the developer’s local zone:

expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('UTC');

If that assertion reads a city zone instead of UTC, TZ was not applied before the process booted — fix the script or CI env, not the test.

Troubleshooting

Assertion passes locally, fails in CI (or vice versa). The two environments resolve different timezones because TZ is unset on one. Set TZ=UTC (or your target zone) in the test script and the CI runner so both boot identically; never rely on the OS default.

Intl.DateTimeFormat output differs between Node versions. Formatting depends on the ICU data compiled into Node, so a full-icu vs small-icu build changes locale rendering. Pin a single Node version in CI and pass explicit locale and timeZone to every formatter so behaviour is independent of host defaults.

Temporal is not defined. The proposal is not in your runtime. Add a polyfill such as @js-temporal/polyfill in the setup file, or assert with Intl/Date instead. Mixing the polyfill with native Temporal across Node versions can drift, so choose one source consistently.

FAQ

Why isn’t freezing the clock with fake timers enough on its own?

Because the fake clock only fixes the UTC instant, while local-time methods and formatters still read the host timezone and locale. You must pin TZ (and pass explicit timeZone/locale to Intl) so the rendering of that instant is also deterministic across machines.

Can I change the timezone inside a single test instead of setting TZ globally?

Not reliably for Date local-time methods, because Node resolves TZ once at process start and reassigning process.env.TZ mid-run is not honoured consistently. The robust approach is to pass an explicit timeZone to Intl.DateTimeFormat or Temporal, which lets one test cover multiple zones without restarting the process.

Does this work the same in Jest?

Yes — set TZ=UTC in the Jest test script, use jest.useFakeTimers() and jest.setSystemTime(), and pass explicit timeZone/locale to formatters exactly as shown. Both runners share the @sinonjs/fake-timers engine, so only the namespace differs.

How do I keep date fixtures deterministic too?

Pin the data the same way you pin the clock: generate fixtures from a fixed seed rather than new Date() at creation time. Combine this guide with deterministic seeding for test data in Vitest so both the clock and the inputs are reproducible.