Files
gh-vanman2024-cli-builder-p…/skills/cli-testing-patterns/examples/exit-code-testing
2025-11-30 09:04:14 +08:00
..
2025-11-30 09:04:14 +08:00

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

Resources