Flaky test fails only on the 31st: month arithmetic with the real clock
A billing test passed for weeks and failed this morning, 31 January, for every engineer at once. JavaScript's setMonth rolled 31 January into 3 March, and the test read the real clock. We fixed the arithmetic, injected the clock and added property tests over every day.
At 07:40 this morning the main branch went red. Nobody had merged anything since the previous evening. Every developer who ran the unit suite locally saw the same single failure in our subscription service:
FAIL src/billing/nextRenewal.test.ts > nextRenewal > renews one month after today
AssertionError: expected 2 to be 1 // Object.is equality
- Expected
+ Received
- 1
+ 2
❯ src/billing/nextRenewal.test.ts:14:38
Re-running CI did not help. The test had passed every day for the four months since it was written, and today's date is 31 January 2025.
Reproducing it
The test used the real clock:
it('renews one month after today', () => {
const today = new Date();
const renewal = nextRenewal(today);
expect(renewal.getUTCMonth()).toBe((today.getUTCMonth() + 1) % 12);
});
And the function under test did what many of us have written at some point:
export function nextRenewal(from: Date): Date {
const d = new Date(from);
d.setUTCMonth(d.getUTCMonth() + 1);
return d;
}
Calling it with a fixed date reproduced the failure on any day of the year:
nextRenewal(new Date('2025-01-31T09:00:00Z')).toISOString();
// '2025-03-03T09:00:00.000Z'
What we ruled out
- A timezone difference between CI and laptops. The code uses UTC methods, CI runs in UTC, and the failure was identical in Hanoi and in Lisbon.
- A dependency update. The lockfile had not changed since last week.
- Test order or shared state. Running the single test with
vitest run -t "renews one month"failed the same way.
Root cause
setUTCMonth(1) on 31 January asks for 31 February. JavaScript Date does not clamp, it overflows:
February 2025 has 28 days, so the extra three days carry into March and the result is 3 March. The
test only noticed because it read new Date() and today happens to be the 31st. The real bug was
in production code: customers who subscribed on the 29th, 30th or 31st of a month were being
renewed late, and their renewal day then drifted for the rest of their subscription.
What is happening under the hood
Date setters normalise out-of-range values by carrying them into the next unit. That is
intentional and documented, and it makes "add one month" ambiguous at the end of a month:
| Start (UTC) | setUTCMonth(+1) result |
What a billing system usually wants |
|---|---|---|
| 2025-01-28 | 2025-02-28 | 2025-02-28 |
| 2025-01-29 | 2025-03-01 | 2025-02-28 |
| 2025-01-31 | 2025-03-03 | 2025-02-28 |
| 2024-02-29 + 1 year | 2025-03-01 | 2025-02-28 |
| 2025-03-31 | 2025-05-01 | 2025-04-30 |
PHP's DateTime::modify('+1 month') and Carbon's addMonth() overflow the same way by default,
and other ecosystems vary. There is no universal answer, which is why the rule must be a product
decision encoded in one function, not an accident of the standard library.
The second half of the bug is the test design. A test that reads the real clock samples a single date per day. Most dates are safe, so the test is green 350 days a year and fails on a handful of specific days. That is the definition of a time bomb, and it explodes for everyone at once.
Environment
| Component | Version / value |
|---|---|
| Runtime | Node.js 22.13 |
| Language | TypeScript 5.7 |
| Test runner | Vitest 3.0 |
| Service | Subscription billing worker, all dates stored and computed in UTC |
| Affected input | Subscriptions starting on day 29, 30 or 31 |
Minimal reproduction
for (const day of ['2025-01-29', '2025-01-30', '2025-01-31']) {
const d = new Date(`${day}T00:00:00Z`);
d.setUTCMonth(d.getUTCMonth() + 1);
console.log(day, '->', d.toISOString().slice(0, 10));
}
// 2025-01-29 -> 2025-03-01
// 2025-01-30 -> 2025-03-02
// 2025-01-31 -> 2025-03-03
Who is affected
Any code that adds months or years to dates near month end: billing cycles, trial expiries,
contract renewals, report periods, "same day next month" reminders. Tests that call new Date(),
Date.now(), time.Now() or Carbon::now() without freezing the clock will eventually fail on
the 29th to 31st, on 29 February, or around daylight saving changes.
The fix
1. Encode the business rule. Product confirmed the rule: renew on the subscription's anchor day, or the last day of the month when that day does not exist. The anchor day is stored with the subscription, so 31 January renews on 28 February and then on 31 March, instead of drifting to the 28th forever.
export function daysInMonthUTC(year: number, month: number): number {
// Day 0 of the next month is the last day of this month.
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
}
export function addMonthsClamped(from: Date, months: number, anchorDay = from.getUTCDate()): Date {
const total = from.getUTCMonth() + months;
const year = from.getUTCFullYear() + Math.floor(total / 12);
const month = ((total % 12) + 12) % 12;
const day = Math.min(anchorDay, daysInMonthUTC(year, month));
return new Date(Date.UTC(
year, month, day,
from.getUTCHours(), from.getUTCMinutes(), from.getUTCSeconds(), from.getUTCMilliseconds(),
));
}
export function nextRenewal(sub: { renewsAt: Date; anchorDay: number }): Date {
return addMonthsClamped(sub.renewsAt, 1, sub.anchorDay);
}
2. Make time an input. The worker passes now explicitly instead of reading the clock deep in
the call stack. Code that genuinely needs "now" gets it from one small clock module that tests can
control.
3. Rewrite the test with fixed dates, including the ones that broke:
import { describe, expect, it } from 'vitest';
import { addMonthsClamped } from './dates';
describe('addMonthsClamped', () => {
it.each([
['2025-01-15', 15, '2025-02-15'],
['2025-01-31', 31, '2025-02-28'],
['2024-01-31', 31, '2024-02-29'],
['2025-02-28', 31, '2025-03-31'],
['2025-12-31', 31, '2026-01-31'],
])('%s with anchor %i -> %s', (start, anchor, expected) => {
const result = addMonthsClamped(new Date(`${start}T10:00:00Z`), 1, anchor);
expect(result.toISOString()).toBe(`${expected}T10:00:00.000Z`);
});
});
For tests that exercise code which still reads the clock, Vitest's fake timers pin it:
import { afterEach, beforeEach, vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-31T09:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
Alternatives and trade-offs
- A date library.
date-fnsaddMonthsand Luxon'splus({ months: 1 })clamp to the last valid day, which matches our rule for a single step. They do not remember an anchor day, so repeated additions still drift; we kept our own wrapper for that. - Fixed 30-day periods. Simple and predictable, but customers notice renewals moving across the calendar, and it does not match how most payment providers bill monthly.
- PHP projects: Carbon offers
addMonthNoOverflow()andCarbon::setTestNow(), and Laravel tests can use$this->travelTo(). Same principle: explicit clamping and a frozen clock.
How to test the fix
Example-based tests cover the cases we thought of. A property-based test covers the ones we did not, by checking the rule for every day of a multi-year range:
import fc from 'fast-check';
import { expect, it } from 'vitest';
import { addMonthsClamped, daysInMonthUTC } from './dates';
it('always lands in the next month on the anchor day or the last day', () => {
const start = Date.UTC(2023, 0, 1);
fc.assert(
fc.property(fc.integer({ min: 0, max: 365 * 6 }), fc.integer({ min: 1, max: 31 }), (offset, anchor) => {
const from = new Date(start + offset * 86_400_000);
const out = addMonthsClamped(from, 1, anchor);
const expectedMonth = (from.getUTCMonth() + 1) % 12;
expect(out.getUTCMonth()).toBe(expectedMonth);
expect(out.getUTCDate()).toBe(Math.min(anchor, daysInMonthUTC(out.getUTCFullYear(), expectedMonth)));
}),
);
});
npx vitest run src/billing
Confirming in production
The fixed worker shipped the same afternoon. We ran a query for subscriptions whose
renews_at day differed from their anchor day in a way the new rule would not produce, and found
1,312 subscriptions that had drifted since launch, all started on the 29th to 31st. A one-off
migration recomputed their next renewal from the anchor day, and support sent a short note to the
63 customers whose renewal moved by more than two days. The nightly billing report for 28 February
showed the expected spike of end-of-month renewals that had previously been spread across 1 to 3
March.
Regression guard
- The property test runs on every pull request; it explores thousands of date and anchor combinations in under a second.
- An ESLint
no-restricted-syntaxrule flagssetMonth,setUTCMonth,setFullYearandsetUTCFullYearoutsidesrc/lib/dates.ts, pointing toaddMonthsClamped. - A weekly CI job runs the whole suite with the system time set to 31 January, 29 February 2028 and the last Sunday of March, to shake out other clock-dependent tests.
Security implications
None beyond the bug itself. The defect affected billing correctness, not access control. Billing dates do have a financial integrity side: late renewals extended paid periods for free, while a naive "fix" that simply subtracted days could have charged customers early. The data correction was reviewed by finance before it ran, and the migration logged every changed row for audit.
Prevention checklist
- Never read the real clock inside business logic; pass
nowin or use a controllable clock. - Decide and document the month-end rule; implement it in one function.
- Test 28, 29, 30 and 31 day months, 29 February and year boundaries explicitly.
- Add a property-based test for any date arithmetic used in billing or scheduling.
- Run the suite periodically with the clock pinned to awkward dates.
References
- MDN,
Date.prototype.setUTCMonth()and date overflow behaviour — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/setUTCMonth - Vitest documentation, Mocking dates and timers (
vi.setSystemTime) — https://vitest.dev/guide/mocking.html - fast-check documentation — https://fast-check.dev/docs/introduction/
- date-fns documentation,
addMonths— https://date-fns.org/docs/addMonths - Carbon documentation, addition and subtraction without overflow — https://carbon.nesbot.com/docs/
Add a label
Embed this post
<iframe src="https://hibug.me/flaky-test-fails-end-of-month-time" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
Flaky Playwright suite: hard sleeps, racing navigation and ambiguous selectors
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.