Testing Commander.js command line applications
Software Engineer
Using terminal commands like git clone <some GitHub repository>
is a common practice known as using the command line. In many cases, working with a command-line interface (CLI) application can be faster and more efficient than using a graphical user interface (GUI). Tools like Commander.js, a Node.js command-line solution, simplify the creation of CLI applications by providing a set of high-level tools for gathering input and managing command-line options.
In this tutorial, you will learn the details of CLI application development with Commander.js and how you can build robust and resilient CLI applications by thoroughly testing them. Testing is a crucial step in ensuring that your CLI applications behave as expected under various conditions and can handle errors gracefully. To ensure your application continues to function as expected as you add new features, we’ll set up a continuous integration pipeline to automatically test every change.
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
- A basic understanding of CI/CD pipelines
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 this command in your terminal:
git clone https://github.com/CIRCLECI-GWP/palatial-cakes-cli.git
cd palatial-cakes-cli
npm install
Why use CLI applications?
Graphical user interfaces are amazing, but the the software revolution was spawned using command line interfaces.
So, what exactly is a CLI application? A CLI application is a program that allows text commands to be entered into a computer terminal. These commands are then interpreted by the host computer as a program being executed.
GUIs have their uses, but CLI apps are preferable in some cases:
- Knowing CLI commands and tools boosts a developer’s productivity. Using the command line with automation is faster and more efficient than using a GUI can be.
- Compared to GUI apps, CLI apps require less memory and processing power, making them faster to use.
- Because of their text-based interface, CLI apps can run perfectly well on low-resolution monitors and are supported by a wide range of operating systems.
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
, while others need manual text input in addition to the provided options.
Let’s review command-line interface structure. Here’s a syntax example of a git
command-line application when the fetch
command is executed.
Program
is the name of the actual program, usually a noun.- The
command
** is a verb that describes what the program does, or what you are instructing the program to do. Arguments
allow the CLI to accept values while they are being processed.- An
option
is a documented type of argument that modifies the behaviour of a command. Options 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 cakes available for order.
There are two different commands for running the demo CLI application: one for ordering cakes and one for getting a list of cakes that can be ordered.
To make a command line cake order, run:
npm run order-cake
To get a list of all available cakes, run:
npm run list-cakes
Here is an illustration of the two different workflows for ordering and listing cakes using the CLI.
Writing tests for the CLI application
Now that you know how the CLI application works, it’s good to double-check that adding more changes won’t break the code. Testing also gives you insight into potential error-prone areas. You will add the 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
Instead of just providing the questions, which will prompt for input via the CLI, put this logic to the test by providing the questions and the expected answers. This causes Inquirer to avoid prompting for answers.
Because this method returns a promise, you 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 described in the next code snippet, 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 add 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 the 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, you 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, you 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 can now test inputs and outputs for your cake order commands.
Listing the cakes
Testing the logic that handles cake rendering is simple. You 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 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 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 this 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
docker:
- image: cimg/node:21.4.0
steps:
- checkout
- 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/__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
. 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 run your CLI application tests and integrated them into CircleCI.
Now any time you make changes to your CLI 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 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 in more detail 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!