DEV Community

Goqa Ai
Goqa Ai

Posted on

Testing time without waiting for it

Anything with an expiry has a first failure that arrives weeks after the deploy. Password reset links, sessions, trial periods, cache entries, retention jobs, token refresh. All of them are testable in milliseconds, and almost none of them are tested at all.

Why these go untested

The honest reason is that the obvious test is unbearable:

test('reset link expires after an hour', async () => {
  const link = await requestReset(user);
  await sleep(60 * 60 * 1000); // ...no
  expect(await useLink(link)).toBe('expired');
});
Enter fullscreen mode Exit fullscreen mode

So it gets skipped, and the expiry logic ships unverified. Which is how you get links that never expire, or expire immediately, or expire correctly but display the wrong message.

Inject the clock

The fix is to stop reading the current time directly:

// Before — untestable
function isExpired(issuedAt) {
  return Date.now() - issuedAt > ONE_HOUR;
}

// After — testable, and no less readable
function isExpired(issuedAt, now) {
  return now - issuedAt > ONE_HOUR;
}
Enter fullscreen mode Exit fullscreen mode

Now the test is instant:

test('reset link expires after an hour', () => {
  const issued = 1_000_000;
  expect(isExpired(issued, issued + 59 * MINUTE)).toBe(false);
  expect(isExpired(issued, issued + 61 * MINUTE)).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

The boundaries that matter

Once time is a parameter, test the edges rather than the middle:

  • Exactly at the boundary. Is 3600000ms expired or valid? Pick one deliberately; > and >= are a real decision.
  • One unit either side. Catches off-by-one in unit conversion, which is the most common bug in this code.
  • Negative elapsed time. Clock skew and daylight-saving transitions both produce it. What does your code do when now is before issuedAt? Usually something wrong.
  • Far future. A token issued and checked a year later should be expired, not overflow.

The trap: only injecting it halfway

The common failure is injecting the clock into the check but not the creation. Then your test controls when the link is validated but not when it was issued, and you are back to real time for half the calculation. Both ends need to be parameters.

Where this generalises

Any non-deterministic input is the same problem: randomness, UUIDs, the filesystem, the network. If a function reads the world directly, it can only be tested in the world. Pass the world in.

That is the whole of dependency injection, minus the framework.


If you want the expiry cases enumerated for you, GoQA generates BDD suites from acceptance criteria — including boundary and negative scenarios, which are the ones that get skipped. No sign-up for the demo.

Top comments (0)