citty-test-utils
Version:
A comprehensive testing framework for CLI applications built with Citty, featuring Docker cleanroom support, fluent assertions, advanced scenario DSL, and noun-verb CLI structure with template generation.
592 lines (452 loc) • 14.2 kB
Markdown
# Migration Guide
Guide for migrating from other testing approaches to `citty-test-utils`.
## Table of Contents
- [From Manual CLI Testing](#from-manual-cli-testing)
- [From Other Testing Libraries](#from-other-testing-libraries)
- [From Shell Scripts](#from-shell-scripts)
- [From Docker Compose](#from-docker-compose)
- [From Custom Test Runners](#from-custom-test-runners)
- [Migration Checklist](#migration-checklist)
## From Manual CLI Testing
### Before: Manual Testing
```javascript
// Old approach: Manual CLI testing
import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
async function testCLI() {
try {
const { stdout, stderr } = await execAsync('node src/cli.mjs --help')
if (stdout.includes('USAGE')) {
console.log('✅ Help command works')
} else {
console.log('❌ Help command failed')
}
if (stderr) {
console.log('⚠️ Stderr:', stderr)
}
} catch (error) {
console.error('❌ Command failed:', error.message)
}
}
```
### After: Using citty-test-utils
```javascript
// New approach: Using citty-test-utils
import { runLocalCitty } from 'citty-test-utils'
async function testCLI() {
const result = await runLocalCitty(['--help'])
result
.expectSuccess()
.expectOutput('USAGE')
.expectNoStderr()
console.log('✅ Help command works')
}
```
### Migration Steps
1. **Replace exec calls:**
```javascript
// Before
const { stdout, stderr } = await execAsync('node src/cli.mjs --help')
// After
const result = await runLocalCitty(['--help'])
```
2. **Replace manual assertions:**
```javascript
// Before
if (stdout.includes('USAGE')) {
console.log('✅ Test passed')
}
// After
result.expectOutput('USAGE')
```
3. **Replace error handling:**
```javascript
// Before
try {
const { stdout, stderr } = await execAsync('command')
} catch (error) {
console.error('Command failed:', error.message)
}
// After
const result = await runLocalCitty(['command'])
result.expectSuccess() // Throws if command fails
```
## From Other Testing Libraries
### From Jest/Vitest with exec
#### Before: Jest with exec
```javascript
// jest.config.js
module.exports = {
testEnvironment: 'node',
testTimeout: 30000
}
// cli.test.js
import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
describe('CLI Tests', () => {
test('help command works', async () => {
const { stdout, stderr } = await execAsync('node src/cli.mjs --help')
expect(stdout).toContain('USAGE')
expect(stderr).toBe('')
})
test('version command works', async () => {
const { stdout } = await execAsync('node src/cli.mjs --version')
expect(stdout).toMatch(/\d+\.\d+\.\d+/)
})
})
```
#### After: Jest with citty-test-utils
```javascript
// cli.test.js
import { runLocalCitty } from 'citty-test-utils'
describe('CLI Tests', () => {
test('help command works', async () => {
const result = await runLocalCitty(['--help'])
result
.expectSuccess()
.expectOutput('USAGE')
.expectNoStderr()
})
test('version command works', async () => {
const result = await runLocalCitty(['--version'])
result
.expectSuccess()
.expectOutput(/\d+\.\d+\.\d+/)
})
})
```
### From Mocha with exec
#### Before: Mocha with exec
```javascript
// cli.test.js
import { exec } from 'child_process'
import { promisify } from 'util'
import { expect } from 'chai'
const execAsync = promisify(exec)
describe('CLI Tests', () => {
it('should show help', async () => {
const { stdout, stderr } = await execAsync('node src/cli.mjs --help')
expect(stdout).to.include('USAGE')
expect(stderr).to.be.empty
})
it('should show version', async () => {
const { stdout } = await execAsync('node src/cli.mjs --version')
expect(stdout).to.match(/\d+\.\d+\.\d+/)
})
})
```
#### After: Mocha with citty-test-utils
```javascript
// cli.test.js
import { runLocalCitty } from 'citty-test-utils'
import { expect } from 'chai'
describe('CLI Tests', () => {
it('should show help', async () => {
const result = await runLocalCitty(['--help'])
result
.expectSuccess()
.expectOutput('USAGE')
.expectNoStderr()
})
it('should show version', async () => {
const result = await runLocalCitty(['--version'])
result
.expectSuccess()
.expectOutput(/\d+\.\d+\.\d+/)
})
})
```
### Migration Benefits
1. **Automatic project detection**: No need to specify CLI paths
2. **Better error messages**: Detailed context when tests fail
3. **Fluent assertions**: More readable test code
4. **Consistent behavior**: Same API across different environments
## From Shell Scripts
### Before: Shell Script Testing
```bash
#!/bin/bash
# test-cli.sh
echo "Testing CLI..."
# Test help command
if node src/cli.mjs --help | grep -q "USAGE"; then
echo "✅ Help command works"
else
echo "❌ Help command failed"
exit 1
fi
# Test version command
if node src/cli.mjs --version | grep -qE "[0-9]+\.[0-9]+\.[0-9]+"; then
echo "✅ Version command works"
else
echo "❌ Version command failed"
exit 1
fi
echo "All tests passed!"
```
### After: JavaScript with citty-test-utils
```javascript
// test-cli.mjs
import { runLocalCitty } from 'citty-test-utils'
async function testCLI() {
console.log('Testing CLI...')
// Test help command
const helpResult = await runLocalCitty(['--help'])
helpResult
.expectSuccess()
.expectOutput('USAGE')
console.log('✅ Help command works')
// Test version command
const versionResult = await runLocalCitty(['--version'])
versionResult
.expectSuccess()
.expectOutput(/\d+\.\d+\.\d+/)
console.log('✅ Version command works')
console.log('All tests passed!')
}
testCLI().catch(console.error)
```
### Migration Steps
1. **Convert shell script to JavaScript**
2. **Replace grep with expectOutput**
3. **Replace exit codes with expectSuccess/expectFailure**
4. **Add proper error handling**
## From Docker Compose
### Before: Docker Compose Testing
```yaml
# docker-compose.test.yml
version: '3.8'
services:
cli-test:
build: .
command: node src/cli.mjs --help
volumes:
- .:/app
working_dir: /app
```
```bash
# Run tests
docker-compose -f docker-compose.test.yml up --abort-on-container-exit
```
### After: Cleanroom Testing
```javascript
// test-cleanroom.mjs
import { setupCleanroom, runCitty, teardownCleanroom } from 'citty-test-utils'
async function testCleanroom() {
// Setup cleanroom (equivalent to docker-compose up)
await setupCleanroom({ rootDir: '.' })
try {
// Run tests in cleanroom
const result = await runCitty(['--help'])
result
.expectSuccess()
.expectOutput('USAGE')
console.log('✅ Cleanroom test passed')
} finally {
// Cleanup (equivalent to docker-compose down)
await teardownCleanroom()
}
}
testCleanroom().catch(console.error)
```
### Migration Benefits
1. **Simplified setup**: No need for docker-compose files
2. **Better integration**: Works with existing test frameworks
3. **Automatic cleanup**: Containers are cleaned up automatically
4. **Flexible configuration**: Easy to customize container settings
## From Custom Test Runners
### Before: Custom Test Runner
```javascript
// custom-test-runner.js
class CLITestRunner {
constructor() {
this.tests = []
this.results = []
}
addTest(name, command, expectedOutput) {
this.tests.push({ name, command, expectedOutput })
}
async runTests() {
for (const test of this.tests) {
try {
const { stdout } = await execAsync(`node src/cli.mjs ${test.command}`)
if (stdout.includes(test.expectedOutput)) {
this.results.push({ name: test.name, success: true })
console.log(`✅ ${test.name}`)
} else {
this.results.push({ name: test.name, success: false })
console.log(`❌ ${test.name}`)
}
} catch (error) {
this.results.push({ name: test.name, success: false, error: error.message })
console.log(`❌ ${test.name}: ${error.message}`)
}
}
}
generateReport() {
const passed = this.results.filter(r => r.success).length
const total = this.results.length
console.log(`\nTest Results: ${passed}/${total} passed`)
}
}
// Usage
const runner = new CLITestRunner()
runner.addTest('Help', '--help', 'USAGE')
runner.addTest('Version', '--version', '1.0.0')
await runner.runTests()
runner.generateReport()
```
### After: Using citty-test-utils
```javascript
// test-runner.mjs
import { runLocalCitty, scenario } from 'citty-test-utils'
class CLITestRunner {
constructor() {
this.tests = []
}
addTest(name, command, expectedOutput) {
this.tests.push({ name, command, expectedOutput })
}
async runTests() {
for (const test of this.tests) {
try {
const result = await runLocalCitty([test.command])
result
.expectSuccess()
.expectOutput(test.expectedOutput)
console.log(`✅ ${test.name}`)
} catch (error) {
console.log(`❌ ${test.name}: ${error.message}`)
}
}
}
async runScenarioTests() {
const result = await scenario('All Tests')
.step('Help test')
.run('--help')
.expectSuccess()
.expectOutput('USAGE')
.step('Version test')
.run('--version')
.expectSuccess()
.expectOutput(/\d+\.\d+\.\d+/)
.execute('local')
console.log('Scenario result:', result.success)
}
}
// Usage
const runner = new CLITestRunner()
runner.addTest('Help', '--help', 'USAGE')
runner.addTest('Version', '--version', /\d+\.\d+\.\d+/)
await runner.runTests()
await runner.runScenarioTests()
```
## Migration Checklist
### Pre-Migration
- [ ] **Audit existing tests**: Identify all CLI testing approaches
- [ ] **Document current behavior**: Note expected outputs and behaviors
- [ ] **Identify dependencies**: Check for custom test utilities
- [ ] **Plan migration strategy**: Decide on migration order and approach
### During Migration
- [ ] **Install citty-test-utils**: `npm install citty-test-utils`
- [ ] **Update imports**: Replace exec/child_process imports
- [ ] **Convert assertions**: Replace manual checks with fluent assertions
- [ ] **Update error handling**: Use expectSuccess/expectFailure
- [ ] **Test incrementally**: Migrate one test at a time
- [ ] **Verify behavior**: Ensure tests still pass
### Post-Migration
- [ ] **Remove old dependencies**: Clean up unused packages
- [ ] **Update documentation**: Update test documentation
- [ ] **Train team**: Ensure team understands new testing approach
- [ ] **Monitor performance**: Check test execution times
- [ ] **Gather feedback**: Collect feedback from team members
### Common Migration Patterns
#### Pattern 1: Simple Command Testing
```javascript
// Before
const { stdout } = await execAsync('node src/cli.mjs --help')
expect(stdout).toContain('USAGE')
// After
const result = await runLocalCitty(['--help'])
result.expectSuccess().expectOutput('USAGE')
```
#### Pattern 2: Error Testing
```javascript
// Before
try {
await execAsync('node src/cli.mjs invalid-command')
expect.fail('Should have thrown')
} catch (error) {
expect(error.message).toContain('Unknown command')
}
// After
const result = await runLocalCitty(['invalid-command'])
result.expectFailure().expectStderr(/Unknown command/)
```
#### Pattern 3: Environment Testing
```javascript
// Before
const { stdout } = await execAsync('NODE_ENV=test node src/cli.mjs --help')
// After
const result = await runLocalCitty(['--help'], {
env: { NODE_ENV: 'test' }
})
```
#### Pattern 4: Timeout Testing
```javascript
// Before
const { stdout } = await execAsync('node src/cli.mjs long-command', {
timeout: 30000
})
// After
const result = await runLocalCitty(['long-command'], {
timeout: 30000
})
```
### Migration Tools
#### Automated Migration Script
```javascript
// migrate-tests.mjs
import { readFileSync, writeFileSync } from 'fs'
import { glob } from 'glob'
async function migrateTests() {
const testFiles = await glob('tests/**/*.test.{js,mjs}')
for (const file of testFiles) {
let content = readFileSync(file, 'utf8')
// Replace common patterns
content = content.replace(
/const \{ stdout, stderr \} = await execAsync\('node src\/cli\.mjs ([^']+)'\)/g,
'const result = await runLocalCitty([$1])'
)
content = content.replace(
/expect\(stdout\)\.toContain\('([^']+)'\)/g,
'result.expectOutput(\'$1\')'
)
content = content.replace(
/expect\(stderr\)\.toBe\(''\)/g,
'result.expectNoStderr()'
)
// Add import if not present
if (!content.includes('import { runLocalCitty }')) {
content = `import { runLocalCitty } from 'citty-test-utils'\n${content}`
}
writeFileSync(file, content)
console.log(`Migrated: ${file}`)
}
}
migrateTests().catch(console.error)
```
### Best Practices
1. **Start small**: Begin with simple tests and gradually migrate complex ones
2. **Maintain compatibility**: Keep old tests running during migration
3. **Test thoroughly**: Verify migrated tests produce same results
4. **Document changes**: Update documentation to reflect new approach
5. **Train team**: Ensure team understands new testing patterns
### Common Pitfalls
1. **Forgetting to import**: Always import `runLocalCitty` from `citty-test-utils`
2. **Wrong assertion order**: Use `expectSuccess()` before other assertions
3. **Missing error handling**: Use `expectFailure()` for expected failures
4. **Environment differences**: Test in both local and cleanroom environments
5. **Timeout issues**: Adjust timeouts for long-running commands
This migration guide should help you transition from your current testing approach to `citty-test-utils` smoothly and efficiently.