On this page 4 sections
Short one. A test that had passed for eight months failed on a new CI runner, and the reason was a small enough lesson that I wanted to write it down.
Our app shows trial users a banner: “3 days left in your trial.” The function behind it had a test, the test pinned the clock, and everything was green. Then we added a self-hosted runner in our Berlin office, and the same test failed there every time.
The function
const DAY_MS = 86_400_000;
export function daysLeft(trialEndsAt: Date, now: Date = new Date()): number {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(trialEndsAt);
end.setHours(0, 0, 0, 0);
return Math.floor((end.getTime() - start.getTime()) / DAY_MS);
}It looks reasonable. Snap both dates to local midnight, subtract, divide by the length of a day.
The problem is that last word. In time zones with daylight saving, a day is not always 24 hours. On the last Sunday of March in Europe, local time jumps from 02:00 to 03:00, so that calendar day is 23 hours long. Between local midnight on 27 March 2026 and local midnight on 30 March 2026, there are three calendar days but only 71 hours. 71 divided by 24 is 2.96, and Math.floor turns that into 2.
So for anyone in Europe whose trial ended in the three days after the spring change, the banner was off by one. On UTC servers and on our laptops in Osaka, where there is no DST, the bug did not exist.
Why our test did not catch it
The test pinned the clock correctly. It just pinned it to a date in June, and it ran in whatever time zone the machine happened to use. Pinning the time is only half of controlling time: the time zone is part of the input too.
Here is the test we have now, using Vitest’s fake timers:
// trial.test.ts: describe, it, expect, vi and afterEach come from vitest
describe('daysLeft', () => {
afterEach(() => {
vi.useRealTimers();
});
it('counts calendar days across the spring DST change', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-27T09:00:00+01:00'));
expect(daysLeft(new Date('2026-03-30T18:00:00+02:00'))).toBe(3);
});
it('counts calendar days across the autumn DST change', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-23T09:00:00+02:00'));
expect(daysLeft(new Date('2026-10-26T18:00:00+01:00'))).toBe(3);
});
});The fix to the function was one word: Math.round instead of Math.floor. A local day is always 23, 24 or 25 hours, so rounding the difference always lands on the right whole number of days.
Run the suite in a time zone you do not live in
The more useful change was to CI. Node reads the TZ environment variable at startup, so we now run the date-heavy tests twice:
TZ=UTC npx vitest run src/billing # what the servers use
TZ=Europe/Berlin npx vitest run src/billing # DST edges, never the laptop defaultThe second run takes nine seconds and has since caught one more bug, in a report that grouped events by local day.
What I took away
Three things, in order of how often they will matter:
- A test that pins the clock but not the time zone is testing one environment, not the function.
- Arithmetic on local midnights is calendar arithmetic. Count days by rounding, or better, compare calendar dates with a library that understands them.
- When a test fails on only one machine, the machine is telling you something. Our first instinct was to set
TZ=UTCon the new runner and move on. I am glad someone asked why first.