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