How to Create a Testable CLI using TypeScript?

This article was originally posted on www.realpythonproject.com

A CLI is a tool that allows users to interact with a computer program through the command line. It can perform various tasks, such as running commands, executing scripts, and interacting with other programs. This tutorial will teach you how to build a simple CLI in TypeScript. We will create a CLI that has the following commands

  • command print a name

  • command to perform basic arithmetic operations with unit tests

  • command to make an API call

  • We will also format our output by adding some color to it!

  • Finally, we will use mocha and chai to write unit tests for our CLI functions

Setup

Create a new folder and set up a project.

mkdir my-cli
cd my-cli
npm init -y

This will create a new directory called my-cli and initialize it as a npm project with default settings

Now, let's install some of the required packages

npm install typescript @types/node --save-dev
npm install commander

Commander.js is an NPM package that makes it easier to build CLI tools. You can find its documentation over here

Configure Typescript for your project by creating a tsconfig.json with the following

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "outDir": "dist",
    "strict": true
  },
  "include": ["src/**/*", "test/cli.spec.ts"]
}

Create two folders src and test.

Inside the src folder, create a file named index.ts and add a Hello World Statment

console.log("Hello World!");

You can either compile the above file and use node to execute the result javascript file

npx tsc && node ./dist/src/index.js

or alternatively you can install tsnode and directly execute the typescript file

npm install ts-node --save-dev

npx ts-node src/index.ts

I recommend installing ts-node since it'll be used when setting up mocha for unit testing.

Commands

Hello World Command

Create a file named utils.ts, this will contain our utility functions

export const printName = (name: string) => {
  console.log(`Hello, ${name || "World"}!`);
};

It accepts a string as an argument. If no string is provided, it simply outputs Hello,World!. If a string is provided, it will output the name Hello,[name]!

Instead of console.log, you can also use other methods such console.error and console.warn. In this tutorial, we will only work with console.log.

Now go back to index.ts. We will create an instance of Commander and register our first command.

import { printName } from "./utils";
import { Command } from "commander";

const program = new Command();

program
    .name("tcli")
    .description("Learn how to build a CLI using TypeScript")
    .version("1.0.0");

program
    .command("hello [name]")
    .action(printName);

Update the name, description and version number of your CLI as needed. When registering a command, the following syntax is used

${command name} ${[arguement1]} ${[arguement2]} ${<arguement3>} ......

If an arguement/flag is required, it should be specified inside <..> and if it is optional, it should be specified inside [...] In this case, we want the arguement to be optional since we have a default output Hello, World!. You can alternatively register a command in the following way as well

program
    .command("Hello")
    .argument("[name]")
    .action(printName);

program.parse(process.argv);

Next we will pass the printName function as a callback method.

Now run the following in your terminal to run the CLI

npx ts-node src/index.ts hello

or

npx ts-node src/index.ts hello Jake

Now, let's add some color to the output

For this, we will have to install a npm package called Chalk

npm install chalk

The various color functions provided by Chalk take a string as an argument and return a string, so we basically need to wrap our strings in one of Chalk's functions and can directly use it with console.log

Go back to utils.ts and update the printName function as follows

import * as chalk from "chalk";

export const printName = (name: string) => {
  console.log(chalk.yellow(`Hello, ${name || "World"}!`));
};

This will print the output in yellow.

Try running the below command again to see the output

npx ts-node src/index.ts hello

You can even add the above command as a script in package.json

  "scripts": {
    "local-cli": "ts-node src/index.ts"
  }

Now you could run index.ts with the following command

npm run local-cli

If you are using the script from package.json, you need to use -- before adding the command

npm run local-cli -- hello

or

npm run local-cli -- hello Jake

Command to Perform Operations on Numbers

First, let's write the a function to perform various operations

export const performOperation = (
  num1: number,
  num2: number,
  options: { operation: string }
) => {
  let result: number;
  switch (options.operation) {
    case "multiply":
      result = num1 * num2;
      break;
    case "divide":
      result = num1 / num2;
      break;
    default:
      console.error(chalk.red("Invalid operation"));
      process.exit(1);
  }
  console.log(chalk.green(result));
};

It is a pretty simple function, it accepts a couple of numbers and the operation to perform. It has a switch case block to perform either multiplication or division. If any other operation is provided, it prints an error message in red and exits.

For this command we need 3 values, the 2 numbers and the operation.

import {performOperation} from "./utils";

program
  .command("calculate <num1> <num2>")
  .option(
    "-o, --operation <operation>",
    "operation to perform: multiply, or divide"
  )
  .action(performOperation);

program.parse(process.argv);

In the code above, we add an option for the operation. We can also provide a help message if the user uses -h flag.

Test the above using the following command

npm run local-cli -- calculate 1 2 -o multiply

The following command should return an error message

npm run local-cli -- calculate 1 2 -o add

Command to make an API call

To make the API call, we will be using axios

npm install axios

We will be using the agify API, it tries to predict the age of a name. Below is the function to make a request to the API

Like before, write it in the utils.ts file

import axios from "axios";

export const getAge = async (name: string) => {
  const response = await axios.get(`https://api.agify.io/?name=${name}`);
  const data = await response.data;
  console.log(`${chalk.yellow(data["name"])} - ${chalk.gray(data["age"])} `);
};

Registering the command is pretty straight forward

import {getAge} from "./utils";

program
    .command('age [name]')
    .action(getAge)

program.parse(process.argv);

Test it using the below command

npm run local-cli -- age rahul

How to make the CLI Tool "Global"?

Right now, we have to manually run the script to run our CLI Tool but this is not optimal. We want users to be able to install it as an npm package and then use a command like tcli to run it. Below is how you can add that functionality

Add the following shebang line at the top of your index.ts or the file where you created an instance of Commander

#!/usr/bin/env node

Now, update your package.json file by adding the following

  "bin": {
    "tcli": "./dist/src/index.js"
  }

They key is what the user would have the call to run the CLI and the value is the path to the compiled javascript file. In this case, the user would have to type tcli to run our CLI tool.

You can also test it by installing your package globally

npm i -g

Now if you type tcli in your CLI, it should run your CLI tool

tcli hello

You can uninstall the package as well

npm uninstall typescriptcli -g

You can also update the scripts in package.json with some more helpful commands

 "scripts": {
    "install-cli": "npx tsc && npm i -g",
    "uninstall-cli": "npm uninstall typescriptcli -g",
    "test-cli": "tcli hello",
  }

How to Test a CLI Tool?

We will use mocha and chai for unit testing. Install the required dependencies

npm i mocha chai @types/mocha @types/chai --save-dev

Create a folder called test and a file called cli.spec.ts inside it

Before any of the unit tests are run, we need to install our package globally so that our unit tests can test the CLI

import { before, after, test } from "mocha";
import * as path from "path";
import * as childProcess from "child_process";

before(() => {
  childProcess.execSync(`npm install -g`, { cwd: path.join(__dirname, "..") });
  console.log("Installed CLI");
});

after(() => {
  childProcess.execSync(`npm uninstall -g`, { cwd: path.join(__dirname, "..") });
  console.log("Uninstalled CLI");
});

We will use childProcess.execSync to install and later uninstall the CLI. Now in the same file, let's add some unit tests to test the hello command and the calculate command

test("should print hello world", () => {
  // Run the hello command and capture the output
  const output = childProcess.execSync(`tcli hello`, {
    encoding: "utf8",
  });

  // Check that the output is correct
  expect(output).to.equal("Hello, World!\n");
});

test("should print a greeting", () => {
  // Run the hello command and capture the output
  const output = childProcess.execSync(`tcli Hello Rahul`, {
    encoding: "utf8",
  });

  // Check that the output is correct
  expect(output).to.equal("Hello, Rahul!\n");
});

test("should perform the specified operation", () => {
  // Run the calculate command and capture the output
  const output = childProcess.execSync(`tcli calculate 3 4 -o multiply`, {
    encoding: "utf8",
  });

  // Check that the output is correct
  expect(output).to.equal("12\n");
});

This will only check the string value output in the console, it doesn't check the color of the output. Similarly you add an unit test for the age command as well.

Conclusion

A CLI is a powerful tool and I hope you found this tutorial useful. Here a few other articles that you may find interesting

https://www.realpythonproject.com/what-is-axios-create-in-javascript/