Red Shift

The official Infinite Red publication for React Native design & development. We’re a fully distributed team building world-class apps for over 20 years for clients all around the world.

Follow publication

Integration Testing Interactive CLIs

How to test your interactive Node-based Command Line Interface using Jest and mock-stdout

Jamon Holmgren
Red Shift
Published in
6 min readFeb 6, 2019

--

I’ve been spending a lot of time on Gluegun, our Node-based CLI toolkit. Along the way, I’ve studiously avoided building in-depth integration tests, because I didn’t know how to simulate user input. I knew I would eventually need to bite the bullet and learn. So this week I took a deep breath, made myself a hot cup of coffee, and dove in.

Gluegun CLI

I decided to build integration tests for Gluegun’s “prompt” extension. Gluegun’s CLI allows you to generate a new CLI, powered by Gluegun itself. When spinning up a new CLI, you can choose whether you want to use TypeScript or JavaScript.

This uses the very cool library Enquirer to display these prompts. Enquirer is a built-in dependency of Gluegun. (Not to be confused with its much larger predecessor, Inquirer!)

NOTE: For more about Gluegun, see the links at the end of this article.

Mocking Standard Input

What we had done in our tests in the past was to pass in a flag, --typescript, to tell it if we wanted a TypeScript CLI or not. But this only tested that the flag worked, not if the user interface did.

I needed a way to mock “stdin” (standard input) in the terminal and send keystrokes — specifically, “down arrow” and “enter” being the most important.

In order to set up the test, I first added the NPM module mock-stdin.

$ yarn add -D mock-stdin

I then imported it at the top of my test file, like so:

import { stdin } from 'mock-stdin'

(Note that all of my code examples will be in modern JavaScript.)

Since I’m using Jest to run my tests, I set up a beforeAll and afterAll hook to ensure I’m only mocking stdin while the tests are running. If I don’t do that, it could have unintended side effects.

// Mock stdin so we can send messages to the CLIlet io = null
beforeAll(() => (io = stdin()))
afterAll(() => io.restore())

I can then “send” keystrokes using io.send(...).

Integration Test

Now I’m ready to make our integration test. I can load up the prompt library directly from Gluegun and use it programmatically.

import { prompt } from 'gluegun/prompt'

I then want to run the Jest tests in asynchronous mode, since I’ll be sending keystrokes and then waiting to see what happens.

test('prompt for input', async done => {
// tests here
done()
})

NOTE: The done() call tells Jest that the test is done running, since it wouldn’t know otherwise in an asynchronous function.

One more thing before I can write our test. I need to know what the ASCII escape code is for the arrow keys, enter, and any other keystroke I want to send.

This took me a lot of experimentation — probably at least a half hour of Googling and trying things. But I’ll give you the answer right now:

// Key codes
const keys = {
up: '\x1B\x5B\x41',
down: '\x1B\x5B\x42',
enter: '\x0D',
space: '\x20'
}

Okay, cool, now comes the fun part! Let’s write a test.

First, I’ll trigger the prompt:

test('prompt for input', async done => {
const result = await prompt.ask({
name: 'shoe',
type: 'list',
description: 'What shoes are you wearing?',
choices: [ 'Suede', 'Rubber Boots', 'High Heels' ]
})
done()
})

Next, I will write the test:

test('prompt for input', async done => {
const result = await prompt.ask({
name: 'shoe',
type: 'list',
description: 'What shoes are you wearing?',
choices: [ 'Suede', 'Rubber Boots', 'High Heels' ]
})
expect(result).toEqual({ shoe: 'Rubber Boots' }) done()
})

And lastly, I need to make an async function that I can use to send keystrokes. While I’m not doing anything async right now, I will after a bit, so I might as well plan ahead. I’ll also set a 5ms timer to fire off the keystrokes after the prompt is displayed.

test('prompt for input', async done => {
const sendKeystrokes = async () => {
io.send(keys.down)
io.send(keys.down)
io.send(keys.enter)
}
setTimeout(() => sendKeystrokes().then(), 5)
const result = await prompt.ask({
name: 'shoe',
type: 'list',
description: 'What shoes are you wearing?',
choices: [ 'Suede', 'Rubber Boots', 'High Heels' ]
})
expect(result).toEqual({ shoe: 'Rubber Boots' }) done()
})

Here’s the end result. When I run the test, it passes!

import { stdin } from 'mock-stdin'
import { prompt } from 'gluegun/prompt'
// Key codes
const keys = {
up: '\x1B\x5B\x41',
down: '\x1B\x5B\x42',
enter: '\x0D',
space: '\x20'
}
// Mock stdin so we can send messages to the CLI
let io = null
beforeAll(() => (io = stdin()))
afterAll(() => io.restore())
test('prompt for input', async done => {
const sendKeystrokes = async () => {
io.send(keys.down)
io.send(keys.down)
io.send(keys.enter)
}
setTimeout(() => sendKeystrokes().then(), 5)
const result = await prompt.ask({
name: 'shoe',
type: 'list',
description: 'What shoes are you wearing?',
choices: [ 'Suede', 'Rubber Boots', 'High Heels' ]
})
expect(result).toEqual({ shoe: 'Rubber Boots' }) done()
})

Multiple Inputs

I’d like to ask several questions and test them all. When I was debugging this, I noticed that I needed to wait about 10ms between prompts to ensure the next one was displayed before firing off the keystrokes — otherwise the test would fail.

To do this, I made a little async delay helper function.

// helper function for timing
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

I can use it by writing await delay(10). Easy peasy.

Let’s look at how the multiple inputs looks:

import { stdin } from 'mock-stdin'
import { prompt } from 'gluegun/prompt'
// Key codes
const keys = {
up: '\x1B\x5B\x41',
down: '\x1B\x5B\x42',
enter: '\x0D',
space: '\x20'
}
// helper function for timing
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
// Mock stdin so we can send messages to the CLI
let io = null
beforeAll(() => (io = stdin()))
afterAll(() => io.restore())
test('prompt for input', async done => {
const sendKeystrokes = async () => {
// shoes
io.send(keys.down)
io.send(keys.down)
io.send(keys.enter)
await delay(10)
// name
io.send('Jamon')
io.send(keys.enter)
await delay(10)
// hats
io.send(keys.down)
io.send(keys.space)
io.send(keys.down)
io.send(keys.space)
io.send(keys.enter)
}
setTimeout(() => sendKeystrokes().then(), 5)
const result = await prompt.ask([{
name: 'shoe',
type: 'list',
description: 'What shoes are you wearing?',
choices: [ 'Suede', 'Rubber Boots', 'High Heels' ]
}, {
name: 'name',
type: 'input',
description: 'What is your name?'
}, {
name: 'hats',
type: 'radio',
description: 'What hats do you own?',
choices: [ 'Bowler', 'Fedora', 'Boater' ]
}])
expect(result).toEqual({
shoe: 'Rubber Boots',
name: 'Jamon',
hats: [ 'Fedora', 'Boater' ]
})
done()
})

And we’re done!

Actual Implementation

While I simplified the implementation a bit for the purposes of this blog post, you can check out the actual implementation in this pull request:

Other Links

Jamon Holmgren is a co-founder and CTO at Infinite Red, an app and web design/development agency. Follow Jamon on Medium, Twitter, and Github for more Node/Gluegun, technology, and business talk. If you’re near Portland, Oregon, let’s have coffee!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Published in Red Shift

The official Infinite Red publication for React Native design & development. We’re a fully distributed team building world-class apps for over 20 years for clients all around the world.

Written by Jamon Holmgren

Co-founder & CTO @infinite_red. Lutheran, husband, dad to 4, React Native Radio podcast host, Twitch streamer, hockey goalie. Talking shop!

Responses (1)