Skip to content

About

Collection of testing utilities for use with the Heroku CLI

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

20 watching

Forks

@heroku-cli/test-utils

A collection of test utilities for the Heroku CLI, designed to make testing CLI commands easier and more consistent. Built with ESM support and TypeScript.

Overview

This package provides utilities to help test Heroku CLI commands, including:

  • Command execution helpers with output capture
  • Output expectation utilities
  • Test initialization helpers
  • Shared ESLint configuration

Installation

npm install --save-dev @heroku-cli/test-utils

Requirements

  • Node.js >= 22.12
  • TypeScript >= 6 (required by the shared ESLint config; see ESLint Configuration)
  • ESM (ES Modules) support
  • A test runner of your choice (Mocha and Vitest are both supported)

ESLint Configuration

This package provides a shareable ESLint 10 flat config that extends oclif's base configuration with Heroku-specific rules. The base config is test-framework-agnostic; layer on the mocha or vitest overlay depending on what your repo uses.

Setup

  1. Install the required peer dependencies:
npm install --save-dev eslint@^10 eslint-config-oclif@^7
# vitest repos also need:
npm install --save-dev @vitest/eslint-plugin
  1. Create eslint.config.js in your project root:

Mocha repos:

import herokuConfig from '@heroku-cli/test-utils/eslint-config'
import mocha from '@heroku-cli/test-utils/eslint-config/mocha'

export default [
  ...herokuConfig,
  ...mocha,
  // Your additional config here
]

Vitest repos:

import herokuConfig from '@heroku-cli/test-utils/eslint-config'
import vitest from '@heroku-cli/test-utils/eslint-config/vitest'

export default [
  ...herokuConfig,
  ...vitest,
  // Your additional config here
]

Note: As of eslint-config-oclif 7, the config uses type-aware linting (projectService). ESLint must be able to resolve every linted file through a tsconfig.json, so make sure your test files (and root config files such as vitest.config.ts) are covered by your tsconfig's include — files outside the TypeScript project fail with a was not found by the project service parse error. If you build from the same tsconfig, keep a separate tsconfig.build.json that only includes src so test files aren't emitted to your published output.

TypeScript 6 required: eslint-config-oclif 7 (via eslint-config-xo) declares a typescript >=6 peer dependency. If your project is still on TypeScript 5, npm will install a second, nested TypeScript 6 for the lint stack, and the version skew crashes type-aware rules with tsutils.unionConstituents is not a function. Pin a single TypeScript ~6.0 at your project root (and, if needed, add "overrides": { "typescript": "$typescript" }) so the whole lint stack resolves one TypeScript. Note that TypeScript 6 requires an explicit rootDir in build configs that previously inferred it.

What's Included

The base configuration includes:

  • oclif base rules - TypeScript, Node.js, and CLI best practices
  • Import plugin - Module import/export rules
  • Heroku-specific rules:
    • no-console: 'off' - Allow console for CLI output
    • @typescript-eslint/no-explicit-any: 'warn'
    • Custom indent rules for readability
  • Ignore patterns - Excludes dist/, coverage/, and workflows-repo/

The mocha overlay is optional — the base config already passes through oclif's mocha-plugin defaults. Importing it bumps mocha/no-exclusive-tests from warn to error so a stray it.only fails CI.

The vitest overlay adds @vitest/eslint-plugin's recommended rules and globals for test files, and globally disables every mocha/* rule. (The mocha plugin itself is loaded transitively via eslint-config-oclif and can't be unregistered in flat config — but the overlay neutralizes its rules so they produce zero diagnostics.)

Vitest Configuration

For vitest repos, this package also provides a shareable vitest config factory at @heroku-cli/test-utils/vitest-config, so you no longer hand-maintain the cross-repo defaults in every vitest.config.ts. The base sets the v8 coverage provider, disableConsoleIntercept: true (required by runCommand/captureOutput, which write directly to process.stdout/stderr), a default src/**/*.ts coverage surface, and excludes the release workflow's ./workflows-repo checkout from test discovery.

Install the peer dependencies (vitest, plus @vitest/coverage-v8 for the v8 coverage provider this config sets):

npm install --save-dev vitest @vitest/coverage-v8

Call the factory in your vitest.config.ts, passing only what's specific to your repo (test dir, timeout, coverage thresholds/reporter):

import defineHerokuVitestConfig from '@heroku-cli/test-utils/vitest-config'

export default defineHerokuVitestConfig({
  test: {
    include: ['test/**/*.test.ts'],
    testTimeout: 60_000,
  },
})

Overrides win per key, and arrays replace rather than concatenate, so you can override any individual option — including the source layout:

export default defineHerokuVitestConfig({
  test: {
    coverage: { include: ['lib/**/*.ts'] }, // replaces the default src/**/*.ts
  },
})

To extend a shared list instead of replacing it, spread the exported herokuCoverageExclude / herokuTestExclude constants into your override:

import defineHerokuVitestConfig, { herokuCoverageExclude } from '@heroku-cli/test-utils/vitest-config'

export default defineHerokuVitestConfig({
  test: {
    coverage: { exclude: [...herokuCoverageExclude, 'fixtures/**'] },
  },
})

The factory types its argument, so a misspelled option (e.g. testTimout, or thresholds.line instead of lines) is a compile error rather than a silently-ignored one. vitest and @vitest/coverage-v8 are optional peer dependencies, needed only by consumers of this export.

Usage

Running Commands

The runCommand helper runs oclif commands and captures their output. It's framework-agnostic — use it with whatever assertion library and runner your project uses.

Mocha + chai:

import { runCommand } from '@heroku-cli/test-utils'
import { expect } from 'chai'
import { MyCommand } from '../src/commands/my-command.js'

describe('MyCommand', () => {
  it('should run successfully', async () => {
    const { result, stdout } = await runCommand(
      MyCommand,
      ['--flag', 'value'],
      { root: __dirname }
    )

    expect(result).to.deep.equal({ success: true })
    expect(stdout).to.include('Expected output')
  })
})

Vitest:

import { runCommand } from '@heroku-cli/test-utils'
import { describe, it, expect } from 'vitest'
import { MyCommand } from '../src/commands/my-command.js'

describe('MyCommand', () => {
  it('should run successfully', async () => {
    const { result, stdout } = await runCommand(
      MyCommand,
      ['--flag', 'value'],
      { root: import.meta.dirname }
    )

    expect(result).toEqual({ success: true })
    expect(stdout).toContain('Expected output')
  })
})

Return Value

runCommand returns an object with:

  • result - The command's return value (if successful)
  • error - Error object (if command failed)
  • stdout - Captured standard output
  • stderr - Captured standard error

Test Configuration

Use the test helper functions to set up your test environment:

import { getConfig, getHerokuAPI } from '@heroku-cli/test-utils'

// Get oclif configuration
const config = await getConfig({ root: '/path/to/project' })

// Get Heroku API client
const api = await getHerokuAPI({ root: '/path/to/project' })

Expecting Output

expectOutput compares two strings after normalizing trailing whitespace and indentation. It throws a node:assert AssertionError on mismatch, so it works with any test runner (mocha, vitest, node:test, etc.) without requiring chai.

import { expectOutput } from '@heroku-cli/test-utils'

expectOutput(stdout, `
  expected
  multi-line
  output
`)

If you'd rather assert with your own matcher, use normalizeOutput to get the normalized strings:

import { normalizeOutput } from '@heroku-cli/test-utils'
import { expect } from 'vitest'

const { actual, expected } = normalizeOutput(stdout, `expected output`)
expect(actual).toBe(expected)

Initializing Tests

import { initCliTest } from '@heroku-cli/test-utils'

// In your test setup file (mocha) or vitest setupFiles entry
initCliTest()

Development

Building

npm run build

Linting

npm run lint

Testing

npm test

Contributing

Please read CONTRIBUTING.md for details on our code of conduct and the process for submitting pull requests.

License

This project is licensed under the ISC License - see the LICENSE file for details.

About

Collection of testing utilities for use with the Heroku CLI

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

20 watching

Forks

Releases

Packages

Used by

Contributors

Languages