End-to-End Testing for Node.js CLI Apps

Brian Kotos

October 12, 2020

I've recently been refactoring my Node.js project env-prompt in preparation for some new features. Env-prompt is a command-line application that diffs two .env files containing environment variables. One file is tracked in git, meant to contain placeholder values, while the other contains the users actual values. If a new variable is added to the git-tracked file, the user will be prompted to enter a value via the command line.

There are a handful of people who depend on env-prompt in their project workflow. While it's enjoyable to move fast as a developer and ship new code, it's important to do so in a way that avoids regressions. In an effort to do this, I had previously been testing each new release manually before pushing to NPM. While adequate initially, this is slow and prone to human error. With the upcoming changes I had planned, I needed the ability to say confidently each new release is regression-free and to do so at scale.

Jest is an excellent test runner and assertion library for the JavaScript ecosystem. It works perfectly out of the box for unit tests. It also has descriptive method names, allowing you to write declarative tests such as expect(2 + 2).toBe(4). I felt strongly about using Jest. However, I was unable to find any complementary libraries to test command line functionality.

With no other solutions out there, I took it upon myself to create my own. Node.js has a child_process module as part of its standard library. This module allows you to spawn processes via the command line and read/write to the standard streams (stdin, stdout, and stderr).

After playing with child_process for a few days in my spare time, I came up with a solution that I'm happy with. It allows me to run a process, specify stdin values when prompted, and introspect the process once it finishes.

This is what my solution looks like, when paired with Jest:

// run env-prompt
const testableProcess = await (new Testable.Process(
'node',
[envPromptScript],
{ cwd }
))
.onNextStdOutRespondWithStdIn('foo')
.onNextStdOutRespondWithStdIn('bar')
.run()
// test stdout/stderr transmissions
expect(testableProcess.exitCode).toBe(0)
expect(testableProcess.streamTransmissionCount()).toBe(3)
expect(testableProcess.streamTransmissionNumber(1).type).toBe(StreamType.stdErr)
expect(testableProcess.streamTransmissionNumber(1).content).toBe(
`\u001b[33m${'New environment variables were found. When prompted, please enter their values.'}\u001b[0m\n`
)
expect(testableProcess.streamTransmissionNumber(2).type).toBe(StreamType.stdOut)
expect(testableProcess.streamTransmissionNumber(2).content).toBe(
`\u001b[46m${'FIRST_NAME'}\u001b[0m (\u001b[33m${'JOHN'}\u001b[0m): `
)
expect(testableProcess.streamTransmissionNumber(3).type).toBe(StreamType.stdOut)
expect(testableProcess.streamTransmissionNumber(3).content).toBe(
`\u001b[46m${'LAST_NAME'}\u001b[0m (\u001b[33m${'doe'}\u001b[0m): `
)

I purposely chose descriptive method and property names so that the assertions would read like english sentences, sandwiched elegantly between the expect() and and .toBe() calls from Jest's API. All my methods and properties are chainable in order to craft full sentences.

Another key feature is the .streamTransmissionNumber() method. This is important because it allows me to assert the order in which stdout and stderr were written to. This way, the test will fail if say the user was prompted for the variable LAST_NAME before FIRST_NAME.

The child_process.spawn() Node.js method used under the hood sends you the raw bytes written to stdout and stderr when data is received. Env-prompt uses ANSI escape codes to style the output text with text and background colors. Having access to the byte buffer was helpful in asserting that these escape codes are being properly used.

My complete implementation of this solution at the time of this writing can be found on GitHub here.