@bunli/test
Testing utilities for Bunli CLI applications
@bunli/test
Comprehensive testing utilities for Bunli CLI applications. Test commands, mock user interactions, and verify CLI behavior with confidence.
Installation
bun add -d @bunli/test
npm install -D @bunli/test
pnpm add -D @bunli/test
Features
- 🧪 Test individual commands or entire CLIs
- 🎭 Mock user prompts and shell commands
- ✅ Built-in test matchers for CLI output
- 🔄 Support for validation and retry scenarios
- 📝 TypeScript support with full type inference
- ⚡ Fast execution with isolated test environments
- 🎨 Color output preserved as tags for easy testing
- 🔌 Zero dependencies, works seamlessly with Bun's test runner
Basic Usage
Import testing utilities
import { test, expect } from 'bun:test'
import { testCommand, expectCommand } from '@bunli/test'
Test a simple command
import { defineCommand } from '@bunli/core'
const greetCommand = defineCommand({
name: 'greet',
description: 'Greet someone',
handler: async ({ colors }) => {
console.log(colors.green('Hello, world!'))
}
})
test('greet command', async () => {
const result = await testCommand(greetCommand)
expectCommand(result).toHaveSucceeded()
expectCommand(result).toContainInStdout('[green]Hello, world![/green]')
})
Verify the output
The test utilities preserve color codes as tags for easy assertion:
colors.green('text')
→[green]text[/green]
colors.bold('text')
→[bold]text[/bold]
colors.dim('text')
→[dim]text[/dim]
- All ANSI escape codes are converted to readable tags
Testing Commands with Options
Test commands that accept flags and arguments:
const deployCommand = defineCommand({
name: 'deploy',
options: {
env: option(
z.enum(['dev', 'staging', 'prod']),
{ description: 'Target environment' }
),
force: option(
z.coerce.boolean().default(false),
{ description: 'Force deployment' }
)
},
handler: async ({ flags }) => {
console.log(`Deploying to ${flags.env}${flags.force ? ' (forced)' : ''}`)
}
})
test('deploy with flags', async () => {
const result = await testCommand(deployCommand, {
flags: { env: 'prod', force: true }
})
expect(result.stdout).toContain('Deploying to prod (forced)')
expect(result.exitCode).toBe(0)
})
// Test validation errors
test('deploy validates environment', async () => {
const result = await testCommand(deployCommand, {
flags: { env: 'invalid' as any }
})
expectCommand(result).toHaveFailed()
expect(result.stderr).toContain('[red]Validation errors:[/red]')
expect(result.stderr).toContain('--env:')
})
Mocking User Interactions
Mock Prompts
Test interactive commands by mocking user responses:
import { mockPromptResponses } from '@bunli/test'
const setupCommand = defineCommand({
name: 'setup',
handler: async ({ prompt }) => {
const name = await prompt('Project name:')
const useTs = await prompt.confirm('Use TypeScript?')
const db = await prompt.select('Database:', {
options: ['postgres', 'mysql', 'sqlite']
})
console.log(`Creating ${name} with ${db}${useTs ? ' and TypeScript' : ''}`)
}
})
test('interactive setup', async () => {
const result = await testCommand(setupCommand, mockPromptResponses({
'Project name:': 'my-app',
'Use TypeScript?': 'y',
'Database:': '1' // Select first option (postgres)
}))
expect(result.stdout).toContain('Creating my-app with postgres and TypeScript')
})
// Test select menu display
test('shows select options', async () => {
const result = await testCommand(setupCommand, mockPromptResponses({
'Project name:': 'test',
'Use TypeScript?': 'n',
'Database:': '2' // Select second option
}))
// Verify menu was displayed
expect(result.stdout).toContain('1. postgres')
expect(result.stdout).toContain('2. mysql')
expect(result.stdout).toContain('3. sqlite')
})
Mock Shell Commands
Test commands that execute shell operations:
import { mockShellCommands } from '@bunli/test'
const statusCommand = defineCommand({
name: 'status',
handler: async ({ shell }) => {
const branch = await shell`git branch --show-current`.text()
const hasChanges = await shell`git status --porcelain`.text()
console.log(`Branch: ${branch.trim()}`)
console.log(`Status: ${hasChanges ? 'Modified' : 'Clean'}`)
}
})
test('git status', async () => {
const result = await testCommand(statusCommand, mockShellCommands({
'git branch --show-current': 'feature/awesome\n',
'git status --porcelain': 'M src/index.ts\n'
}))
expect(result.stdout).toContain('Branch: feature/awesome')
expect(result.stdout).toContain('Status: Modified')
})
Testing Validation
Test commands with input validation and retry logic:
const emailCommand = defineCommand({
name: 'register',
handler: async ({ prompt }) => {
const email = await prompt('Enter email:', {
schema: z.string().email()
})
console.log(`Registered: ${email}`)
}
})
test('email validation with retries', async () => {
const result = await testCommand(emailCommand, mockPromptResponses({
// Provide multiple attempts - first two fail, third succeeds
'Enter email:': ['invalid', 'still@bad', 'valid@email.com']
}))
// Check validation errors appear in order
expect(result.stderr).toContain('[red]Invalid input:[/red]')
expect(result.stderr).toContain('[dim] • Invalid email[/dim]')
// Verify final success
expect(result.stdout).toContain('Registered: valid@email.com')
expectCommand(result).toHaveSucceeded()
})
// Test password validation
test('password masking and validation', async () => {
const passwordCommand = defineCommand({
handler: async ({ prompt }) => {
const pass = await prompt.password('Enter password:', {
schema: z.string().min(8)
})
console.log('Password accepted')
}
})
const result = await testCommand(passwordCommand, mockPromptResponses({
'Enter password:': ['short', 'validpassword123']
}))
// Password input is masked with asterisks
expect(result.stdout).toContain('*****') // 'short' masked
expect(result.stdout).toContain('****************') // 'validpassword123' masked
expectCommand(result).toHaveSucceeded()
})
Testing Complete CLIs
Test entire CLI applications with multiple commands:
import { createCLI } from '@bunli/core'
import { testCLI } from '@bunli/test'
test('CLI help command', async () => {
const result = await testCLI(
(cli) => {
cli.command({
name: 'hello',
description: 'Say hello',
handler: async () => console.log('Hello!')
})
cli.command({
name: 'goodbye',
description: 'Say goodbye',
handler: async () => console.log('Goodbye!')
})
},
['--help']
)
expectCommand(result).toContainInStdout('Say hello')
expectCommand(result).toContainInStdout('Say goodbye')
})
test('run specific command', async () => {
const result = await testCLI(
(cli) => {
// ... setup commands
},
['hello'],
{ flags: { verbose: true } }
)
expect(result.stdout).toContain('Hello!')
})
Test Matchers
Bunli provides specialized matchers for CLI testing:
Exit Code Matchers
// Check specific exit code
expectCommand(result).toHaveExitCode(0)
expectCommand(result).toHaveExitCode(1)
// Convenience matchers
expectCommand(result).toHaveSucceeded() // exit code 0
expectCommand(result).toHaveFailed() // exit code !== 0
Output Matchers
// String contains
expectCommand(result).toContainInStdout('success message')
expectCommand(result).toContainInStderr('error message')
// Regex matching
expectCommand(result).toMatchStdout(/deployed to .+ successfully/)
expectCommand(result).toMatchStderr(/failed: .+/)
// Negative assertions
expectCommand(result).not.toContainInStdout('error')
Advanced Testing
Combine Multiple Mocks
Use mockInteractive
to combine prompt and shell mocks:
import { mockInteractive } from '@bunli/test'
test('complex interaction', async () => {
const result = await testCommand(myCommand, mockInteractive(
{
'Project name:': 'awesome-cli',
'Initialize git?': 'y'
},
{
'git init': '',
'git add .': '',
'git commit -m "Initial commit"': ''
}
))
expectCommand(result).toHaveSucceeded()
})
Merge Test Options
Combine multiple test configurations:
import { mergeTestOptions } from '@bunli/test'
test('with merged options', async () => {
const result = await testCommand(myCommand, mergeTestOptions(
{ flags: { verbose: true } },
mockPromptResponses({ 'Name:': 'Test' }),
{ env: { NODE_ENV: 'test' } }
))
})
Test Spinner States
Mock and verify spinner operations:
test('spinner states', async () => {
const command = defineCommand({
handler: async ({ spinner }) => {
const spin = spinner('Processing...')
spin.start()
spin.update('Almost done...')
spin.succeed('Complete!')
// Test other states
const spin2 = spinner('Checking...')
spin2.start()
spin2.fail('Failed!')
const spin3 = spinner('Warning test')
spin3.start()
spin3.warn('Warning!')
const spin4 = spinner('Info test')
spin4.start()
spin4.info('Information')
}
})
const result = await testCommand(command)
expect(result.stdout).toContain('⠋ Processing...')
expect(result.stdout).toContain('⠋ Almost done...')
expect(result.stdout).toContain('✅ Complete!')
expect(result.stdout).toContain('❌ Failed!')
expect(result.stdout).toContain('⚠️ Warning!')
expect(result.stdout).toContain('ℹ️ Information')
})
API Reference
testCommand
Test a single command:
function testCommand(
command: Command,
options?: TestOptions
): Promise<TestResult>
Options:
flags
- Command flags to pass (type-safe based on command options)args
- Positional argumentsenv
- Environment variables to merge with process.envcwd
- Working directory (defaults to process.cwd())stdin
- Input lines for non-prompt stdin (string or array)mockPrompts
- Map of prompt messages to responses (string or array for retries)mockShellCommands
- Map of shell commands to their outputexitCode
- Expected exit code (for testing error scenarios)
Returns:
stdout
- Standard outputstderr
- Standard errorexitCode
- Process exit codeduration
- Execution time in mserror
- Error if command threw
testCLI
Test a complete CLI:
function testCLI(
setupFn: (cli: CLI) => void,
argv: string[],
options?: TestOptions
): Promise<TestResult>
Helper Functions
// Mock prompt responses
mockPromptResponses(responses: Record<string, string | string[]>)
// Mock shell command outputs
mockShellCommands(commands: Record<string, string>)
// Combine prompts and shell mocks
mockInteractive(prompts: Record<string, string>, commands?: Record<string, string>)
// Create stdin for validation testing
mockValidationAttempts(attempts: string[])
// Merge multiple test options
mergeTestOptions(...options: Partial<TestOptions>[])
Best Practices
Color Output: Test utilities preserve colors as tags (e.g., [green]text[/green]
) making it easy to assert colored output without ANSI codes. This works automatically - no configuration needed.
Shell Mock Defaults: The test utilities provide sensible defaults for common shell commands:
git branch --show-current
→main\n
git status
→nothing to commit, working tree clean\n
Override these by providing your own mock values.
-
Test both success and failure cases:
test('handles missing file', async () => { const result = await testCommand(readCommand, { args: ['nonexistent.txt'] }) expectCommand(result).toHaveFailed() expectCommand(result).toContainInStderr('File not found') })
-
Use descriptive test names:
test('deploy command deploys to production with --force flag', async () => { // ... })
-
Mock external dependencies:
// Don't actually hit external APIs or modify files mockShellCommands({ 'curl https://api.example.com': '{"status": "ok"}' })
-
Test validation scenarios:
// Provide multiple attempts for validation mockPromptResponses({ 'Port:': ['abc', '99999', '3000'] // Test invalid → invalid → valid })
-
Verify side effects:
// Check that commands were called const result = await testCommand(deployCommand) expect(result.stdout).toContain('git push origin main')
Common Patterns
Testing Schema Errors
test('handles schema validation errors', async () => {
const result = await testCommand(deployCommand, {
flags: { env: 'qa' as any } // Invalid enum value
})
expectCommand(result).toHaveFailed()
expect(result.stderr).toContain('[red]Validation errors:[/red]')
expect(result.stderr).toContain('[yellow] --env:[/yellow]')
expect(result.stderr).toContain("Expected 'dev' | 'staging' | 'prod'")
})
Testing Shell JSON Output
test('parses JSON from shell commands', async () => {
const command = defineCommand({
handler: async ({ shell }) => {
const data = await shell`curl https://api.example.com`.json()
console.log(`Users: ${data.users.length}`)
}
})
const result = await testCommand(command, mockShellCommands({
'curl https://api.example.com': JSON.stringify({ users: [{}, {}, {}] })
}))
expect(result.stdout).toContain('Users: 3')
})
Testing Progress Updates
test('shows progress during long operations', async () => {
const result = await testCommand(buildCommand)
// Verify progress messages appear in order
const output = result.stdout
const buildIndex = output.indexOf('Building...')
const optimizeIndex = output.indexOf('Optimizing...')
const completeIndex = output.indexOf('✓ Build complete')
expect(buildIndex).toBeLessThan(optimizeIndex)
expect(optimizeIndex).toBeLessThan(completeIndex)
})
Testing Interactive Flows
test('wizard completes full flow', async () => {
const result = await testCommand(wizardCommand, mockInteractive(
{
'Project name:': 'my-app',
'Choose template:': 'typescript',
'Install dependencies?': 'y'
},
{
'npm install': 'added 150 packages',
'git init': 'Initialized empty Git repository'
}
))
expectCommand(result).toHaveSucceeded()
expect(result.stdout).toContain('Project created successfully')
})