How to Test Your Code Across Time Zones and Dates With Jetlag

O

Om Shankar

Guest
Here’s a function that decides whether an invoice is due today:

Code:
function isDueToday(invoicenow = new Date()) {
  return invoice.dueDate === now.toISOString().slice();
}

Its test passes on my laptop in the morning, and it passes in CI. Then, from 5 PM on a summer evening in California, it fails. toISOString() always returns the date in UTC, and in UTC it’s already tomorrow.

Nobody runs their tests at 9:30 PM in Los Angeles. Or on February 29. Or on the night New York skips from 1:59 AM straight to 3:00 AM. Or on a machine that writes 1234.5 as 1.234,5. So date bugs like this one sit quietly in passing test suites until a customer finds them.

I built jetlag to run those days now.

One run of jetlag on a small billing module with eight passing tests.

One command, no config​


jetlag wraps the test command you already have:

Code:
npx jetlagged npm test

Or install it once with npm install --global jetlagged and run jetlag npm test. (The name jetlag was already taken on npm, which felt appropriate.)

It runs your tests once at home, then again in ten “destinations”: other time zones, the dates that break date code, and other languages. You don’t write a config file or change a test. When a destination breaks something, jetlag tells you which test failed, what’s different about that place, and how to reproduce it:

Code:
Kathmandu  Oct 3 12:00 UTC+05:45  Asia/Kathmandu
  Nepal is UTC+05:45. Code that assumes offsets are whole hours, or
  rounds getTimezoneOffset() / 60, is 15 to 45 minutes off.
  ✗ the offset label matches the clock
  debug: jetlag exec kathmandu -- npm test

The debug line runs that one destination with your test runner’s full output, so you can fix the bug without guessing.

Eight passing tests, eight bugs​


To see what it catches, I wrote a small billing module the way people write date code on a deadline, with eight tests that all pass on my machine. One run of jetlag broke every one of them:


The code

Breaks in

Because

date.toISOString().slice(0, 10) as “today”

Los Angeles 9:30 PM, Kiritimati, New Year’s Eve

The UTC date isn’t the local date.

date.getTime() + 24 * 60 * 60 * 1000 for “same time tomorrow”

Spring forward

That day has 23 hours.

next.setMonth(next.getMonth() + 1)

January 31

February 31 overflows to March 3.

next.setFullYear(next.getFullYear() + 1)

Leap day

February 29, 2029 doesn’t exist, so it becomes March 1.

`${year}-${month + 2}` for next month’s key

New Year’s Eve

December plus one is month 13.

Math.trunc(-date.getTimezoneOffset() / 60)

Kathmandu

Nepal is 5 hours and 45 minutes ahead of UTC.

amount.toLocaleString()

German

German writes 1234.5 as 1.234,50.

date.toLocaleDateString(undefined, { year: 'numeric' })

Thai

The Buddhist calendar says it’s 2569.

None of these bugs needs an unusual input. They need an unusual day, and jetlag supplies the day. The whole run takes about five seconds on my laptop.

Where it sends your tests​


Each destination changes one thing your laptop never does, so when a test fails you know exactly why.

Time zones. UTC, because that’s where your CI lives. Los Angeles at 9:30 PM, when the UTC date has already rolled over. Kiritimati, which is UTC+14 and starts each day before anyone else. Kathmandu at UTC+05:45. Lord Howe Island, where daylight saving moves the clock by 30 minutes. Antarctica’s Troll station, where it moves by two hours. And “Anywhere on Earth”, UTC-12, whose IANA name is Etc/GMT+12 because POSIX-style zone names flip the sign.

Tricky dates. The next spring-forward and fall-back nights in New York, two minutes to midnight on New Year’s Eve, the next leap day, January 31, the next day whose ISO week-year differs from its calendar year, a Sunday, one second past the 32-bit Unix time limit in 2038, and February 28, 2100 (which is not followed by a February 29).

Languages. German, Turkish, Arabic, Thai, Persian, Hindi, and the plain POSIX “C” locale.

The default trip visits ten of these. --trip world visits all 28, and --tz with --at builds your own:

Code:
jetlag --trip world python3 -m pytest -q
jetlag --tz Europe/Paris --at 2027-03-28T01:30 npm test
jetlag exec kathmandu node     # a Node.js REPL in Kathmandu

Dates like “the next leap day” are worked out from today, so the destinations stay useful as time passes.

How do you move a clock without touching the clock?​


Time zones and languages are the easy part. They’re environment variables: jetlag sets TZ, LANG, and LC_ALL for your command, and every runtime reads them. Node.js even ships its own locale data, so German and Thai formatting work on machines that don’t have those locales installed.

The clock is harder. There’s no environment variable that says “it’s February 29, 2028”. So jetlag loads a small preload into each runtime it knows: through NODE_OPTIONS=--require for Node.js, a sitecustomize.py on PYTHONPATH for Python, and RUBYOPT=-r for Ruby. Here’s a simplified version of the Node.js one:

Code:
const offset = Date.parse(process.env.JETLAG_NOW) - Number(process.env.JETLAG_EPOCH);
const RealDate = Date;
const now = () => RealDate.now() + offset;

function JetlagDate(args) {
  if (!newtarget) return new RealDate(now()).toString();
  const date = args.length ? new RealDate(args) : new RealDate(now());
  if (newtarget !== JetlagDate) Object.setPrototypeOf(date, newtargetprototype);
  return date;
}
JetlagDateprototype = RealDateprototype;
JetlagDate.now = now;
JetlagDate.parse = RealDate.parse;
JetlagDate.UTC = RealDate.UTC;
globalThis.Date = JetlagDate;

Three details make this work in real test runners.

The clock keeps ticking. The preload moves the starting point, not the speed. Timeouts, durations, and performance.now() behave normally.

Every process agrees on the time. Test runners start workers, and workers start seconds after the parent. jetlag passes JETLAG_EPOCH, the real time when the run began, so every process computes the same shifted time.

Jest has its own Date. Jest runs each test file inside a vm context, which comes with its own built-in objects. Replacing globalThis.Date in the parent does nothing in there. So the preload wraps vm.createContext and installs the same patch in every context it creates. Worker threads and child processes pick up the preload on their own through NODE_OPTIONS. One more corner: Intl.DateTimeFormat().format() with no argument reads the clock through the original Date.now, so the preload covers that too.

I’ve tested it with node:test, Jest 30, Vitest 3 (threads, forks, and vmThreads pools), Mocha 11, pytest 9, and unittest, and jetlag’s own test suite runs on macOS and Linux.

Red has to mean something​


A tool like this is only useful if you can trust its red marks, so jetlag works hard not to cry wolf.

  • Home goes first. jetlag runs your suite unchanged before it travels. If a test already fails at home, it doesn’t blame a destination for it; it reads the failing test names (from node:test, Jest, Vitest, Mocha, pytest, unittest, RSpec, Go, Rust, and Gradle output) and reports only new failures.
  • Parallel failures get a second look. Destinations run four at a time. When one fails, jetlag runs it again by itself. If it passes alone, it’s reported as flaky, usually a shared port or file, not as a date bug.
  • It knows when the clock didn’t move. Each preload leaves a marker when it takes effect. If none did, because the tests are written in Go or Java, jetlag reports the date destinations as skipped instead of passed. Time zones and languages still apply.

Put it in CI​


Date bugs are seasonal, so a weekly run that visits every destination catches them before the dates arrive:

Code:
name: jetlag
on:
  schedule:
    - cron: '0 6 * * 1'
  workflow_dispatch:

jobs:
  jetlag:
    runs-on: ubuntu-latest
    steps:
with:
          node-version:
      - run: npm ci
      - run: npx jetlagged --trip world -- npm test

jetlag exits with 1 when a destination breaks a test, so the job goes red.

What it doesn’t do yet​


jetlag can’t move the clock in Go, Rust, Java, or .NET today. On Linux, libfaketime could cover them, and that’s the next thing I want to try. It also hasn’t been tested on Windows. And on Linux, Python’s locale module and Ruby need the system locales installed (for example with locale-gen); Node.js doesn’t.

Try it on your project​


It’s MIT-licensed, has no dependencies, and needs Node.js 20 or later to run. The tests it runs can be in any language.

Code:
npx jetlagged npm test

Run it once and tell me which destination broke your tests. My money is on Los Angeles at 9:30 PM.

The code, the destination list, and two example projects (one in JavaScript, one in Python) are on GitHub: github.com/OmShiv/jetlag.

Happy testing!
 

Thread statistics

Created
Om Shankar,
Replies
0
Views
0
Back
Top