TypeScript Stub Top Level function by Sinon

eye-catchJavaScript/TypeScript

Functions called in a different function are not always class members. They are often top-level functions which are not defined in a class. It means that the function call is not chained with a dot and thus it’s impossible to replace an object.

Let’s learn how to stub them here.

You can find the complete code here.

unit-testing/SentenceGenerator_spec.ts at main · yuto-yuto/unit-testing
Contribute to yuto-yuto/unit-testing development by creating an account on GitHub.
Sponsored links

Store the result of a function call

This is the target function that we want to write unit tests.

import * as path from "path";
import { loadSentenceFiles, Sentences } from "./SentenceFileLoader";

export class SentenceGenerator {
    private sentences: Sentences = [];

    public async load(): Promise<void> {
        // This way doesn't allow us to use a test file in unit test
        const resourceDir = path.join(__dirname, "../../res");
        // This function can be stubbed
        this.sentences = await loadSentenceFiles(resourceDir);
    }

    public async load2(): Promise<void> {
        const resourceDir = process.env.CONFIG_DIR || path.join(__dirname, "../../res");
        this.sentences = await loadSentenceFiles(resourceDir);
    }

    public generate(): string {
        const choices = this.sentences.map((phrases: string[]) => {
            const random = Math.random() * phrases.length;
            const index = Math.trunc(random);
            return phrases[index];
        });
        return choices.join(" ") + ".";
    }
}

load and load2 functions call a top-level function and store the result to a private variable that will be used in another function. I wrote the two functions in order to compare the unit tests.

The first one load can’t inject a path to a test resource. Therefore, what we can do is to replace loadSentenceFiles. The point is how to replace the behavior.

The second one load2 can inject the path because it uses env variable that can be overwritten in a unit test. However, if we want to inject a path to a test resource, it means that the unit test tests if loadSentenceFiles works as expected. In general, it is not good to test another function’s behavior in a different test. Replacing the function behavior is better.

Test if the function does not throw an error

If you decide that you don’t replace the behavior of loadSentenceFiles, it might be better to check if the function doesn’t throw an error.

import "mocha";
import { expect } from "chai";
import { SentenceGenerator } from "../../lib/typing-game/SentenceGenerator";

describe("SentenceGenerator", () => {
    let instance: SentenceGenerator;
    beforeEach(() => {
        instance = new SentenceGenerator();
    });

    describe("load", () => {
        it("should not throw an error", () => {
            const result = () => instance.load();
            expect(result).not.to.throw;
        });
    });
});

The production code is very simple but I recommend writing this test case. The loadSentenceFiles loads a file but if the file is updated with unexpected content, the test can catch the error. In this scenario, it makes sense to have this test case.

Test the function without stub

Let’s look at the test for load2 function first.

import "mocha";
import { expect } from "chai";
import sinon from "sinon";
import path from "path";
import { SentenceGenerator } from "../../lib/typing-game/SentenceGenerator";
import * as generator from "../../lib/typing-game/SentenceFileLoader";

describe("SentenceGenerator", () => {
    let instance: SentenceGenerator;
    beforeEach(() => {
        instance = new SentenceGenerator();
    });

    describe("generate", () => {
        describe("load2 + generate", () => {
            let originalEnv: NodeJS.ProcessEnv;

            before(() => {
                originalEnv = { ...process.env };
            });

            beforeEach(() => {
                // src/test/res/files
                process.env.CONFIG_DIR = path.join(__dirname, "../res/files");
            });

            afterEach(() => {
                process.env = { ...originalEnv };
            });

            it("should generate a sentence with space and dot", async () => {
                await instance.load2();
                const result = instance.generate();
                expect(result).to.match(/(AA1|AA2) (BBB1|BBB2) (CC 1|CC 2|CC 3)./);
            });
        });
    });
});

load2 function reads env variable. Since we want to have the function load a test resource, we need to assign the path to the env variable but the env variable can be accessed from anywhere, and thus once it’s updated, the updated value is read after it in other tests. Each test should not make any influence on other tests, we should revert the change after each test. Therefore, the original value needs to be stored before tests and assigned to process.env again after each test.

Let’s check one by one.

before(() => {
    originalEnv = { ...process.env };
});

This stores the original env values.

beforeEach(() => {
    // src/test/res/files
    process.env.CONFIG_DIR = path.join(__dirname, "../res/files");
});

This assigns a test path to the env variable used in production code.

afterEach(() => {
    process.env = { ...originalEnv };
});

Then, it assigns the original value to the env variables.

The three dots are called spread operator. If you don’t know how it works, check the following post.

Since the load2 function is void, it is not possible to write a test alone against the function but the internal state changes and we can check it by calling generate function.

Test the function with stub

Let’s look at the test for load function next. It doesn’t have any seam to inject a test value. We somehow need to replace the function behavior of loadSentenceFiles. It is actually exported by the file which means that the function is owned by the file. In other words, it is an object of the object managed by the file.

We can pass an object to sinon.stub and specify one of the functions defined in the object. Then, let’s import the whole object from the file.

import * as generator from "../../lib/typing-game/SentenceFileLoader";

If we import all from the file in this way, the generator becomes a parent object for the function. Then, we can pass the generator to sinon.stub like this below.

sinon.stub(generator, "loadSentenceFiles")
        .resolves([
            ["It's"],
            ["a beautiful"],
            ["test"],
        ]);

loadSentenceFiles can be replaced with our test value in this way. It resolves the Promise with the arrays.

Let’s look at the complete test code.

import "mocha";
import { expect } from "chai";
import sinon from "sinon";
import { SentenceGenerator } from "../../lib/typing-game/SentenceGenerator";
import * as generator from "../../lib/typing-game/SentenceFileLoader";

describe("SentenceGenerator", () => {
    let instance: SentenceGenerator;
    beforeEach(() => {
        instance = new SentenceGenerator();
    });

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

    describe("generate", () => {
        describe("load + generate", () => {
            it("should generate a sentence with space and dot", async () => {
                sinon.stub(generator, "loadSentenceFiles")
                    .resolves([
                        ["It's"],
                        ["a beautiful"],
                        ["test"],
                    ]);
                await instance.load();
                const result = instance.generate();
                expect(result).to.equal("It's a beautiful test.");
            });
        });
    });
});

The loadSentenceFiles function is stubbed in the test, we need to restore it in afterEach function.

A unit test should not test the dependencies

“load + generate” test is better than “load2 + generate” test because the unit test doesn’t test the dependent function loadSentenceFiles. If the test fails, which part do you start reading? I guess we read generate function first and then, read load function. We don’t expect that loadSentenceFiles does something wrong.

If a dependent function is tested in a different test case

  • Test becomes more complicated because some functions require a preparation
  • The test fails even if the target function isn’t modified but the dependent function is modified
  • We need to know how exactly the dependent function works

This is not nice. We should focus on only the target function while writing the unit test. We should know only the return value of the dependent function but not how it works under the hood.

Comments

Copied title and URL