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.
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