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.
Read together, these three axes define a layered defense: each uncontrolled input needs its own explicit pin, and a missing layer is exactly where the flake enters. The fake clock pins the instant, the TZ environment variable pins the resolved zone, an explicit locale and timeZone on every formatter pin the rendering, and a single pinned Node version removes ICU drift. Only when all four are in place does the same assertion produce a byte-identical result on your laptop and every CI runner.
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
The patterns below move from the safest assertion style to the most demanding. The guiding principle throughout is to remove every host default from the equation: assert against UTC where you can, and where the code genuinely depends on a civil zone, name that zone explicitly rather than inheriting it from the runner. Each step assumes the frozen clock and TZ=UTC from the setup above are already in place, so the only remaining variable is how the instant is rendered.
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.
An assertion breaks only twice a year. A test that passes for months and then fails around March or November is almost always reading the host’s live zone across a daylight-saving change rather than a pinned one. Because you froze the instant but let a formatter fall back to the runner’s default zone, the offset flips when the runner’s civil clock does. Freeze the instant and pass an explicit timeZone to every formatter so the test is anchored to a specific rule set, not to whatever the calendar happens to be doing on the day CI runs.
Two-digit years or unexpected separators appear. dateStyle and timeStyle expand to locale-specific patterns, so 'short' renders 12/31/25 in en-US and 31/12/2025 in en-GB. If an assertion is brittle to that, either pin the exact locale you assert against or build the string from explicit year, month, and day components with the widths you want, which removes the dependence on a locale’s default style entirely.
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.
Should I set TZ in the config or the npm script?
Prefer the npm script ("test": "TZ=UTC vitest run") as the source of truth because it applies before the Node process boots, which is when the runtime resolves the zone once and for all. The env block in vitest.config.ts is a useful backstop that documents the intent in the repo, but relying on it alone is risky if a developer runs a single file through an IDE that bypasses the script. Set it in both places and treat the script as authoritative.
Why assert against UTC when my users are in local time?
Because the test is verifying your logic, not your users’ clocks, and UTC removes the one variable that has nothing to do with correctness. When the behaviour genuinely depends on a civil zone — a “start of business day” calculation, say — thread that zone in as an explicit parameter and assert against it directly, as in Step 5. That keeps the zone a controlled input of the test rather than an ambient property of the machine, which is the whole point of pinning it.
Related
- Back to Time & Date Control Strategies
- Controlling Date.now and setTimeout in Jest — the fake-timer lifecycle this page builds on.
- Deterministic seeding for test data in Vitest — pin fixture inputs as well as the zone.
- HTTP Request Stubbing Techniques — align client and server timestamps under one frozen clock.