edgegoat Guides
How to test time zones in JavaScript
Most date bugs are invisible in the time zone of the developer who wrote the code. The fix is to run the same assertions in zones that disagree with UTC, at the instants where they disagree the most: local midnight and New Year.
The bug: deciding "this year" in UTC
GitHub's <relative-time> element hides the year for dates in the current year ("Mar 4") and shows it otherwise ("Mar 4, 2025"). The check compared years in UTC instead of in the display time zone, so around New Year the year was shown or hidden incorrectly. It was fixed in github/relative-time-element#384.
The window is not small. At 2025-12-31T10:00:00Z it is already 2026 in Pacific/Kiritimati (UTC+14:00), and at 2026-01-01T03:00:00Z it is still 2025 in New York. Every zone away from UTC has hours each year where the local year and the UTC year disagree.
A naive implementation that fails
// Shows "Dec 31" for dates in the current year and "Dec 31, 2025" otherwise.
function formatShort(date, timeZone) {
const sameYear = date.getUTCFullYear() === new Date().getUTCFullYear();
return new Intl.DateTimeFormat('en-US', {
timeZone,
month: 'short',
day: 'numeric',
...(sameYear ? {} : { year: 'numeric' }),
}).format(date);
}The edgegoat test that catches it
dates({ tags: ['year-boundary'] }) returns, for each zone, the instant where the local year and the UTC year disagree. Freeze "now" at that instant and format a date one hour away, on the other side of the local New Year:
import { expect, test } from 'vitest';
import { dates, withFrozenTime } from 'edgegoat';
const HOUR = 3_600_000;
const yearIn = (d, timeZone) => new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric' }).format(d);
const edges = dates({ tags: ['year-boundary'] }).filter((c) => c.zone);
test.each(edges)('$label', ({ value, zone }) => {
const before = new Date(value.getTime() - HOUR);
const after = new Date(value.getTime() + HOUR);
const other = yearIn(before, zone) !== yearIn(value, zone) ? before : after;
const out = withFrozenTime(value, () => formatShort(other, zone));
expect(out).toContain(yearIn(other, zone));
});yearIn(date, zone) with yearIn(new Date(), zone).The same test lives in the repository as a runnable example: examples/real-world.
Run the whole suite in another time zone
The TZ environment variable sets the zone of a Node.js process. Run your existing suite under zones with unusual offsets; most suites that only ever ran in UTC will fail somewhere.
TZ=Asia/Kathmandu npx vitest run
TZ=Pacific/Kiritimati npx jest
# Windows PowerShell
$env:TZ = 'Asia/Kathmandu'; npx vitest runIn CI, add a matrix entry per zone. edgegoat's own workflow runs the suite in UTC, America/Sao_Paulo and Asia/Kathmandu:
strategy:
matrix:
tz: [UTC, America/Sao_Paulo, Asia/Kathmandu]
env:
TZ: ${{ matrix.tz }}Switch zones inside a single test
withTimeZone(zone, fn) sets process.env.TZ, verifies that the runtime applied it, runs fn and restores the previous zone, also when fn throws or returns a rejected promise.
import { withTimeZone } from 'edgegoat';
withTimeZone('Asia/Kathmandu', () => {
new Date('2026-01-01T00:00:00Z').getTimezoneOffset(); // -345
});withTimeZone with test.concurrent. In browsers it throws, because a page cannot change its own time zone; launch the browser in the zone instead (for example Playwright's timezoneId option).Which time zones to test
- UTC: the control. Bugs hide here, which is why CI that only runs in UTC misses them.
- America/New_York and Pacific/Pago_Pago (UTC-11:00): local dates lag behind UTC.
- Pacific/Kiritimati (UTC+14:00): local dates run ahead; first place to reach New Year.
- Asia/Kathmandu (UTC+05:45) and Asia/Kolkata (UTC+05:30): offsets that are not whole hours.
- Australia/Lord_Howe: DST that shifts by 30 minutes.
- America/Sao_Paulo before 2019: DST changes at midnight, so some days had no 00:00.
timeZones() returns 18 such zones, each with the reason it is interesting.
FAQ
Run the same date assertions under several time zones, either for the whole process with the TZ environment variable or per test with withTimeZone(), and feed them instants where local and UTC dates disagree, such as the year-boundary cases from dates().
Yes. Assigning process.env.TZ makes Node.js reset its cached zone, so later Date and Intl calls use the new one. Deleting the variable does not reset it, which is why withTimeZone() switches back explicitly.
A page cannot change its own zone at runtime. Launch the browser in the zone: Playwright has a timezoneId option, and Chrome DevTools Protocol has Emulation.setTimezoneOverride.
CI runners usually run in UTC while your machine does not, so dates near midnight or New Year land on different days. Pin TZ in CI and add matrix entries with zones far from UTC.
More guides
- How to test daylight saving time in Jest
- Unicode test strings for JavaScript
- JavaScript number edge cases to test
- Testing dates before 1970 in JavaScript
- Negative zero in JavaScript: why -0 is different from 0
- How to mock Date in JavaScript tests
- Property based testing and curated edge cases in JavaScript