[go: up one dir, main page]

Skip to content
How to Test CLI Output in Jest & Vitest

Created: – Last Updated:

Tags: CLI

Digital Garden

For the CLI of secco (opens in a new tab) I wanted to test its output with my existing Vitest (opens in a new tab) setup. The CLI has two modes:

  1. Questionnaire through secco init (using enquirer (opens in a new tab))
  2. A “fire once and forget” mode like most CLIs

I didn’t want to test the first case as my prompts are simple and most of secco’s logic lies in the second mode. If you want to test enquirer, I can recommend reading Gleb Bahmutov’s article Unit testing CLI programs (opens in a new tab) as a starting point.

At the end of this post you should be able to write such tests:

tests/config.test.ts
ts
import { join } from 'node:path'import { YourCLI } from './invoke-cli'const emptyFixture = join(__dirname, 'fixtures', 'empty')describe('missing config file', () => {  it('should display error when no config file is found', () => {    const [exitCode, logs] = YourCLI().setCwd(emptyFixture).invoke(['start', '--verbose'])    logs.should.contain('No config file found in')    logs.should.contain('Please run `cli init` to create a new config file.')    expect(exitCode).toBe(0)  })})

This guide uses Vitest but you should be able to transfer it to Jest (opens in a new tab), too, as the APIs are very similar.

Create a logs matcher

Create a matcher.ts file in order to easily check if a word or a sequence of words is found inside the CLI output (logs).

tests/matcher.ts
ts
export function createLogsMatcher(output: string) {  return {    logOutput() {      console.log(output)    },    should: {      contain: (match: string) => expect(output).toContain(match),      not: {        contain: (match: string) => expect(output).not.toContain(match),      },    },  }}

Create a CLI helper

Create a invoke-cli.ts file to author a new YourCLI helper following the builder pattern. With it you’ll run your CLI inside a specified directory and with your defined commands.

First, install the necessary dependencies:

shell
npm install -D execa strip-ansi

Next, create invoke-cli.ts and add the following contents:

tests/invoke-cli.ts
ts
import { join } from 'node:path'import process from 'node:process'import type { ExecaSyncError } from 'execa'import { execaSync } from 'execa'import strip from 'strip-ansi'import { createLogsMatcher } from './matcher'const builtCliLocation = join(__dirname, '..', 'dist', 'cli.mjs')type CreateLogsMatcherReturn = ReturnType<typeof createLogsMatcher>export type InvokeResult = [exitCode: number, logsMatcher: CreateLogsMatcherReturn]export function YourCLI() {  let cwd = ''  const self = {    setCwd: (_cwd: string) => {      cwd = _cwd      return self    },    invoke: (args: Array<string>): InvokeResult => {      const NODE_ENV = 'production'      try {        const results = execaSync(          process.execPath,          [builtCliLocation].concat(args),          {            cwd,            env: { NODE_ENV },          },        )        return [          results.exitCode,          createLogsMatcher(strip(results.stderr.toString() + results.stdout.toString())),        ]      }      catch (e) {        const execaError = e as ExecaSyncError        return [          execaError.exitCode,          createLogsMatcher(strip(execaError.stdout?.toString() || ``)),        ]      }    },  }  return self}

The YourCLI builder uses execa’s (opens in a new tab) synchronous method to invoke your CLI and its arguments. Through setCwd you’ll need to define the location where the CLI should be run. The result is a tuple of the exitCode and the cleaned up logs.

Important: You need to define the path to your built CLI through builtCliLocation. Alternatively you could also try using something like ts-node to point to your source file before invoking the CLI.

You could also rewrite this helper to use execa’s Promise interface if you need to use async/await.

Usage

Inside your test you can now use it like so:

ts
// Run CLIconst [exitCode, logs] = YourCLI().setCwd('/absolute/path/to/location').invoke(['command', '--flag'])// Debugging helperlogs.logOutput()// Assertionslogs.should.contain('Some string')logs.should.not.contain('Some other string')expect(exitCode).toBe(0)

Want to learn more? Browse my Digital Garden