Mastering Time: Using Fake Timers with Vitest

Bruno SabotBruno Sabot
7 min read

Photo by Aron Visuals on Unsplash

In the world of testing, controlling time can be a real challenge. Real-world timers, like setTimeout() and setInterval(), can be cumbersome when writing unit tests: without the right technique, you will introduce external dependencies on actual time passing that will make your test either slower, wrong or difficult to understand.

This is where Vitest’s fake timers come in, giving you the power to manipulate time within your tests for a smoother and more efficient testing experience.

At PlayPlay, we create high-quality software while prioritizing efficient development practices. We leverage innovative tools like Vitest’s fake timers to write faster and more reliable tests, ensuring exceptional software from the ground up.

Why Fake Timers?

Imagine testing a function that debounces another one after a 2-second delay. Using real timers, your test would have to wait for the full 2 seconds to pass, making the whole test scenario longer by these 2 seconds. This is slow and inefficient, and even more when you’re dealing with multiple timers or complex timing interactions. Hopefully, fake timers are here to allow you to:

  • Speed up tests: Advance the virtual clock by any amount, making tests run significantly faster. This is particularly beneficial for tests that involve waiting for timers to expire or simulating longer time intervals. Vitest prioritizes test speed. Fake timers become even more crucial when dealing with functions that rely on timers. You can avoid waiting for long intervals, keeping your tests lightning fast.

  • Isolate functionality: By removing reliance on external timers, you ensure your tests focus solely on the code you’re testing. This eliminates external factors that could potentially cause flaky tests and makes it easier to pinpoint the source of any issues.

  • Simulate specific timeouts: Test how your code behaves under different time constraints. Fake timers allow you to create scenarios with specific delays or timeouts, helping you ensure your code functions as expected in various situations. You can check something’s state just before the timer is executed and right after.

Getting Started with Fake Timers

Vitest provides the vi.useFakeTimers() function to enable fake timers: this mocks out the behavior of setTimeout(), setInterval(), clearTimeout(), and clearInterval().

You can call this method globally, before each test or on-demand. Here is an example:

import { vi, beforeEach } from 'vitest';

beforeEach(() => {
    vi.useFakeTimers();
});

If you chose to do it on demand, you will need to restore the real behavior to ensure subsequent tests don’t inherit the fake timer behavior. Here is an example:

import { vi, afterEach } from 'vitest';

afterEach(() => {
    vi.useRealTimers();
});

Basic Usage

Let’s start with a simple example. Imagine you have a function that uses setTimeout() to execute a callback after a delay:

function getDelayedGreeting(callback) {
    setTimeout(() => {
        callback('Hello, World!');
    }, 1000);
}

To test this function with fake timers, you can write:

import { describe, expect, test, vi, afterEach } from 'vitest';

afterEach(() => {
    vi.useRealTimers();
});

describe('Given the getDelayedGreeting function', () => {
    describe('When we wait 1s', () => {
        test('Then it calls the callback with the right message', () => {
            // Arrange
            vi.useFakeTimers();
            const callback = vi.fn();
            getDelayedGreeting(callback);

            // Act
            vi.advanceTimersByTime(1000);

            // Assert
            expect(callback).toHaveBeenCalledWith('Hello, World!');
        });
    });
});

In this test, vi.advanceTimersByTime(1000) fast-forwards the timer by 1000 milliseconds, causing the setTimeout() to fire immediately. The expect() assertion then checks if the callback was called with the correct argument.

The true power of fake timers lies in their ability to ensure that time advances exactly as expected in your tests.. We can write the non-passing test as easy as the previous one:

import { describe, expect, test, vi, afterEach } from 'vitest';

afterEach(() => {
    vi.useRealTimers();
});

describe('Given the getDelayedGreeting function', () => {
    describe('When we wait 0.999s', () => {
        test("Then the callback hasn't been called yet", () => {
            // Arrange
            vi.useFakeTimers();
            const callback = vi.fn();
            getDelayedGreeting(callback);

            // Act
            vi.advanceTimersByTime(999);

            // Assert
            expect(callback).not.toHaveBeenCalled();
        });
    });
});

Advanced Usage

When writing tests, we will put a great importance to having the right situation at the right time. If the previous example is working, occasional delay could occurs and make the test flaky. So to ensure nothing more will happen with time passing, we would ideally cancel every running timer.

That’s where vi.clearAllTimers() can help us: by cancelling everything that is running, we make sure that nothing will prevent our test to work as expected. Here is the previous example improved:

import { describe, expect, test, vi, afterEach } from 'vitest';

afterEach(() => {
    vi.useRealTimers();
});

describe('Given the getDelayedGreeting function', () => {
    describe('When we wait 0.999s', () => {
        test("Then the callback hasn't been called yet", () => {
            // Arrange
            vi.useFakeTimers();
            const callback = vi.fn();
            getDelayedGreeting(callback);

            // Act
            vi.advanceTimersByTime(999);
            vi.clearAllTimers();

            // Assert
            expect(callback).not.toHaveBeenCalled();
        });
    });
});

This ensures that any timers scheduled after the specified time advancement with vi.advanceTimersByTime() won't interfere with the assertion, making our tests safe.

Using vi.clearAllTimers() after vi.advanceTimersByTime()ensures that no timers scheduled after the specified time advancement will interfere with the assertion. This makes your test more robust and less susceptible to unexpected timeouts caused by lingering timers.

But sometimes, what we want is more than just advancing time: we want to make the timer to be executed no matter how long it lasts.

Let’s imagine a game that tries to improves your reflexes: the callback will wait a random timeout before calling the callback where a mystery number will be display for you to enter. The method would look like:

function getMysteryNumber(callback) {
    const number = Math.floor(Math.random() * 10);
    // Get a delay between 1 and 6 seconds
    const delay = 1 + Math.random() * 5000;
    setTimeout(() => {
        callback(number);
    }, delay);
}

To be sure that the method is working, we can use vi.runPendingTimers() to execute the timeout no matter the delay:

import { describe, expect, test, vi, afterEach } from 'vitest';

afterEach(() => {
    vi.useRealTimers();
});

describe('Given the getMysteryNumber function', () => {
    describe('When we wait the delay to be completed', () => {
        test('Then the callback is called with the mystery number', () => {
            // Arrange
            vi.useFakeTimers();
            vi.spyOn(Math, 'random').mockImplementationOnce(() => 0.5);
            const callback = vi.fn();
            getMysteryNumber(callback);

            // Act
            vi.runOnlyPendingTimers();

            // Assert
            expect(callback).toHaveBeenNthCalledWith(1, 5);
        });
    });
});

Here, we use vi.spyOn() to stub the Math.random() function and mock its behavior to return a specific value (0.5) for the delay. This allows us to control the randomness and ensure a consistent delay of 5 seconds in the test.

Now, no matter how long the delay is supposed to be, the test will be executed quickly.

But what if you have an interval timer that stop after a specific amount of time? For example, you have a method that counts up to five seconds:

function startCounter(callback) {
    let count = 0;
    const intervalId = setInterval(() => {
        ++count;
        if (count === 5) {
            callback('Done!');
            clearInterval(intervalId);
        }
    }, 1000);
}

For interval timers that complete after a specific duration, you can use vi.advanceTimersByTime() to advance the virtual clock by the expected interval duration. This approach often provides more control over the test execution. However, vi.runAllTimers() is a viable alternative when you need to ensure all timers, including intervals, are run to completion. Here is an example:

import { describe, expect, test, vi, afterEach } from 'vitest';

afterEach(() => {
    vi.useRealTimers();
});

describe('Given the startCounter function', () => {
    describe('When we wait 0.999s', () => {
        test("Then the callback hasn't been called yet", () => {
            // Arrange
            vi.useFakeTimers();
            const callback = vi.fn();
            startCounter(callback);

            // Act
            vi.runAllTimers();

            // Assert
            expect(callback).toHaveBeenNthCalledWith(1, 'Done!');
        });
    });
});

Conclusion

Fake timers in Vitest provide a robust way to test time-dependent code : they allow you to write faster, more reliable, and more focused unit tests.

By simulating the passage of time, you can write tests that are both fast and deterministic. Whether you are dealing with simple timeouts or complex interval logic, Vitest’s fake timers have you covered.

With this guide, you should now have a solid understanding of how to use fake timers in your Jest tests. Experiment with these techniques in your own projects to achieve more effective and maintainable tests. Happy testing! For further details and advanced usage, refer to the Vitest documentation.

0
Subscribe to my newsletter

Read articles from Bruno Sabot directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Bruno Sabot
Bruno Sabot

I am Bruno, a web developer for more than 12 years, mainly working on JavaScript, React, VueJS, HTML, CSS, UX and performance topics. I spend my career mostly on consulting jobs, since I like to share and help other people to grow in their jobs. I'm currently working for (PlayPlay)[https://playplay.com/], a french company. I was also a member of BDX I/O, a French non-profit organization that aims to propose a yearly conference about everything trending in the web technologies. I like to share my discoveries and my expertise, that you will find here, on Hashnode, but also on my Twitter account, where I'm often posting small stories about the tech stuff I like. I'm also a proud father, working on home automation on my spare time, reading about cognitive sciences and I like to cook. I hope you will find all my posts here useful!