TypeScript Stub Date and timer friends functions with sinon

eye-catch JavaScript/TypeScript

When Date class, timer friends functions like setTimeout, and setInterval are used in a function, we need to control the timer under unit testing. Otherwise, the test result could vary because the time value changes from time to time.

In addition to that, the test execution time is longer. For example, if 1000 is set to setTimeout, it takes at least 1 second to complete the function. If there are 10 tests for the function, it takes more than 10 seconds. It’s horrible.

Let’s learn how to test a function that uses timer-related functions.

You can find the complete code here.

GitHub - yuto-yuto/unit-testing
Contribute to yuto-yuto/unit-testing development by creating an account on GitHub.
Sponsored links

A function that calls setTimeout repeatedly

The function that we want to write unit tests this time is the following. The application is a console typing game. It countdowns 3 seconds before it actually starts. This function shows the number and updates it on a console every second.

// src/lib/typing-game/GameMonitor.ts
export class GameMonitor {
    constructor(private generator: SentenceGenerator) { }

    public countdown(): Promise<void> {
        return new Promise((resolve) => {
            const process = (count: number) => {
                updateConsoleLine(`    ${count}`);
                if (count > 0) {
                    count--;
                    global.setTimeout(() => process(count), 1000);
                    return;
                }
                console.log("GO!!");
                resolve();
            };
            process(3);
        });
    }
}

// src\test\typing-game\GameMonitor_spec.ts
export function updateConsoleLine(text: { toString: () => string }): void {
    process.stdout.write(text.toString() + "\r");
}

updateConsoleLine function updates the text on the same line because we don’t want to use a new line every time the number is updated.

Since it waits for 3 seconds in the countdown function, Promise is used to make the process asynchronous. setTimeout is called in process function because it needs to be called 3 times. It is a recursive call that might be a bit hard to understand for beginners.

The process is the following.

  • First call
    It calls process function with 3 for the first call. It shows 3 on the console and decrements the count and call setTimeout.
  • Second call
    1 second later after the first call, process is called with argument 2. It shows 2 on the console and decrements the count and calls setTimeout.
  • Third call
    2 seconds later after the first call, process is called with argument 1. It shows 1 on the console and decrements the count and calls setTimeout.
  • Fourth call
    3 seconds later after the first call, process is called with argument 0. It shows 0 but is immediately followed by GO!! on the console. Promise is resolved.

The promise is not resolved until 3 seconds are elapsed. This is important point to know.

Sponsored links

The problem that does not stub Timer

Now, you understand what the function does. The next step is to write the unit tests. Let’s write a unit test without stubbing a timer. If we don’t stub it, the unit test looks like this.

// src\test\typing-game\GameMonitor_spec.ts
describe("countdown", () => {
    context('Without FakeTimer', () => {
        it("should be resolved after 3 seconds", async () => {
            const start = Date.now();
            await instance.countdown();
            const result = Date.now() - start;
            expect(result).to.be.greaterThan(3000);
        }).timeout(4000);
    });

It has to wait for at least 3 seconds for the test but the default timeout is 2 seconds. Therefore, the timeout must be extended. Otherwise, the following error occurs.

1) GameMonitor
    countdown
        Without FakeTimer
        should be resolved after 3 seconds:
    Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (C:\xxxxx\unit-testing\src\test\typing-game\GameMonitor_spec.ts)

The timeout can be set by chaining timeout(4000) call. If you want to apply the same settings to all tests, a configuration file can be used for that. For the details, go to the official web site.

The test above succeeds after 3 seconds. If we have a lot of this kind of test, it takes a long time to complete all the tests. It is not a good thing because the tests are not executed often due to the long execution. If unit tests are not often executed, we find a bug at the end of the development and it is harder to fix it than do it soon.

A unit test execution should be completed in a short time.

Stub timers by useFakeTimers

We somehow need to control the timer ourselves. sinon offers SinonFakeTimers to replace the original behavior with fake behavior. Great. It by default replaces all the timer related functions setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, clearImmediate, process.hrtime, performance.now, and Date.

The usage is easy. Call sinon.useFakeTimers(); in beforeEach and call restore() function in afterEach. The test looks like the following.

// src\test\typing-game\GameMonitor_spec.ts
describe("countdown", () => {
    context('With FakeTimer', () => {
            let fakeTimer: sinon.SinonFakeTimers;

            beforeEach(() => {
                fakeTimer = sinon.useFakeTimers();
            });

            afterEach(() => {
                fakeTimer.restore();
            });

            it("should not be resolved after 2999 ms", () => {
                const result = instance.countdown();
                fakeTimer.tick(2999);
                // Don't add "return" here because the test fails due to timeout
                // since promise is not resolved.
                expect(result).not.to.be.fulfilled;
            });

            it("should resolve after 3 seconds", () => {
                const result = instance.countdown();
                fakeTimer.tick(3000);
                return expect(result).to.eventually.be.fulfilled;
            });

            it("should not call resolve twice after 6 seconds", () => {
                const result = instance.countdown();
                fakeTimer.tick(6000);
                return expect(result).to.eventually.be.fulfilled;
            });
        });
    });
});

The difference is to control the timer in each test. By calling fakeTimer.tick(3000);, the timer proceeds the specified milliseconds. If we don’t call it, the timer keeps stopping and the promise won’t be resolved.

In the unit tests, the timer proceeds for 3 seconds or 6 seconds in a short time, and thus all the tests are done soon.

Set the current timestamp

In the previous example, the timer stops and it is completely controlled by us but we didn’t know the timestamp. There are some cases in that we need to set a timestamp. It is also possible by sinon.

When it comes to the point, we can do it in the following way.

const timestamp = Date.parse("2022-04-04T10:11:12.000");
const fakeTimer = sinon.useFakeTimers(timestamp);

We can set the desired timestamp. Once setting it, Date.now() returns the timestamp. Let’s see the actual code.

This is the target function.

// src/lib/typing-game/GameMonitor.ts
export class GameMonitor {
    constructor(private generator: SentenceGenerator) { }

    public async start(): Promise<void> {
        const text = this.generator.generate();

        const startTime = Date.now();
        console.log(text);
        const userInput = await promptUserInput();
        const elapsedTimeMs = Date.now() - startTime;
        const elapsedTimeSec = elapsedTimeMs * 0.001;

        console.log("----- Result -----");
        console.log(text);
        console.log(userInput);

        const score = calculateScore({
            original: text,
            userInput,
            time: elapsedTimeSec,
        });
        console.log(`\nYour score is: ${Math.trunc(score)}`);
        console.log(`Time: ${elapsedTimeSec} (Sec)`);
    }
}

We want to test if the desired arguments are specified for the calculateScore. For the test, the dependencies need to be stubbed like the following.

// import other modules here

import * as UserInput from "../../lib/typing-game/UserInput";
import * as ScoreCalculator from "../../lib/typing-game/ScoreCalculator";

describe("GameMonitor", () => {
    let instance: GameMonitor;
    let generator: SentenceGenerator;

    before(() => {
        use(chaiAsPromised);
        use(sinonChai);
    });

    beforeEach(() => {
        generator = new SentenceGenerator();
        instance = new GameMonitor(generator);
    });

    describe("start", () => {
        let consoleStub: sinon.SinonStub;
        let generatorStub: sinon.SinonStub;
        let userInputStub: sinon.SinonStub;
        let nowStub: sinon.SinonStub;
        let calcStub: sinon.SinonStub;

        beforeEach(() => {
            consoleStub = sinon.stub(console, "log");
            generatorStub = sinon.stub(generator, "generate");
            userInputStub = sinon.stub(UserInput, "promptUserInput");
            nowStub = sinon.stub(Date, "now");
            calcStub = sinon.stub(ScoreCalculator, "calculateScore");
        });

        afterEach(() => {
            consoleStub.restore();
            generatorStub.restore();
            userInputStub.restore();
            nowStub.restore();
            calcStub.restore();
            sinon.restore();
        });

        it("should show the generated sentence", async () => {
            generatorStub.returns("test text");
            userInputStub.resolves();
            await instance.start();
            expect(consoleStub).to.be.calledWith("test text");
        });
    });
});

The dependencies can be controlled by the stub. Let’s define the values as calculateScore receives the following object.

{
    original: "test text",
    userInput: "user input",
    time: 2,
}

It is actually not necessary to set a timestamp in this example because the return value can completely be controlled in the following way.

it("should pass original string, userInput and elapsedTime", async () => {
    generatorStub.returns("test text");
    userInputStub.resolves("user input");
    nowStub.onFirstCall().returns(1000);
    nowStub.onSecondCall().returns(3000);
    calcStub.returns(1);

    await instance.start();
    expect(calcStub).to.be.calledWith({
        original: "test text",
        userInput: "user input",
        time: 2,
    });
});

Date.now() is called in the production code. If we define the two returned values, we can manage to write the test.

If we want to set the desired timestamp, the test becomes the following.

describe("use fake timer", () => {
    let fakeTimer: sinon.SinonFakeTimers;

    beforeEach(() => {
        const timestamp = Date.parse("2022-04-04T10:11:12.000Z");
        fakeTimer = sinon.useFakeTimers(timestamp);
    });

    afterEach(() => {
        fakeTimer.restore();
    });

    function showCurrentTime(): void {
        const now = Date.now();
        const date = new Date(now);
        console.log(date.toISOString());
    }

    it("should pass original string, userInput and elapsedTime", async () => {
        // restore in order to show the current time in showCurrentTime()
        consoleStub.restore();

        generatorStub.returns("test text");

        showCurrentTime(); // 2022-04-04T10:11:12.000Z
        userInputStub.callsFake(() => {
            fakeTimer.tick(2000);
            return Promise.resolve("user input");
        });

        await instance.start();

        showCurrentTime(); // 2022-04-04T10:11:14.000Z
        expect(calcStub).to.be.calledWith({
            original: "test text",
            userInput: "user input",
            time: 2,
        });
    });
});

In this test, it doesn’t define what value Date.now() returns. Instead, it defines the current timestamp. The first call of Date.now() returns exactly the same time as defined in beforeEach. Therefore, we somehow need to tick the timer.

The right place is in promptUserInput function. To replace the behavior, sinon stub offers callsFake.

userInputStub.callsFake(() => {
    fakeTimer.tick(2000);
    return Promise.resolve("user input");
});

It ticks the timer and it must resolve in the callback because it is Promise function. At the second call of showCurrentTime function, the timer is 2 seconds forward.

Comments

Copied title and URL