Tips for Testing Time-Dependent JavaScript Functions

This article was published on Jan 09, 2023, and takes approximately 6 minutes to read.

When writing code, we may not think about how it'll behave in the test suit.

One common mistake, for example, is when we have a function that relies on the Date object.

Let's imagine a (dummy) function where we pass a date as a param and get back the difference of the years:

Copied
export function getYearsDiff(date: Date) {
  const now = new Date();
  return now.getFullYear() - date.getFullYear();
}

console.log(getYearsDiff(new Date("2022-01-01"))); // 1

Because we're running this code in 2023, the difference between the date we've passed and the "new Date()" will always be one.

Then, we can write a test like this:

Copied
getYearsDiff.spec.ts
describe("getYearsDiff", () => {
  it("should return the year difference between today and the sent date", () => {
    const date = new Date("2022-01-01");
    const result = getYearsDiff(date);
    expect(result).toBe(1);
  });
});

This test will pass along in the year 2023 in my CI environment with no problem. However, on 2024-01-01, my test pipeline will start failing for, apparently, no reason.

If we start investigating, we'll quickly see that result is no longer 1 but 2.

That's because our code relies on new Date(), and this date always matches the current date.

You could say:

"Ah, ok... let me fix it to be 2 instead 1"

But let's be honest: this is far from an optimal solution.

Not only because you have to change this particular test every new year but because your future self or teammates will have to spend some time trying to figure out why the tests suddenly stopped working.

Also, this case is simple because we can easily see that our function is using new Date(), but the thing is: this statement could be really nested into the tested code.

What I mean by that is you may have written an integration test where a function calls a function that calls a bunch of other functions and this new Date() is not visible at a glance.

Possible Solutions

There are a few possible solutions to this problem.

I'm going to show you 2 of the ones I like the most and also explain the caveats and what you must be aware of.

1. The "mockdate" package

One strategy for handling this problem is using a package called mockdate.

This single-file package wraps the global Date object and allows us to define a specific date we want to have every time the Date.now or new Date functions are called.

Copied
import MockDate from "mockdate";

MockDate.set("2023-01-01");
console.log(new Date()); // 2023-01-01T00:00:00.000Z
console.log(Date.now()); // 1672531200000 which is our date in milliseconds

As you can see, when we call the method .set with the date we want, when calling the new Date() or Date.now(), it'll return on this date.

It's important to mention that if you create a date passing an initial time, our mock will have no effect on it:

Copied
import MockDate from "mockdate";

MockDate.set("2023-01-01");

console.log(new Date()); // 2023-01-01T00:00:00.000Z
console.log(new Date("1999-12-12")); // 1999-12-12T00:00:00.000Z

Now, let's create a test for this function.

It's a good practice to run a clean-up whenever we stub something in tests to avoid cross-testing side effects.

So, the beforeAll hook, we set the date to the one we want. In the afterAll hook (that will be executed after all assertions finish), we remove the mock:

Copied
getYearsDiff.spec.ts
import MockDate from "mockdate";

import { getYearsDiff } from "./index";

describe("getYearsDiff", () => {
  beforeAll(() => {
    // Freeze the new Date()/ Date.now() creation on this period
    MockDate.set("2023-01-01");
  });

  afterAll(() => {
    // Restore the original Date() / Date.now() behavior
    MockDate.reset();
  });

  it("should return the year difference between today and the sent date", () => {
    const date = new Date("2022-01-01");

    const result = getYearsDiff(date);
    expect(result).toBe(1);
    expect(Date.now()).toBe(1);
  });
});

By doing this, no matter when this test runs, we'll have the same result because we're freezing the period.

Setting it globally

Jest, Vitest, and other test frameworks allow us to specify setup files.

In a nutshell, these files are any configuration we want to do that will run before each test file.

In that sense, we'd have to add this setup file freezing it globally on this date:

Copied
setupTest.ts
import MockDate from "mockdate";

// Freeze the new Date()/ Date.now() creation on this period
MockDate.set("2023-01-01");
Learn how to add this file: Jest docs, Vitest docs, Mocha docs

The problem with this approach is that every single test is bound to this date.

If an engineer is testing a function that uses the Date object and they're not aware of this setup, they might spend some time trying to figure this problem out.

Worth to mention that they can override the date in a specific test by calling the same method .set with another date in their test, so, for this particular test, the Date will be different from the rest of the tests.

fakeTimers

Both Jest and Vite have the concept of fakeTimer.

In summary, when we use fake timers instead of real ones, the framework will replace our set/clear timeout, interval, immediate, tick, and guess what? The Date object.

This means that if we're running a code that uses a setTimeout function, we don't need to wait until the timeout to assert against something.

That said, when we use useFakeTimers() we have access to a bunch of methods to handle these timers.

There's a function called setSystemTime to freeze the Date while creating a new Date with no initial date value.

Copied
getYearsDiff.spec.tsx
import { getYearsDiff } from "./index";

describe("getYearsDiff", () => {
  it("should return the year difference between today and the sent date", () => {
vi.useFakeTimers().setSystemTime(new Date("2023-01-01"));
const date = new Date("2022-01-01"); const result = getYearsDiff(date); expect(result).toBe(1); }); });

This will have the exact same effect as the mockdate. Now, new Date() and Date.now() will always fall back to the defined period.

Caveat

The biggest problem with this approach is that every single code that uses the functions I mentioned before (e.g., setTimeout, nextTick, etc.) may not behave as you expect.

An example I can give you is a problem I faced recently with @testing-library/user-event.

For those who don't know, this library is a library to help us in component testing that emulates the actual user event, such as pointers, keyboard, clipboard, etc.

When I tried to use the fake timers, all functions that were using user-event to do a click, for example, started getting time outs for jest.

After digging into this problem, I figured that the click function uses under the hood, setTimeout.

Because now it's the fake one, the setTimeout callback was never resolved because.

For resolving fake timers we have to imperatively invoke methods such as advanceTimersByTime, advanceTimersToNextTimer, runAllTimers, etc.

Reading through the docs, I encountered one option that solved my problem: I could pass to the argument called advanceTimers, a function that would be called to, indeed, advance my fake timer.

Copied
it('clicks on the element', async () => {
  // ...code...
  await userEvent.click(element, {
advanceTimers: jest.advanceTimersByTime
}) // ...code... })

That said, you can rely on this method, but you must be extra careful because such problems are tough to debug.

Testing Library has a docs with some guidance regarding fake timers which I strong recommend the reading: Using Fake Timers

Conclusion

I hope now you'll be able not to be bitten by this problem.

No matter what solution you choose, handle your time-dependent functions with caution. After all, no one wants to have its tests broken every new year.

Also, be careful with your approach. If you decide to mock them globally, discuss and warn your team about this so no one spends much time trying to figure out why the date is always the same.

References