This tutorial covers:
- How CLI applications work
- Writing tests for a CLI application
- Automating command line application tests with CircleCI
Breaking changes in production are inconvenient and can be costly to fix. Using commands like git clone < some GitHub repository >
, executed on your terminal is a common practice, known as using the command line. This practice can be faster and more efficient than using a GUI. For this tutorial, I will walk you through the process of testing command-line applications git
, explain why you need command-line applications, and describe in detail how they work.
Prerequisites
The following items are required to complete this tutorial:
- Node.js installed on your system.
- Some knowledge of working with JavaScript and Node.js.
- A CircleCI account.
- A GitHub account and an understanding of how Git works.
- An understanding of how CI/CD pipelines work.
Our tutorials are platform-agnostic, but use CircleCI as an example. If you don’t have a CircleCI account, sign up for a free one here.
Setting up the application
To help you follow with this tutorial, I have created a Github repository for you to clone. You will need to install the project’s dependencies by running the following command in your terminal:
git clone https://github.com/CIRCLECI-GWP/palatial-cakes-cli-app.git
cd palatial-cakes-cli-app
npm install
Why use CLI applications?
The advances in technology resulting from sophisticated Graphic User Interfaces (GUIs) are amazing, but we cannot deny that Command Line Applications (CLIs) spawned the software revolution.
So, what exactly is a CLI
application? A command-line interface (CLI) application is a program that allows text commands to be entered via a computer terminal and they are then interpreted by the host computers as a program being executed.
Let’s look at why CLI apps are preferable to GUI apps in some cases.
- Knowing CLI commands and the tools with them boosts a developer’s productivity. Using the command line with automation (repetitive tasks) is faster and more efficient than using a GUI can be.
- Compared to GUI apps, CLI apps require less memory and processing power from a machine.
- Because of their text-based interface, CLI apps can run perfectly well on low-resolution monitors and are supported by a vast array of operating systems.
Description of how CLI applications work
Working with CLI applications requires text commands entered into and executed by the host computer’s terminal. Some CLI apps allow users to choose from provided default options
, whereas others need manual text input in addition to the provided options.
Let’s take a look at a command-line interfaces structure and syntax example of a git
command-line application when the fetch
command is executed.
In this case, the Program
keyword is the name of the actual program, which is usually a noun. The command
(a verb) describes what the program does or what we’re instructing the program to do. Arguments
allow the CLI to accept values while they are being processed. A documented type of argument that modifies the behaviour of command is known as an option
. These are entered as hyphen-prefixed characters.
Overview of how our sample CLI application works
In this tutorial, I have created a practice CLI that will help you learn how to test command-line applications. The CLI application lets you order cakes, and create a list of all available cakes that can be ordered.
There are two different commands for running our CLI application: one for ordering the cakes and one for getting the list of cakes that can be ordered.
Ordering a cake
To make a command line cake order run:
npm run order-cake
Listing all cakes
To get a list of all available cakes, run this command:
npm run list-cakes
Here is an illustration of the two different workflows for ordering and listing cakes using the CLI.
Writing tests for our CLI application
Now that you have seen how our CLI application works, it’s good to double-check that adding more changes won’t break the code. Testing also helps you gain insight into potential error-prone areas. To do this, you will add tests to the existing cake application logic. Then you will use Jest to test your application.
Understanding the cake ordering logic
This is how the logic that handles cake orders is implemented:
/** lib/order.js **/
const inQuirerOder = async () => {
const answers = await inquirer.prompt(orderDetailsQuestions);
return Object.keys(answers).map(
(key, index) => `\n${index + 1}:${key} => ${answers[key]}`
);
};
Using the Inquirer.js
’s prompt()
method, you provide the user with a list of options. For each question, you retrieve the user-selected answer using its key.
According to the documentation for Inquirer.js, the prompt method accepts an array containing the question object and another argument: answers, an object containing the values of previously answered questions. The answers object is empty by default:
inquirer.prompt(questions, answers) -> promise
To put this logic to the test, instead of just providing the questions, which will prompt for input via the CLI, we will provide the questions and the expected answers. This causes Inquirer to avoid prompting for answers.
Because this method returns a promise, we can use the [async-await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)
pattern to obtain the response object.
Next you will test ordering various cakes, so grouping the related tests into a single test suite is a good practice. The syntax for grouping several related tests using the describe()
method is provided below, along with three test blocks with different types of tests for your cakes.
/** Order.spec.js **/
describe('Order different types of cakes', () => {
test('order cake: type A', async () => {
...
});
test('order cake: type B', async () => {
...
});
test('order cake: type C', async () => {
...
});
});
Getting started with assertions
Import everything from the order.js
file and the inquirer
module at the top of the file.
/** __tests__/order.spec.js **/
const order = require("../lib/order");
const inquirer = require("inquirer");
Now that you have the required modules, you can quickly create a cake object inside the first test block; this will be our answer. You will get your questions from the imported order file.
/** __tests__/order.spec.js **/
cakeA = {
Coating: "Butter",
type: "Strawberry",
"Cake Size": "Medium",
Toppings: "Fruit",
};
The first cake object contains key-value
pairs, the keys taken from the original question object. The only difference between the other two test blocks will be the values for the cake object and the object name.
As previously stated, we will provide a variety of questions and answers for the Inquirer prompt method:
const order_cli = await inquirer.prompt(order.orderDetailsQuestions, cakeA);
The returned promise is an object containing the answers that the user could have chosen while using the CLI. As a result, we can assert these mocked responses.
expect(order_cli).toMatchObject(cakeA);
If you want to review the entire order.spec.js
test file, you can find it here.
Just like that you are now able to test various inputs and outputs for your cake order commands.
Listing the cakes
Testing the logic that handles cake rendering will be simple. You will provide a list of cakes and then assert it against the list of cakes rendered by your CLI.
The full implementation:
/** __tests__/cakes-list.spec.js **/
const renderCakes = require("../lib/cakes-list");
const results = [
"\n1 => Strawberry",
"\n2 => Vanilla",
"\n3 => Mint",
"\n4 => White Chocolate",
"\n5 => Black Forest",
"\n6 => Red Velvet",
"\n7 => Fruit Cake",
];
test("renders cakes list", () => {
expect(renderCakes()).toEqual(results);
});
This snippet imports the cakes-list
module and then uses the renderCakes
method to assert that the list of cakes is the same as the one previously declared in your command-line application.
Setting up error handling
You would probably like to make sure that errors are detected and handled within your CLI app, so that you can fix bugs when they occur. The tests you will write here will cover unknown options
, commands
, and the use of undefined
options. You will not be testing outputs in this test but rather whether an error is detected.
Checking for detection of options after the delimiter
You can start by finding out what happens when the arguments include undefined options. Possible options are provided after –
.
Before you can declare a program variable, you need to make it available:
const { Command } = require("commander");
Commander.js
recommends that creating a local command object to use while testing. You will do that later, inside each test block, like this:
const program = new Command();
Consider the following test snippet:
/** handle-errors.spec.js **/
test("when arguments includes -- then stop processing options", () => {
const program = new Command();
program
.option("-c, --coatings [value]", "cake coatings to apply")
.option("-t, --type <cake-type>", "specify the type of cake");
program.parse([
"node",
"palatial-cakes-cli",
"--coatings",
"--",
"--type",
"order-cake",
]);
const opts = program.opts();
expect(opts.coatings).toBe(true);
expect(opts.type).toBeUndefined();
expect(program.args).toEqual(["--type", "order-cake"]);
});
The first —
non-option-argument argument should be accepted as a delimiter indicating the end of options. Even if they begin with the -
character, any subsequent arguments should be treated as operands.
Because the —-coatings
option comes before the --
, it is treated as a valid option, and you can perform an assertion to see if that is correct. The -–type
following this should be treated as an undefined
option.
program.parse(arguments)
will process the arguments, and any options not taken in by the program will be left in the program.args
array. Because the –type
option was ignored, you can conclude that it belongs in the program.args
array.
Checking for detection of unknown options
/** handle-errors.spec.js **/
test("unknown option, then handle error", () => {
const program = new Command();
program
.exitOverride()
.command("order-cake")
.action(() => {});
let caughtErr;
try {
program.parse(["node", "palatial-cakes-cli", "order-cake", "--color"]);
} catch (err) {
caughtErr = err;
}
expect(caughtErr.code).toBe("commander.unknownOption");
});
There’s nothing out of the ordinary in the above test block. It is just passing an unknown option –-color
to the program.parse()
method and checking to see if it’s detected as an error. You can confirm that the error is an instance of commander.unknownOption
as expected.
The error is then handled by neatly logging it to the terminal. If you run npm test
with the above code block inside the handle-errors.spec.js
file, you should get a passing test with the following error message: error: unknown option '--color'
.
Checking for detection of an unknown command
Just like you tested for an unknown option, you can detect an unknown command and then neatly log an error message to the terminal.
Here’s the implementation of the test snippet:
/** handle-errors.spec.js **/
test("unknown command, then handle error", () => {
const program = new Command();
program
.exitOverride()
.command("order-cake")
.action(() => {});
let caughtErr;
try {
program.parse(["node", "palatial-cakes-cli", "make-order"]);
} catch (err) {
caughtErr = err;
}
expect(caughtErr.code).toBe("commander.unknownCommand");
});
This provides a list of the expected arguments in the program.parse()
method, but with an unknown CLI command make-order
. You can expect the error thrown to be an instance of commander.unknownCommand
so you can assert this against that error. Running your tests should verify that they all pass locally before setting them up in a CI environment.
In the next section, you will connect your GitHub account to CircleCI. Then you will push all of the tests you have written to your Github account so that you can configure them to run on the CI environment.
Configuring CircleCI
To configure CircleCI, create a directory named .circleci
and add a file named config.yml
. In the .circleci/config.yml
file, add this configuration:
# .circleci/config.yml
version: 2.1
jobs:
build:
working_directory: ~/palatial-cakes-cli-app
docker:
- image: cimg/node:10.16.3
steps:
- checkout
- run:
name: update npm
command: "npm install -g npm@5"
- restore_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
- run:
name: install dependencies
command: npm install
- save_cache:
key: dependency-cache-{{ checksum "package-lock.json" }}
paths:
- ./node_modules
- run:
name: run tests
command: npm test
- store_artifacts:
path: ~/palatial-cakes-cli-app/__tests__
Now you can commit and push your changes to the repository. Then set up your project on the CircleCI dashboard.
On the CircleCI dashboard, go to Projects. All the GitHub repositories associated with your GitHub username or organization will be listed. The repository for this tutorial is palatial-cakes-cli-app
. On the Projects dashboard, select the option to set up the project. Use the option for an existing configuration in the branch main
.
Voila! On checking the CircleCI dashboard and expanding the build details, you can verify that you have successfully running your CLI application tests and integrated them into CircleCI.
Now any time you make changes to your application, CircleCI will automatically run your tests and verify whether the changes made are breaking your application.
Conclusion
In this tutorial, you have learned about CLI applications and how they work. You set up a simple CLI application, learned how it works, and wrote tests for it. You then configured CircleCI to run your tests and verified that those tests were successful.
Although the sample application was already created and configured, it may be worthwhile to go through the files. This may help you better understand what happens under the hood when the CLI application is run by commander.js
.
As always, I enjoyed creating this tutorial for you, and I hope you found it valuable. Until the next one, keep learning and keep building!
Waweru Mwaura is a software engineer and a life-long learner who specializes in quality engineering. He is an author at Packt and enjoys reading about engineering, finance, and technology. You can read more about him on his web profile.