Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use runCommand() from @oclif/test to test an oclif command’s user-visible behavior: its output, return value, and errors. In this first red-green-refactor slice, a whoami command makes an HTTP request, prints the signed-in user’s email on success, and exits with an error when the API responds with HTTP 401. A mocked HTTP response keeps both tests independent of a live service.

What to test in an oclif command

oclif is a Node.js framework for building command-line interfaces. The framework does not require a particular test runner: generated projects conventionally include Mocha, @oclif/test, and an example test, but you can use another runner if you configure it to work with the test utilities.

For a command, focus on its observable contract rather than its private implementation. A caller sees text written to stdout or stderr, whether the command succeeds, and its exit status when it fails. Tests can also inspect the command’s return value when that is part of what the command exposes.

  • runCommand(command) runs a CLI command and provides captured stdout, stderr, a return value, and an error to assert against.
  • runHook(hook) runs an oclif hook and provides the corresponding observable result.
  • captureOutput(callback) captures output and the callback’s return value or error when you need to test a callback or lower-level code rather than run a command.

The example below uses Mocha-style tests, runCommand(), Axios for the HTTP request, and Nock to stub it. It tests a single behavior in two cases: an authenticated response and an unauthorized response.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Set up the test and stub the HTTP dependency

In a generated oclif project, start from its existing test and adapt the command and assertions to your behavior. If the project does not already have the dependencies used here, add @oclif/test, axios, and nock using your package manager. Keep the real HTTP client in the command and replace only the network response in the test.

Assume the command will request GET https://api.example.test/user. The host is a test fixture for this example, not a real API. A test can define the response and run the CLI without contacting a service:

import { expect } from 'chai'
import nock from 'nock'
import { runCommand } from '@oclif/test'

describe('whoami', () => {
  afterEach(() => {
    nock.cleanAll()
  })

  it('prints the signed-in user email', async () => {
    nock('https://api.example.test')
      .get('/user')
      .reply(200, { email: 'ada@example.test' })

    const { stdout, stderr, error } = await runCommand('whoami')

    expect(error).to.equal(undefined)
    expect(stdout).to.equal('ada@example.testn')
    expect(stderr).to.equal('')
  })
})

Run the new test before implementing the command. It should fail because the command does not yet exist or does not produce the asserted output. That failing test is the red step: it makes the required behavior precise before implementation begins.

Implement the smallest behavior that turns the test green

With the success case in place, add the command. This example uses an Axios request and writes only the email and newline to stdout:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import { Command } from '@oclif/core'
import axios from 'axios'

export default class Whoami extends Command {
  async run(): Promise<void> {
    const response = await axios.get<{ email: string }>(
      'https://api.example.test/user',
    )

    this.log(response.data.email)
  }
}

The test’s exact stdout assertion makes formatting part of the contract. If the intended interface includes a label, such as Email: ada@example.test, change the assertion and command together so the test documents that choice. As written, this.log() appends a newline, hence the expected n.

Run the test again. A passing result is the green step: the command performs the requested operation and produces the tested output. The test does not care whether the command uses Axios internally; it cares that the CLI behaves as promised.

Add a failure case and assert the exit status

A command’s failure behavior is also part of its interface. Have the mocked API respond with HTTP 401, then assert that the command reports the expected oclif exit status rather than allowing the Axios exception to surface as an uncontrolled failure.

it('exits with status 2 when the API rejects the request', async () => {
  nock('https://api.example.test')
    .get('/user')
    .reply(401, { message: 'Unauthorized' })

  const { stdout, stderr, error } = await runCommand('whoami')

  expect(stdout).to.equal('')
  expect(stderr).to.contain('Not logged in')
  expect(error?.oclif?.exit).to.equal(2)
})

Then handle that case in the command. Keep the special case narrow: an HTTP 401 means the user is not authenticated; other request failures should remain distinguishable rather than being mislabeled.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import { Command } from '@oclif/core'
import axios from 'axios'

export default class Whoami extends Command {
  async run(): Promise<void> {
    try {
      const response = await axios.get<{ email: string }>(
        'https://api.example.test/user',
      )

      this.log(response.data.email)
    } catch (error) {
      if (axios.isAxiosError(error) && error.response?.status === 401) {
        this.error('Not logged in', { exit: 2 })
      }

      throw error
    }
  }
}

Run both tests. The success test checks the output and empty stderr; the failure test checks that stdout remains empty, the user-facing error is on stderr, and error?.oclif?.exit is 2. If your command uses a different exit status or error wording, assert that deliberate contract instead.

Refactor without changing the tested contract

Once both cases pass, refactor only where it improves clarity—for example, extract the API request into a small client function or share setup between tests. Keep the mocked URL and response at the network boundary, and leave the assertions focused on what a CLI user can observe.

After a refactor, rerun the tests. If the output, error stream, or exit status changed unintentionally, the tests should expose it. If the intended CLI behavior changed, update the tests to express the new contract rather than weakening assertions to make them pass.

Choose the right oclif test utility

Use runCommand() for CLI behavior

Use runCommand() when the question is what happens when someone runs a command. It exercises the command through oclif and lets you check stdout, stderr, errors, return values, and oclif’s exit information.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use runHook() for lifecycle behavior

Use runHook() when the behavior under test is an oclif hook rather than a command. It provides the same kind of observable result for assertions, without making you invoke a command simply to reach a hook.

Use captureOutput() for a callback

Use captureOutput(callback) to capture stdout, stderr, the callback’s return value, and a callback error without running a full command or hook. Its options let you print captured streams, strip ANSI codes (on by default), and set NODE_ENV for the capture. This makes it useful for lower-level output tests; for a command’s actual CLI contract, prefer runCommand().

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Use Vitest without losing captured output

Mocha is the generated-project default, not a requirement. When using Vitest with @oclif/test, disable Vitest’s console interception in vitest.config.ts. Vitest’s default interception can conflict with the library’s native stdout and stderr capture, leaving assertions incomplete.

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    disableConsoleIntercept: true,
  },
})

Use the same behavior-first tests after changing runners. The runner determines how tests are discovered and executed; it should not change what the command promises to its users.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Keep tests repeatable as the project grows

  • Stub every external request in the test that depends on it. A live API adds network availability, credentials, and changing data to a test that should be deterministic.
  • Clean up Nock interceptors after each test so one case cannot leave network expectations for another.
  • Test meaningful success and failure outcomes, not every private line or implementation detail.
  • Keep output assertions exact where formatting matters; use a looser assertion only when the extra output is intentionally variable.
  • Use a supported Node.js runtime for your oclif project. The oclif/core repository states that Node 18 and later are supported; check the project’s own runtime requirements when choosing a version.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.