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:
- Questionnaire through
secco init
(using enquirer (opens in a new tab)) - 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:
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 matcher.ts
file in order to easily check if a word or a sequence of words is found inside the CLI output (logs).
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), }, }, }}
If you’re using Vitest and haven’t set the globals
option (opens in a new tab) to true
you’ll need to add an import:
import { expect } from 'vitest'
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:
npm install -D execa strip-ansi
Next, create invoke-cli.ts
and add the following contents:
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.
Inside your test you can now use it like so:
// 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)