Exit Code Testing Patterns
Comprehensive guide to testing CLI exit codes correctly.
Standard Exit Codes
POSIX Standard Exit Codes
| Code | Meaning | When to Use |
|---|---|---|
| 0 | Success | Command completed successfully |
| 1 | General Error | Catchall for general errors |
| 2 | Misuse of Command | Invalid arguments or options |
| 126 | Command Cannot Execute | Permission problem or not executable |
| 127 | Command Not Found | Command not found in PATH |
| 128+N | Fatal Error Signal N | Process terminated by signal N |
| 130 | Ctrl+C Termination | Process terminated by SIGINT |
Custom Application Exit Codes
// Define custom exit codes
enum ExitCode {
SUCCESS = 0,
GENERAL_ERROR = 1,
INVALID_ARGUMENT = 2,
CONFIG_ERROR = 3,
NETWORK_ERROR = 4,
AUTH_ERROR = 5,
NOT_FOUND = 6,
ALREADY_EXISTS = 7,
PERMISSION_DENIED = 8,
}
Node.js Exit Code Testing
Basic Exit Code Testing
describe('Exit Code Tests', () => {
test('success returns 0', () => {
const { code } = runCLI('status');
expect(code).toBe(0);
});
test('general error returns 1', () => {
const { code } = runCLI('fail-command');
expect(code).toBe(1);
});
test('invalid argument returns 2', () => {
const { code } = runCLI('deploy --invalid-env unknown');
expect(code).toBe(2);
});
test('command not found returns 127', () => {
const { code } = runCLI('nonexistent-command');
expect(code).toBe(127);
});
});
Specific Error Conditions
describe('Specific Exit Codes', () => {
test('configuration error', () => {
const { code, stderr } = runCLI('deploy production');
expect(code).toBe(3); // CONFIG_ERROR
expect(stderr).toContain('configuration');
});
test('network error', () => {
// Mock network failure
const { code, stderr } = runCLI('fetch --url https://unreachable.example.com');
expect(code).toBe(4); // NETWORK_ERROR
expect(stderr).toContain('network');
});
test('authentication error', () => {
const { code, stderr } = runCLI('login --token invalid');
expect(code).toBe(5); // AUTH_ERROR
expect(stderr).toContain('authentication');
});
test('resource not found', () => {
const { code, stderr } = runCLI('get resource-123');
expect(code).toBe(6); // NOT_FOUND
expect(stderr).toContain('not found');
});
test('resource already exists', () => {
runCLI('create my-resource');
const { code, stderr } = runCLI('create my-resource');
expect(code).toBe(7); // ALREADY_EXISTS
expect(stderr).toContain('already exists');
});
});
Testing Exit Code Consistency
describe('Exit Code Consistency', () => {
const errorScenarios = [
{ args: 'deploy', expectedCode: 2, reason: 'missing required argument' },
{ args: 'deploy --env invalid', expectedCode: 2, reason: 'invalid environment' },
{ args: 'config get missing', expectedCode: 6, reason: 'config key not found' },
{ args: 'unknown-cmd', expectedCode: 127, reason: 'command not found' },
];
test.each(errorScenarios)(
'should return exit code $expectedCode for $reason',
({ args, expectedCode }) => {
const { code } = runCLI(args);
expect(code).toBe(expectedCode);
}
);
});
Python Exit Code Testing
Basic Exit Code Testing
class TestExitCodes:
"""Test CLI exit codes"""
def test_success_exit_code(self, runner):
"""Success should return 0"""
result = runner.invoke(cli, ['status'])
assert result.exit_code == 0
def test_general_error_exit_code(self, runner):
"""General error should return 1"""
result = runner.invoke(cli, ['fail-command'])
assert result.exit_code == 1
def test_usage_error_exit_code(self, runner):
"""Usage error should return 2"""
result = runner.invoke(cli, ['deploy']) # Missing required arg
assert result.exit_code == 2
def test_unknown_command_exit_code(self, runner):
"""Unknown command handling"""
result = runner.invoke(cli, ['nonexistent'])
assert result.exit_code != 0
Custom Exit Codes with Click
import click
import sys
# Define custom exit codes
class ExitCode:
SUCCESS = 0
GENERAL_ERROR = 1
INVALID_ARGUMENT = 2
CONFIG_ERROR = 3
NETWORK_ERROR = 4
AUTH_ERROR = 5
@click.command()
def deploy():
"""Deploy command with custom exit codes"""
try:
# Check configuration
if not has_valid_config():
click.echo("Configuration error", err=True)
sys.exit(ExitCode.CONFIG_ERROR)
# Check authentication
if not is_authenticated():
click.echo("Authentication failed", err=True)
sys.exit(ExitCode.AUTH_ERROR)
# Deploy
deploy_application()
click.echo("Deployment successful")
sys.exit(ExitCode.SUCCESS)
except NetworkError:
click.echo("Network error", err=True)
sys.exit(ExitCode.NETWORK_ERROR)
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(ExitCode.GENERAL_ERROR)
Testing Custom Exit Codes
class TestCustomExitCodes:
"""Test custom exit codes"""
def test_config_error_exit_code(self, runner, tmp_path):
"""Configuration error should return 3"""
# Remove config file
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 3
assert 'configuration' in result.output.lower()
def test_network_error_exit_code(self, runner, monkeypatch):
"""Network error should return 4"""
def mock_request(*args, **kwargs):
raise NetworkError("Connection failed")
monkeypatch.setattr('requests.post', mock_request)
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 4
assert 'network' in result.output.lower()
def test_auth_error_exit_code(self, runner):
"""Authentication error should return 5"""
result = runner.invoke(cli, ['deploy', 'production', '--token', 'invalid'])
assert result.exit_code == 5
assert 'authentication' in result.output.lower()
Testing Exit Codes in Scripts
Bash Script Exit Code Testing
describe('Script Exit Codes', () => {
test('should respect shell exit codes', () => {
// Test that CLI properly exits with script error codes
const script = `
#!/bin/bash
${CLI_PATH} deploy staging
if [ $? -ne 0 ]; then
echo "Deployment failed"
exit 1
fi
echo "Deployment succeeded"
`;
const { code, stdout } = execSync(script, { encoding: 'utf8' });
expect(code).toBe(0);
expect(stdout).toContain('Deployment succeeded');
});
test('should propagate errors in pipelines', () => {
const { code } = execSync(`${CLI_PATH} invalid | tee output.log`, {
encoding: 'utf8',
});
expect(code).not.toBe(0);
});
});
Exit Code Best Practices
1. Document Exit Codes
/**
* CLI Exit Codes
*
* 0 - Success
* 1 - General error
* 2 - Invalid arguments
* 3 - Configuration error
* 4 - Network error
* 5 - Authentication error
* 6 - Resource not found
* 7 - Resource already exists
* 8 - Permission denied
*/
2. Consistent Error Handling
def handle_error(error: Exception) -> int:
"""
Handle errors and return appropriate exit code
Returns:
Appropriate exit code for the error type
"""
if isinstance(error, ConfigurationError):
click.echo(f"Configuration error: {error}", err=True)
return ExitCode.CONFIG_ERROR
elif isinstance(error, NetworkError):
click.echo(f"Network error: {error}", err=True)
return ExitCode.NETWORK_ERROR
elif isinstance(error, AuthenticationError):
click.echo(f"Authentication failed: {error}", err=True)
return ExitCode.AUTH_ERROR
else:
click.echo(f"Error: {error}", err=True)
return ExitCode.GENERAL_ERROR
3. Test Exit Codes with Error Messages
test('exit code matches error type', () => {
const errorCases = [
{ args: 'deploy', expectedCode: 2, expectedMsg: 'missing required argument' },
{ args: 'login --token bad', expectedCode: 5, expectedMsg: 'authentication failed' },
{ args: 'get missing-id', expectedCode: 6, expectedMsg: 'not found' },
];
errorCases.forEach(({ args, expectedCode, expectedMsg }) => {
const { code, stderr } = runCLI(args);
expect(code).toBe(expectedCode);
expect(stderr.toLowerCase()).toContain(expectedMsg);
});
});
4. Test Help and Version Return 0
def test_help_returns_success(runner):
"""Help should return 0"""
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
def test_version_returns_success(runner):
"""Version should return 0"""
result = runner.invoke(cli, ['--version'])
assert result.exit_code == 0
Common Pitfalls
1. Don't Use Exit Code 0 for Errors
// ❌ Wrong - using 0 for errors
if (error) {
console.error('Error occurred');
process.exit(0); // Should be non-zero!
}
// ✅ Correct - using non-zero for errors
if (error) {
console.error('Error occurred');
process.exit(1);
}
2. Don't Ignore Exit Codes in Tests
# ❌ Wrong - not checking exit code
def test_deploy(runner):
result = runner.invoke(cli, ['deploy', 'production'])
assert 'deployed' in result.output # What if it failed?
# ✅ Correct - always check exit code
def test_deploy(runner):
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 0
assert 'deployed' in result.output
3. Use Specific Exit Codes
// ❌ Wrong - using 1 for everything
if (configError) process.exit(1);
if (networkError) process.exit(1);
if (authError) process.exit(1);
// ✅ Correct - using specific codes
if (configError) process.exit(ExitCode.CONFIG_ERROR);
if (networkError) process.exit(ExitCode.NETWORK_ERROR);
if (authError) process.exit(ExitCode.AUTH_ERROR);
Testing Exit Codes in CI/CD
# GitHub Actions example
- name: Test CLI Exit Codes
run: |
# Should succeed
./cli status && echo "Status check passed" || exit 1
# Should fail
./cli invalid-command && exit 1 || echo "Error handling works"
# Check specific exit code
./cli deploy --missing-arg
if [ $? -eq 2 ]; then
echo "Correct exit code for invalid argument"
else
echo "Wrong exit code"
exit 1
fi