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

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(),
}));
/** 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

  1. Step 1.

    Check that your shell passes TZ to 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"
    bar

    Both forms report Europe/Kiev, the machine zone, and envTZ is null although bash exported the variable. Git Bash strips TZ from the environment handed to a native Windows program, while FOO passes through. Nothing errors, so the run looks like a Node that ignores TZ.

  2. 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.mjs
    
    PS> $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/Kathmandu resolves as Asia/Katmandu. ICU canonicalises identifiers, so an assertion on resolvedOptions().timeZone can fail against the string you set.

  3. 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 midnight

    Four 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.

  4. Step 4.

    Read the full failure under the daylight saving zone.

    $env:TZ = 'America/New_York'; npx vitest run l-zone.test.ts
    
     RUN  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 and getHours() 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.

  5. 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 forks pool and fails under threads, where the offset stays at 180 and the zone stays Europe/Kiev. Switching pool for speed loses every in-test zone stub, and the tests that relied on one then assert against the machine zone.

  6. 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.mjs
    
    start       {"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.TZ mid-process takes effect on Windows, and it retunes a Date that already exists, because the assignment goes through a setter. Inside a worker thread process.env is 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

Sign: TZ=America/New_York npx vitest run prints the same results as a run with no TZ at all.Cause: Git Bash on Windows removes TZ from the environment it hands to a native program, measured in step 1, while other variables pass through. The run is real, the zone is not, and every assertion you thought you had covered still ran on the machine zone.
Sign: One test fails on CI and nobody can reproduce it locally.Cause: CI runners default to UTC and developer machines rarely are. Set TZ to the runner zone locally and the failure appears at once. Read the zone from inside the process rather than from the variable, because the variable can be set and ignored.
Sign: A date assertion that only breaks in March and October.Cause: The test crosses a daylight saving transition. A local day is 23 or 25 hours long twice a year, so arithmetic in milliseconds and a local wall-clock hour disagree on exactly those two days and agree on the other 363.
Sign: vi.stubEnv on TZ works in one repository and does nothing in another.Cause: The two use different worker pools. Under forks the assignment reaches the runtime and retunes existing Date objects; under threads process.env is a copy and the assignment is inert. Neither run warns you, and the version that silently does nothing still passes any assertion written against the machine zone.

What to check next

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.

intermediate12 minpublished updated Maks Verny