and Exit Codes with PHPUnit
CLI commands are often the least tested part of a PHP application. Yet these are exactly the commands that drive cron jobs, migrations, deployments and critical batch operations. The exit code is the only communication channel between a process and the calling system, and it has to be correct so CI pipelines and orchestrators can react to failures.
Table of Contents
- 1. Why testing CLI commands is different
- 2. Exit codes: semantics and conventions
- 3. Symfony CommandTester: unit tests for console commands
- 4. Output assertions: testing stdout, stderr and formatting
- 5. The Process component: testing real processes
- 6. Integration tests for CLI commands
- 7. Common mistakes in CLI testing
- 8. Test strategies compared
- 9. Summary
- 10. FAQ
1. Why testing CLI commands is different
A CLI command differs from a regular class in one decisive way: it communicates through standardized channels. Input arrives through arguments, options and stdin. Output goes through stdout and stderr. And the most important signal, success or failure, is conveyed through the exit code. These channels are not implementation details, they are the public interface of a command. If you do not test the public interface, you are testing the wrong thing.
In practice you frequently see commands that work correctly internally but always return exit code 0, even when an exception was caught and logged along the way. The calling system, a cron job, a CI pipeline, a deployment script, is then told "success" even though the command actually failed. Tests that only check internal logic and ignore exit codes give a false sense of security here. The fix is to write tests that check the command's entire interface: arguments, output, exit code and side effects.
Another distinctive aspect of CLI commands is their state. Many commands read from files, write to files, talk to databases and communicate with external services. For meaningful tests you have to decide: should the logic be tested in isolation (a unit test with mocks) or should the entire behavior including I/O be verified (an integration test)? Both levels have their place, and a complete test portfolio for CLI commands includes both.
2. Exit codes: semantics and conventions
Exit codes follow Unix conventions: 0 means success, anything else is a failure. Values 1 to 125 are reserved for application-defined errors. Symfony Console defines its own constants: Command::SUCCESS (0), Command::FAILURE (1) and Command::INVALID (2). Code 2 signals misuse of the command, wrong arguments or unknown options. Exit code 127 usually means "command not found", 128 plus a signal number signals termination by a signal.
In a PHPUnit test you check exit codes with assertSame(Command::SUCCESS, $exitCode), not with assertEquals, because the type of the number (int) matters just as much as the value. With the Symfony Process component, $process->getExitCode() returns the exit code as an integer. If the explicit return call is missing in a Symfony command, the command returns 0, even if an exception occurred and was caught internally. This is one of the most common bugs in command implementations and one of the most important things tests have to check explicitly.
<?php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Imports product data from a CSV file.
* Exit codes: 0 = success, 1 = import error, 2 = invalid arguments.
*/
#[AsCommand(name: 'app:import:products', description: 'Import products from CSV')]
final class ImportProductsCommand extends Command
{
public function __construct(
private readonly ProductImporter $importer,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('file', InputArgument::REQUIRED, 'Path to CSV file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$file = $input->getArgument('file');
if (!file_exists($file)) {
// Return INVALID (2) for bad arguments, not FAILURE (1)
$output->writeln("<error>File not found: {$file}</error>");
return Command::INVALID;
}
try {
$count = $this->importer->importFromCsv($file);
$output->writeln("<info>Imported {$count} products.</info>");
return Command::SUCCESS;
} catch (ImportException $e) {
$this->logger->error('Import failed', ['error' => $e->getMessage()]);
$output->writeln("<error>Import failed: {$e->getMessage()}</error>");
// Explicit FAILURE, must NOT return 0 here
return Command::FAILURE;
}
}
}
3. Symfony CommandTester: unit tests for console commands
The CommandTester from Symfony\Component\Console\Tester is the most important tool for unit testing console commands. It simulates the execution of a command without spawning a real process: it injects arguments and options, captures stdout and stderr, and returns the exit code. This enables fast, isolated tests that need no real files or database connections.
The setup is simple: instantiate the command (with mock dependencies), create the CommandTester, call execute() with an array of arguments and options, then check the exit code and output. Important: the first key in the arguments array is the command name, followed by arguments and options in the defined order. The getDisplay() method returns the entire stdout output as a string. getErrorOutput() returns stderr.
<?php
declare(strict_types=1);
namespace Tests\Unit\Command;
use App\Command\ImportProductsCommand;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Unit tests for ImportProductsCommand using CommandTester.
*/
final class ImportProductsCommandTest extends TestCase
{
private MockObject&ProductImporter $importer;
private MockObject&LoggerInterface $logger;
private CommandTester $tester;
protected function setUp(): void
{
$this->importer = $this->createMock(ProductImporter::class);
$this->logger = $this->createMock(LoggerInterface::class);
$command = new ImportProductsCommand($this->importer, $this->logger);
$this->tester = new CommandTester($command);
}
/** @test */
public function successful_import_returns_exit_code_zero(): void
{
$this->importer
->expects($this->once())
->method('importFromCsv')
->willReturn(42);
$exitCode = $this->tester->execute(['file' => '/tmp/products.csv']);
$this->assertSame(Command::SUCCESS, $exitCode);
$this->assertStringContainsString('Imported 42 products', $this->tester->getDisplay());
}
/** @test */
public function missing_file_returns_invalid_exit_code(): void
{
$exitCode = $this->tester->execute(['file' => '/nonexistent/file.csv']);
$this->assertSame(Command::INVALID, $exitCode);
$this->assertStringContainsString('File not found', $this->tester->getDisplay());
$this->importer->expects($this->never())->method('importFromCsv');
}
/** @test */
public function import_exception_returns_failure_exit_code(): void
{
$this->importer
->method('importFromCsv')
->willThrowException(new ImportException('Malformed CSV'));
$this->logger->expects($this->once())->method('error');
$exitCode = $this->tester->execute(['file' => '/tmp/bad.csv']);
$this->assertSame(Command::FAILURE, $exitCode);
$this->assertStringContainsString('Import failed', $this->tester->getDisplay());
}
}
4. Output assertions: testing stdout, stderr and formatting
Output assertions are an often neglected part of CLI testing. Yet a command's output is its most visible interface: operators read it, scripts parse it, monitoring systems analyze it. If a command prints "Imported 0 products" instead of "Imported 42 products", that is a bug, even if the exit code is correct. Tests that ignore the output only check half the interface.
For structured output, assertMatchesRegularExpression is preferable to assertStringContainsString when the exact value varies but the format has to stay constant. For formatted tables and colored output, check the output without ANSI codes: $tester->execute([], ['decorated' => false]) disables color codes. For stderr there is $tester->getErrorOutput(). Important: error messages belong on stderr, not on stdout, that is a Unix convention many commands violate.
5. The Process component: testing real processes
The Symfony Process component makes it possible to start external processes from PHP and read their output, exit code and error output. This is the right approach for integration tests that run the command as a real process, exactly the way a cron job or a deployment script would. The difference from CommandTester: Process starts a real PHP process, CommandTester runs the command inside the same PHP process.
To test a CLI script with the Process component, create a Process object with the command as an array, set the working directory, run the process synchronously with run(), and then check getExitCode(), getOutput() and getErrorOutput(). Setting a timeout is mandatory, a hanging process should not block the entire CI pipeline. Process::setTimeout(30) is a reasonable starting point for commands without external I/O dependencies.
<?php
declare(strict_types=1);
namespace Tests\Integration\Command;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Process;
/**
* Integration test: runs the CLI command as a real process.
* Tests the full interface: exit code, stdout, stderr.
*/
final class ImportProductsCommandIntegrationTest extends TestCase
{
private string $fixtureDir;
protected function setUp(): void
{
$this->fixtureDir = __DIR__ . '/fixtures';
}
/** @test */
public function command_exits_zero_for_valid_csv(): void
{
$process = new Process([
PHP_BINARY,
'bin/console',
'app:import:products',
$this->fixtureDir . '/valid-products.csv',
]);
$process->setTimeout(30);
$process->run();
$this->assertSame(0, $process->getExitCode(), $process->getErrorOutput());
$this->assertStringContainsString('Imported', $process->getOutput());
}
/** @test */
public function command_exits_two_for_missing_file(): void
{
$process = new Process([
PHP_BINARY,
'bin/console',
'app:import:products',
'/does/not/exist.csv',
]);
$process->setTimeout(10);
$process->run();
// Exit code 2 = INVALID (bad arguments, not runtime error)
$this->assertSame(2, $process->getExitCode());
$this->assertStringContainsString('File not found', $process->getOutput());
}
/** @test */
public function command_writes_errors_to_stderr_not_stdout(): void
{
$process = new Process([PHP_BINARY, 'bin/console', 'app:import:products', '/bad.csv']);
$process->setTimeout(10);
$process->run();
// Error messages must go to stderr, stdout must be empty or minimal
$this->assertNotEmpty($process->getErrorOutput());
$this->assertStringNotContainsString('Exception', $process->getOutput());
}
/** @test */
public function command_respects_timeout_signal(): void
{
$process = new Process([PHP_BINARY, 'bin/console', 'app:import:products', '/big.csv']);
$process->setTimeout(1); // Force timeout for testing
try {
$process->run();
} catch (\Symfony\Component\Process\Exception\ProcessTimedOutException $e) {
$this->assertTrue(true, 'Process timed out as expected');
return;
}
// If no timeout occurred (fast system), ensure exit code is still correct
$this->assertContains($process->getExitCode(), [0, 1, 2]);
}
}
6. Integration tests for CLI commands
A complete integration test for a CLI command checks the whole system: the command is run with real dependencies (or with minimal stubs for external services such as APIs), and the test verifies whether the desired state was reached after execution. For an import command that means: were the records actually in the database after the call? For a cleanup command: were the right files deleted and the wrong ones left alone?
Integration tests for commands in Symfony can use the kernel framework: KernelTestCase bootstraps the application container, and $kernel->getContainer()->get(ImportProductsCommand::class) returns the command with real dependencies. The CommandTester is then created with this command. This way real services are used without starting an actual process. The database is reset to a defined state before every test, using fixtures or transactions that are rolled back after the test.
7. Common mistakes in CLI testing
The most common mistake: tests do not check exit codes. A command test that only calls $tester->execute() and then checks the output, without asserting the exit code, gives no guarantee that the system correctly communicates errors. CI pipelines fail, deployments run through despite errors, because nobody tested whether the command actually returns a non-zero exit code on failure.
A second common mistake: commands that catch and log exceptions internally, but then return Command::SUCCESS anyway. The command "runs through", the exit code is 0, the test passes, but the operation did not actually work. This bug is only found when tests explicitly check that the exit code is Command::FAILURE under simulated errors.
| Test strategy | Tool | Checks | When to use |
|---|---|---|---|
| Unit test | CommandTester |
Logic, exit code, output (isolated) | Fast feedback, business logic |
| Integration test | KernelTestCase + CommandTester |
Real services, database effects | Critical commands with side effects |
| End-to-end test | Process component |
Real process, signal handling | CLI interface, exit codes, timeouts |
| Output test | getDisplay() / getErrorOutput() |
stdout/stderr format and content | Commands parsed by scripts |
| Exit code test | assertSame(Command::FAILURE, $code) |
Error signaling to the OS | Always, no command test without an exit code |
8. Test strategies compared
The choice between a unit test with CommandTester and an integration test with real services follows the same principle as everywhere else in the test pyramid: fast, isolated tests for the business logic, slower but more comprehensive tests for the interplay of components. For CLI commands there is a third level: the real process test with the Process component, which guarantees that exit codes are correctly propagated to the operating system.
The rule of thumb: every command gets at least one unit test with CommandTester that covers the success case and the most important failure case. Commands that change database state additionally get an integration test. Commands used in production scripts or CI pipelines get a Process test that checks the exit code as a real OS exit code.
9. Summary
Testing CLI commands with PHPUnit means: always assert exit codes explicitly. Use CommandTester for fast, isolated unit tests of the command logic. Use the Symfony Process component for real process tests that check the entire CLI interface. Keep output assertions for stdout and stderr separate. And always check that commands actually return a non-zero exit code on failure, that is the most common source of bugs in command implementations.
Exit codes are not an implementation detail, they are the command's public interface toward the operating system. Whoever does not test them lets an entire class of bugs go unchecked into production.
Testing CLI Commands and Exit Codes, the Essentials at a Glance
Always check exit codes
assertSame(Command::SUCCESS, $exitCode), no command test without an exit code assertion. The failure case must return FAILURE (1) or INVALID (2).
CommandTester for unit tests
Fast, isolated tests with mock dependencies. getDisplay() for stdout, getErrorOutput() for stderr. decorated: false for ANSI-free output.
Process component for E2E
Real process, real exit code. Set a timeout. Check error output. Guarantees that the OS receives the correct exit code.
Exception is not failure
Commands that catch exceptions internally and return Command::SUCCESS are a bug. Tests must explicitly check that errors are reported as FAILURE.
Mironsoft
PHP development, CLI testing and deployment infrastructure
CLI commands that run reliably in CI and production?
We analyze existing CLI commands for correct exit codes, missing tests and weak error handling, and build a complete test suite made up of unit, integration and process tests.
Command audit
Analysis of all CLI commands for exit code correctness and error handling
Test buildout
CommandTester, KernelTestCase and Process tests for complete coverage
CI integration
Integrating PHPUnit into CI pipelines with exit code validation and reporting