Skip to content

How to Test CLI Output in Jest & Vitest

Created: Dec 25, 2023 – Last Updated: Dec 25, 2023

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:

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.')

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).

export function createLogsMatcher(output: string) {
return {
logOutput() {
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:

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(
env: { NODE_ENV },
return [
createLogsMatcher(strip(results.stderr.toString() + results.stdout.toString())),
catch (e) {
const execaError = e as ExecaSyncError
return [
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 CLI
const [exitCode, logs] = YourCLI().setCwd('/absolute/path/to/location').invoke(['command', '--flag'])
// Debugging helper
// Assertions
logs.should.contain('Some string')
logs.should.not.contain('Some other string')

Want to learn more? Browse my Digital Garden