How to run jest tests with a specific timezone
Set the zone in the shell that starts the runner and run the suite again, $env:TZ = 'Pacific/Kiritimati' in PowerShell. Assertions that read a local calendar field move with the offset, so one extra run names every timezone-dependent test instead of leaving them for the CI machine to find.
Why check this
A suite that passes on a laptop in UTC+3 and fails on a CI runner in UTC is not flaky. It reads the local calendar somewhere, and the two machines disagree about what day it is.
Run this before trusting a date assertion, when CI disagrees with a local run, and after adding a library that formats dates. The failure it prevents: an event recorded at 23:30 UTC filed under the following day for every user east of London, because the grouping key came from getDate().
Prerequisites
- Node 22.23.2 and vitest 5.0.0 here, with full ICU and tzdata 2026a. Read your own with
npx vitest --version. - Four zones, chosen for shape: UTC,
Asia/Kathmanduat UTC+05:45,America/New_Yorkfor daylight saving,Pacific/Kiritimatiat UTC+14. The IANA database holds the rest. - A probe that prints the zone the process resolved, saved as
zone.mjs.
const d = new Date('2026-07-01T12:00:00Z');
console.log(JSON.stringify({
envTZ: process.env.TZ ?? null,
resolved: Intl.DateTimeFormat().resolvedOptions().timeZone,
localString: d.toString().slice(0, 33),
offsetMinutes: -d.getTimezoneOffset(),
}));
- The code under test, saved as
calendar.ts, andl-zone.test.tsnext to it.
/** Groups an event into a calendar day using the local calendar. */
export function dayKey(ts: number): string {
const d = new Date(ts);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
// l-zone.test.ts
import { it, expect } from 'vitest';
import { dayKey } from './calendar';
it('reports the zone it ran under', () => {
console.log('TZ =', process.env.TZ ?? '(unset)',
'| resolved =', Intl.DateTimeFormat().resolvedOptions().timeZone);
expect(true).toBe(true);
});
it('an event at 23:30 UTC belongs to that day', () => {
expect(dayKey(Date.parse('2026-07-01T23:30:00Z'))).toBe('2026-07-01');
});
it('a date-only string parses to midnight', () => {
expect(new Date('2026-03-08').getHours()).toBe(0);
});
it('02:30 on 2026-03-08 is 02:30', () => {
expect(new Date(2026, 2, 8, 2, 30).getHours()).toBe(2);
});
Steps
- Step 1.
Check that your shell passes
TZto the process it starts.TZ=Asia/Kathmandu node zone.mjs export TZ=Asia/Kathmandu; node zone.mjs echo "bash sees TZ=$TZ" node -p "process.env.TZ" FOO=bar node -p "process.env.FOO"$ TZ=Asia/Kathmandu node zone.mjs {"envTZ":null,"resolved":"Europe/Kiev","localString":"Wed Jul 01 2026 15:00:00 GMT+0300","offsetMinutes":180} $ export TZ=Asia/Kathmandu; node zone.mjs {"envTZ":null,"resolved":"Europe/Kiev","localString":"Wed Jul 01 2026 15:00:00 GMT+0300","offsetMinutes":180} $ echo "bash sees TZ=$TZ" bash sees TZ=Asia/Kathmandu $ node -p "process.env.TZ" undefined $ FOO=bar node -p "process.env.FOO" barBoth forms report
Europe/Kiev, the machine zone, andenvTZis null although bash exported the variable. Git Bash stripsTZfrom the environment handed to a native Windows program, whileFOOpasses through. Nothing errors, so the run looks like a Node that ignoresTZ. - Step 2.
Set the variable from PowerShell and read back what each zone resolved to.
# run-tz.ps1 foreach ($z in 'UTC','Asia/Kathmandu','America/New_York','Pacific/Kiritimati') { $env:TZ = $z Write-Output "PS> `$env:TZ = '$z'; node zone.mjs" node zone.mjs } Remove-Item Env:\TZ Write-Output "PS> Remove-Item Env:\TZ; node zone.mjs" node zone.mjsPS> $env:TZ = 'UTC'; node zone.mjs {"envTZ":"UTC","resolved":"UTC","localString":"Wed Jul 01 2026 12:00:00 GMT+0000","offsetMinutes":0} PS> $env:TZ = 'Asia/Kathmandu'; node zone.mjs {"envTZ":"Asia/Kathmandu","resolved":"Asia/Katmandu","localString":"Wed Jul 01 2026 17:45:00 GMT+0545","offsetMinutes":345} PS> $env:TZ = 'America/New_York'; node zone.mjs {"envTZ":"America/New_York","resolved":"America/New_York","localString":"Wed Jul 01 2026 08:00:00 GMT-0400","offsetMinutes":-240} PS> $env:TZ = 'Pacific/Kiritimati'; node zone.mjs {"envTZ":"Pacific/Kiritimati","resolved":"Pacific/Kiritimati","localString":"Thu Jul 02 2026 02:00:00 GMT+1400","offsetMinutes":840} PS> Remove-Item Env:\TZ; node zone.mjs {"envTZ":null,"resolved":"Europe/Kiev","localString":"Wed Jul 01 2026 15:00:00 GMT+0300","offsetMinutes":180}Each zone takes effect, including the 345 minute offset and the UTC+14 zone where the same instant already falls on 2 July. Note line two:
Asia/Kathmanduresolves asAsia/Katmandu. ICU canonicalises identifiers, so an assertion onresolvedOptions().timeZonecan fail against the string you set. - Step 3.
Run the same file under each zone and compare what survives.
# run-zones.ps1, filtered so four runs fit in one screen foreach ($z in 'UTC','Asia/Kathmandu','America/New_York','Pacific/Kiritimati') { $env:TZ = $z Write-Output "PS> `$env:TZ = '$z'; npx vitest run l-zone.test.ts --reporter=verbose" npx vitest run l-zone.test.ts --reporter=verbose 2>&1 | Select-String -Pattern '(TZ = |l-zone\.test\.ts > )' | ForEach-Object { $_.Line.TrimEnd() } }PS> $env:TZ = 'UTC'; npx vitest run l-zone.test.ts --reporter=verbose stdout | l-zone.test.ts > reports the zone it ran under TZ = UTC | resolved = UTC ✓ l-zone.test.ts > reports the zone it ran under 17ms ✓ l-zone.test.ts > an event at 23:30 UTC belongs to that day 0ms ✓ l-zone.test.ts > a date-only string parses to midnight 0ms ✓ l-zone.test.ts > 02:30 on 2026-03-08 is 02:30 0ms PS> $env:TZ = 'Asia/Kathmandu'; npx vitest run l-zone.test.ts --reporter=verbose stdout | l-zone.test.ts > reports the zone it ran under TZ = Asia/Kathmandu | resolved = Asia/Katmandu ✓ l-zone.test.ts > reports the zone it ran under 16ms × l-zone.test.ts > an event at 23:30 UTC belongs to that day 5ms × l-zone.test.ts > a date-only string parses to midnight 2ms ✓ l-zone.test.ts > 02:30 on 2026-03-08 is 02:30 0ms FAIL l-zone.test.ts > an event at 23:30 UTC belongs to that day FAIL l-zone.test.ts > a date-only string parses to midnight PS> $env:TZ = 'America/New_York'; npx vitest run l-zone.test.ts --reporter=verbose stdout | l-zone.test.ts > reports the zone it ran under TZ = America/New_York | resolved = America/New_York ✓ l-zone.test.ts > reports the zone it ran under 17ms ✓ l-zone.test.ts > an event at 23:30 UTC belongs to that day 0ms × l-zone.test.ts > a date-only string parses to midnight 6ms × l-zone.test.ts > 02:30 on 2026-03-08 is 02:30 1ms FAIL l-zone.test.ts > a date-only string parses to midnight FAIL l-zone.test.ts > 02:30 on 2026-03-08 is 02:30 PS> $env:TZ = 'Pacific/Kiritimati'; npx vitest run l-zone.test.ts --reporter=verbose stdout | l-zone.test.ts > reports the zone it ran under TZ = Pacific/Kiritimati | resolved = Pacific/Kiritimati ✓ l-zone.test.ts > reports the zone it ran under 16ms × l-zone.test.ts > an event at 23:30 UTC belongs to that day 5ms × l-zone.test.ts > a date-only string parses to midnight 2ms ✓ l-zone.test.ts > 02:30 on 2026-03-08 is 02:30 0ms FAIL l-zone.test.ts > an event at 23:30 UTC belongs to that day FAIL l-zone.test.ts > a date-only string parses to midnightFour tests, four zones, three verdicts. The day-grouping assertion holds in UTC and New York and breaks at UTC+05:45 and UTC+14. The date-only string is midnight in UTC alone. Only New York fails the 02:30 assertion. One zone would have found half of this.
- Step 4.
Read the full failure under the daylight saving zone.
$env:TZ = 'America/New_York'; npx vitest run l-zone.test.tsRUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz ❯ l-zone.test.ts (4 tests | 2 failed) 47ms × a date-only string parses to midnight 11ms × 02:30 on 2026-03-08 is 02:30 2ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL l-zone.test.ts > a date-only string parses to midnight AssertionError: expected 19 to be +0 // Object.is equality - Expected + Received - 0 + 19 ❯ l-zone.test.ts:15:45 13| 14| it('a date-only string parses to midnight', () => { 15| expect(new Date('2026-03-08').getHours()).toBe(0); | ^ 16| }); 17| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ FAIL l-zone.test.ts > 02:30 on 2026-03-08 is 02:30 AssertionError: expected 3 to be 2 // Object.is equality - Expected + Received - 2 + 3 ❯ l-zone.test.ts:19:50 17| 18| it('02:30 on 2026-03-08 is 02:30', () => { 19| expect(new Date(2026, 2, 8, 2, 30).getHours()).toBe(2); | ^ 20| }); 21| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ Test Files 1 failed (1) Tests 2 failed | 2 passed (4) Start at 04:08:44 Duration 483ms (tests 39%, transform 37%, import 17%, worker 7%)new Date('2026-03-08')parses as UTC midnight by specification, so in New York it is 19:00 on 7 March andgetHours()returns 19. The second failure is stranger: 02:30 on 8 March does not exist there, because the clock jumps from 02:00 to 03:00, and the runtime returns 03:30 rather than an error. The footer,Start at 04:08:44, is the local clock of the run. - Step 5.
Change the zone from inside a test, under each worker pool.
import { it, expect, vi, afterEach } from 'vitest'; afterEach(() => { vi.unstubAllEnvs(); }); it('changes the zone from inside the test', () => { const d = new Date('2026-07-01T12:00:00Z'); const before = -d.getTimezoneOffset(); vi.stubEnv('TZ', 'Asia/Tokyo'); const after = -d.getTimezoneOffset(); console.log(JSON.stringify({ pool: process.env.VITEST_POOL_ID ? 'worker' : 'main', before, after, resolved: Intl.DateTimeFormat().resolvedOptions().timeZone, })); expect(after).toBe(540); });echo "=== pool: forks (default) ===" npx vitest run k-tz-runtime.test.ts --reporter=verbose echo "=== pool: threads ===" npx vitest run k-tz-runtime.test.ts --pool=threads --reporter=verbose=== pool: forks (default) === RUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz stdout | k-tz-runtime.test.ts > changes the zone from inside the test {"pool":"worker","before":180,"after":540,"resolved":"Asia/Tokyo"} ✓ k-tz-runtime.test.ts > changes the zone from inside the test 18ms Test Files 1 passed (1) Tests 1 passed (1) Start at 11:21:54 Duration 259ms (import 31%, tests 31%, transform 30%, worker 7%) === pool: threads === RUN v5.0.0 D:/how2check/scratch/fake-timers-date-tz stdout | k-tz-runtime.test.ts > changes the zone from inside the test {"pool":"worker","before":180,"after":180,"resolved":"Europe/Kiev"} × k-tz-runtime.test.ts > changes the zone from inside the test 12ms → expected 180 to be 540 // Object.is equality ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL k-tz-runtime.test.ts > changes the zone from inside the test AssertionError: expected 180 to be 540 // Object.is equality - Expected + Received - 540 + 180 ❯ k-tz-runtime.test.ts:16:17 14| resolved: Intl.DateTimeFormat().resolvedOptions().timeZone, 15| })); 16| expect(after).toBe(540); | ^ 17| }); 18| ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed (1) Start at 11:21:56 Duration 206ms (transform 35%, tests 31%, import 23%, worker 11%)The same test passes under the default
forkspool and fails underthreads, where the offset stays at 180 and the zone staysEurope/Kiev. Switching pool for speed loses every in-test zone stub, and the tests that relied on one then assert against the machine zone. - Step 6.
Confirm the mechanism in plain Node.
const d = new Date('2026-07-01T12:00:00Z'); const read = () => ({ resolved: Intl.DateTimeFormat().resolvedOptions().timeZone, offset: -d.getTimezoneOffset(), local: d.toString().slice(0, 24), }); console.log('start ', JSON.stringify(read())); process.env.TZ = 'Asia/Tokyo'; console.log('after set ', JSON.stringify(read()));node runtime-tz.mjsstart {"resolved":"Europe/Kiev","offset":180,"local":"Wed Jul 01 2026 15:00:00"} after set {"resolved":"Asia/Tokyo","offset":540,"local":"Wed Jul 01 2026 21:00:00"} new object {"offset":540,"resolved":"Asia/Tokyo"}Assigning
process.env.TZmid-process takes effect on Windows, and it retunes aDatethat already exists, because the assignment goes through a setter. Inside a worker threadprocess.envis a plain copy with no setter, which is what step 5 shows.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| envTZ is null after you exported TZ | The shell dropped the variable | Set it in PowerShell, or start the run from a shell that passes it |
| The suite is green in UTC and red at UTC+05:45 | Something reads a local calendar field | Assert on the instant or on a UTC field, not on getDate() |
| getHours() returns 19 for a date-only string | The string parsed as UTC midnight | Write 2026-03-08T00:00:00 when you mean local midnight |
| An hour is one higher than the literal you passed | The local time does not exist that day | Build the date from an instant, not from local components |
| resolvedOptions().timeZone differs from TZ | ICU canonicalised the identifier | Compare offsets, not zone names |
| A zone stub inside a test changes nothing | The run uses the threads pool | Set the zone outside the process, or move that file to forks |
Common mistakes
What to check next
- How to mock date in jest: freeze the instant, then run that frozen test under these zones.
- How to test with fake time: the clock the runner replaces, which is separate from the zone it reads.
- How to test timezone handling: the conversion rules the failing assertions above depend on.
- How to change timezone in chrome for testing: the same check for code that runs in the browser.
- How to test daylight saving time: the transition behind the 02:30 failure, on its own.
FAQ
How do I mock the timezone in jest?
There is no zone mock. Set TZ in the environment that starts the runner, which jest and vitest both read through Node. Setting it inside a test works only under child processes, shown in step 5.
Which zones should a suite run under?
At least three, chosen for shape: UTC, one with a fractional offset such as Asia/Kathmandu, one with daylight saving such as America/New_York. Add Pacific/Kiritimati when a calendar day matters.
How do I reproduce the CI machine timezone locally?
Print Intl.DateTimeFormat().resolvedOptions().timeZone in a CI step, set that value locally, run again. Read the resolved zone rather than the variable: a shell can accept TZ and hand the process nothing, which is step 1.
Which assertions are timezone-dependent?
Any that reaches a local field: getFullYear, getMonth, getDate, getHours, toString, toLocaleDateString, and a Date built from local components. Instant comparisons, toISOString and the UTC getters are stable.
Should I set the timezone in the config file instead?
A config value is read after the process starts, too late for every runner. Setting it in the command or the CI job environment applies before the first Date exists and needs no pool-specific behaviour.
Verified
Verified by Maks Vernyvitest 5.0.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny