This guide covers the test infrastructure and patterns for writing tests in Commando.
# Run all tests
bun test
# Run specific test file
bun test tests/cli-help.test.ts
# Run specific test by name pattern
bun test -t "should display help"
# Watch mode
bun test --watch
# Stop on first failure
bun test --bail
# Generate JUnit XML report
bun run test:junitBy default, test logs are silent. Enable verbose output for debugging:
# Enable verbose test logger output
VERBOSE=1 bun test
# Run specific test with verbose output
VERBOSE=1 bun test -t "creates required directories"The VERBOSE=1 flag enables:
- Test logger output (pino-pretty formatted)
- Diagnostic information from test helpers
- Structured logging with timestamps and context
All test output is captured to files in build/test-logs/:
# View logs for a specific test file
ls build/test-logs/cli-help-test-ts/
# View stdout from a specific test
cat build/test-logs/cli-help-test-ts/should-display-help-with--help-stdout.log
# View stderr from a specific test
cat build/test-logs/cli-help-test-ts/should-display-help-with--help-stderr.logCommando provides a lightweight extension for Bun's describe and test functions that automatically injects a TestContext parameter with:
fileName: Current test file name (e.g., "cli-help.test.ts")testName: Current test namedescribePath: Array of describe block namesfullName: Full hierarchical name with " > " separator
The extension uses a clever double-execution strategy:
-
First execution (ours): Tracks describe stack and captures context
- When file loads,
inBunExecution = false - Our
describe()pushes to stack, callsfn(), pops from stack - Our
test()captures context from stack and registers with Bun
- When file loads,
-
Second execution (Bun's): Skips our tracking to avoid duplicates
- When Bun executes describe blocks, we set
inBunExecution = true - Our wrappers see the flag and skip tracking/registration
- Prevents duplicate test registration
- When Bun executes describe blocks, we set
// Import extended describe/test
import { describe, test } from './lib/testx';
// Import everything else from bun:test
import { expect, beforeEach } from 'bun:test';describe('My Suite', () => {
test('my test', async (ctx) => {
// ctx.fileName = "my-file.test.ts"
// ctx.testName = "my test"
// ctx.describePath = ["My Suite"]
// ctx.fullName = "My Suite > my test"
expect(ctx.fileName).toBe('my-file.test.ts');
});
});import { describe, test } from './lib/testx';
import { expect } from 'bun:test';
import { setupTestLogs, runCommandWithLogs } from './lib/utils';
describe('CLI Help Output', () => {
test('should display help with --help', async (ctx) => {
// Auto-setup logs based on context
const logs = await setupTestLogs(ctx);
// logs.logDir = "build/test-logs/cli-help-output-test-ts/"
// logs.logBaseName = "should-display-help-with--help"
const result = await runCommandWithLogs({
command: './bin/cmdo',
args: ['--help'],
env: { ...process.env },
logDir: logs.logDir,
logBaseName: logs.logBaseName,
});
expect(result.exitCode).toBe(0);
// Read from log file
const output = await Bun.file(result.stdoutLog).text();
expect(output).toContain('Usage: cmdo');
});
});describe('Outer', () => {
describe('Middle', () => {
describe('Inner', () => {
test('nested test', async (ctx) => {
// ctx.describePath = ["Outer", "Middle", "Inner"]
// ctx.fullName = "Outer > Middle > Inner > nested test"
// But logs still use file name for directory:
const logs = await setupTestLogs(ctx);
// logs.logDir = "build/test-logs/my-file-test-ts/"
// logs.logBaseName = "nested-test"
});
});
});
});The runCommando() function executes the local development version of the CLI (lib/cli.ts) in tests, eliminating the need to run bun reinstall between test iterations.
import { describe, test } from './lib/testx';
import { expect } from 'bun:test';
import { setupTestLogs, TEST_DIRS } from './lib/utils';
import { runCommando } from './lib/runner';
import { join } from 'path';
describe('CLI Tests', () => {
test('should run local CLI', async (ctx) => {
const logs = await setupTestLogs(ctx);
const projectRoot = join(TEST_DIRS.fixtures, 'test-project');
const result = await runCommando({
args: ['--root', projectRoot, '--help'],
logDir: logs.logDir,
logBaseName: logs.logBaseName,
});
expect(result.exitCode).toBe(0);
const output = await Bun.file(result.stdoutLog).text();
expect(output).toContain('Modern CLI framework');
});
});interface RunCommandoConfig {
args: string[]; // CLI arguments
env?: Record<string, string>; // Environment variables
cwd?: string; // Working directory (default: process.cwd())
logDir: string; // Directory for log files
logBaseName?: string; // Base name for log files (default: 'output')
teeToConsole?: boolean; // Echo output to console (default: false)
}- Executes local
lib/cli.tsdirectly (not the installed version) - Sets up
NODE_PATHto point to commando home node_modules (like production wrapper) - Captures stdout/stderr to log files
- Returns exit code and log file paths
- No need to reinstall between test runs during development
Use createLogger() from tests/lib/logger.ts for structured logging in test helpers and utilities:
import { createLogger } from './lib/logger';
const log = createLogger('install-test');
async function setupTest() {
log.info({ testDir }, 'Setting up test environment');
log.debug({ config }, 'Using configuration');
log.error({ error }, 'Setup failed');
}- Pino-pretty formatting: Human-readable output with colors and timestamps
- Silent by default: Only outputs when
VERBOSE=1is set - Structured logging: Pass objects as first parameter for context
- Timestamped: Shows HH:MM:ss.l format
- Named loggers: Pass name to identify source in output
INFO [install-test] Setting up test environment
DEBUG [install-test] Using configuration
ERROR [install-test] Setup failed
- ✅ Test helpers and utilities (setupTestHome, runInstall, etc.)
- ✅ Diagnostic information during test setup/teardown
- ✅ Complex test scenarios that benefit from structured logging
- ❌ Simple assertions (use expect() instead)
- ❌ Production code (use the app logger from lib/logger.ts)
Decision: Use test file name for log directory, normalized test name for log file base name.
- ✅ Simple: One directory per test file
- ✅ Predictable: Easy to find logs for a specific test file
- ✅ No duplication: describe path can change, file name is stable
- ✅ Clean structure:
build/test-logs/cli-help-test-ts/test-name-stdout.log
build/test-logs/
├── cli-help-test-ts/
│ ├── should-display-help-with--help-stdout.log
│ ├── should-display-help-with--help-stderr.log
│ ├── should-display-help-with--h-stdout.log
│ └── should-display-help-with--h-stderr.log
├── cli-color-test-ts/
│ ├── should-use-colors-by-default-stdout.log
│ └── should-use-colors-by-default-stderr.log
└── install-test-ts/
├── creates-required-directories-install-stdout.log
└── creates-required-directories-install-stderr.log
tests/
├── lib/ # Test infrastructure
│ ├── testx.ts # Test framework extensions (~200 lines)
│ ├── runner.ts # Test runner for local CLI execution
│ ├── logger.ts # Structured logging for tests
│ └── utils.ts # Test utilities
├── fixtures/ # Test data
│ └── test-project/
└── *.test.ts # Test cases
-
tests/lib/testx.ts (~200 lines)
- Extends
describeandtestwith all variants (skip, only, todo, if, skipIf, each) - Tracks describe stack
- Injects TestContext
- Extends
-
tests/lib/runner.ts
runCommando()function to execute local CLI in tests- Sets up NODE_PATH like production wrapper
- Captures output to log files
- Eliminates need for
bun reinstallduring development
-
tests/lib/logger.ts
createLogger()for structured logging- Pino-pretty formatting with colors and timestamps
- Silent by default, verbose with
VERBOSE=1 - For test helpers/utilities, not production code
-
tests/lib/utils.ts
setupTestLogs()accepts TestContext or manual stringssetupTestHome()creates isolated HOME directoriesrunCommandWithLogs()executes commands with output captureTEST_DIRSconstants for test directories
All Bun test variants are extended:
describe:
describe(name, fn)describe.skip(name, fn)describe.only(name, fn)describe.todo(name)
test:
test(name, fn, timeout?)test.skip(name, fn, timeout?)test.only(name, fn, timeout?)test.todo(name, fn?, timeout?)test.if(condition)(name, fn, timeout?)test.skipIf(condition)(name, fn, timeout?)test.each(table)(name, fn, timeout?)
Current behavior:
- Test output (stdout/stderr) from spawned processes is captured to files
- Console.log from test code goes to stdout (captured by Bun test runner)
- Test logger output goes to stderr (not captured unless VERBOSE=1)
What we don't have:
- Per-test output capture to files (like Maven Surefire)
- Automatic capture of all console.log/console.error to test-specific files
- Multiple report formats simultaneously (HTML + JSON + JUnit)
See Issue #17: Consider migrating to Vitest for evaluation of alternative test frameworks that provide:
- Rich HTML reports with interactive debugging UI
- Multiple output formats simultaneously (JUnit XML, JSON, HTML)
- Better console output capture and control
- Browser mode for component testing
- Benchmarking and type testing features
Recommendation: Stay with Bun test for now (fastest option), consider Vitest later if:
- Test suite grows large enough that debugging becomes painful
- CI/CD requires multiple report formats
- Team wants better local development debugging experience
Status: ✅ Stable test infrastructure with Bun test runner