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-promptconst testableProcess = await (new Testable.Process('node',[envPromptScript],{ cwd })).onNextStdOutRespondWithStdIn('foo').onNextStdOutRespondWithStdIn('bar').run()// test stdout/stderr transmissionsexpect(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.