How to inject user input data for unit tests

eye-catch JavaScript/TypeScript

Some console applications require user inputs. For those inputs, we need to write unit tests. If we using readline module, how can we inject test data?

You can find the complete code here.

https://github.com/yuto-yuto/unit-testing/blob/main/src/test/typing-game/UserInput_spec.ts
Sponsored links

How to prompt a user for input

Firstly, let’s implement a function that lets a user input string. Node.js offers readline module. We can easily implement the feature by using it.

import readline from "readline";

export function promptUserInput(question = ""): Promise<string> {
    return new Promise((resolve) => {
        const rl = readline.createInterface({
            input: process.stdin,
            output: process.stdout
        });

        rl.question(question, (userInput: string) => {
            rl.close();
            resolve(userInput);
        });
    });
}

createInterface requires input argument that indicates where the data source is. process.stdin is specified above. It means that the input comes from a standard input that contains console input.

By passing process.stdout to output argument, the question that we want to show to a user is shown on a console.

The question function requires a string for the first argument which will be shown on a console.

The second argument is a callback which is called after a user completes the input. Make sure that close function is called after the user completes the input. Otherwise, the stream keeps open which causes a problem.

By the way, the function is promise because it is I/O related thing. I/O-related task should be processed in asynchronous in order not to block other tasks.

Sponsored links

Test if the desired string is passed to a function

The function doesn’t contain any logic but some of you might want to test if the desired string is passed to question function. To do that, we need to replace rl with a fake object.

An instance is created in the function. Therefore, we can’t inject a fake object from outside. That’s true if the language is a static type like C#.

TypeScript is transpiled to JavaScript. JavaScript is a dynamic type language. We have a good way to replace the object within unit tests. Let’s use sinon module here. By using it, we can control what value/object the function returns.

We want to check if the function is called with desired arguments. It means that we want to inject spy to know it. Let’s check the unit test first.

import "mocha";

import { expect, use } from "chai";
import sinon from "sinon";
import readline from "readline";
import { promptUserInput } from "../../lib/typing-game/UserInput";

describe("UserInput", () => {
    afterEach(() => {
        sinon.restore();
    });

    describe("promptUserInput", () => {
        context("question is default value", () => {
            it("should call question function with empty string", async () => {
                const rl = readline.createInterface(process.stdin);
                const stub = sinon.stub(rl, "question");
                // It triggers a callback when question function is called
                stub.callsFake(() => stub.yield("user input value"));
                sinon.stub(readline, "createInterface").returns(rl);

                await promptUserInput();

                expect(stub.calledWith("", sinon.match.any)).to.be.true;
            });
        });
    });
});

I will explain one by one.

const rl = readline.createInterface(process.stdin);

It creates another rl object.

const stub = sinon.stub(rl, "question");

It replaces question function with a stub object.

stub.callsFake(() => stub.yield("user input value"));

It defines the behaviors of question function. When it’s called, the callback is triggered that triggers the actual callback defined in the production code.

sinon.stub(readline, "createInterface").returns(rl);

It replaces the behavior of createInterface function. When readline.createInterface is called in the production code, it returns the object created in the unit test, namely, stubbed object.

expect(stub.calledWith("", sinon.match.any)).to.be.true;

To check the specified arguments, we can use stub.calledWith function. It checks if the first argument is empty string. It doesn’t check the second argument.

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

After each test execution, we need to restore the fake behavior. Don’t forget to call it.

How to inject test value to stdin

When it comes to the point, we can inject test value by process.stdin.emit function. Let’s check the implementation.

import chaiAsPromised from "chai-as-promised";

describe("UserInput", () => {
    before(() => {
        use(chaiAsPromised);
    });

    context("question is specified", () => {
        it("should resolve with user input", () => {
            const result = promptUserInput("my question");

            // it doesn't trigger without \r, \n or \r\n
            const input = "user input text\r";
            process.stdin.emit("data", input);

            // the received text doesn't contain \r, \n, \r\n
            return expect(result).to.eventually.equal("user input text");
        });
    });
});

As I wrote in the comment, we need to add \r, \n or \r\n. Otherwise, it doesn’t work as expected.

It uses chai-as-promised module. It makes the test easier to to handle Promise. eventually makes the chain Promise. return keyword must be added in this case.

If we don’t use chai-as-promised, the test looks like the following.

 it("should resolve with user input", (done) => {
    promptUserInput("my question").then((result) => {
        try {
            expect(result).to.equal("user input text");
            done();
        } catch (e) {
            done(e);
        }
    });

    const input = "user input text\r";
    process.stdin.emit("data", input);
});

We need to add done callback because promptUserInput is Promise and thus the test succeeds before the process ends.

Comments

Copied title and URL