@test
assert
PHPUnit · CLI Testing · Symfony Console · Exit-Codes
CLI-Commands und Exit-Codes
mit PHPUnit testen

CLI-Commands sind oft der am wenigsten getestete Teil einer PHP-Anwendung. Dabei sind es genau diese Commands, die Cron-Jobs, Migrations, Deployments und kritische Batch-Operationen steuern. Exit-Codes sind der einzige Kommunikationskanal zwischen einem Prozess und dem aufrufenden System – und sie müssen korrekt sein, damit CI-Pipelines und Orchestratoren auf Fehler reagieren können.

14 Min. Lesezeit CommandTester · Process-Komponente · Exit-Codes · Integration PHPUnit 10/11 · Symfony 6/7 · PHP 8.x

1. Warum CLI-Commands testen besonders ist

Ein CLI-Command unterscheidet sich von einer normalen Klasse in einem entscheidenden Aspekt: er kommuniziert über standardisierte Kanäle. Eingabe kommt über Argumente, Optionen und stdin. Ausgabe geht über stdout und stderr. Und das wichtigste Signal – Erfolg oder Fehler – wird über den Exit-Code übermittelt. Diese Kanäle sind keine Implementation-Details, sondern das öffentliche Interface eines Commands. Wer das öffentliche Interface nicht testet, testet das Falsche.

In der Praxis sieht man häufig Commands, die korrekt arbeiten, aber immer Exit-Code 0 zurückgeben – selbst wenn intern eine Exception abgefangen und geloggt wurde. Das aufrufende System – ein Cron-Job, eine CI-Pipeline, ein Deployment-Skript – bekommt dann "Erfolg" gemeldet, obwohl das Kommando fehlgeschlagen ist. Tests, die nur die interne Logik prüfen und Exit-Codes ignorieren, geben hier falsche Sicherheit. Die Lösung liegt darin, Tests zu schreiben, die das gesamte Interface des Commands prüfen: Argumente, Output, Exit-Code und Nebeneffekte.

Ein weiterer besonderer Aspekt von CLI-Commands ist ihr Zustand. Viele Commands lesen aus Dateien, schreiben in Dateien, sprechen Datenbanken an und kommunizieren mit externen Diensten. Für aussagekräftige Tests muss man entscheiden: Soll die Logik isoliert (Unit-Test mit Mocks) oder das gesamte Verhalten einschließlich I/O (Integrationstest) geprüft werden? Beide Ebenen haben ihre Berechtigung, und ein vollständiges Test-Portfolio für CLI-Commands enthält beide.

2. Exit-Codes: Semantik und Konventionen

Exit-Codes folgen Unix-Konventionen: 0 bedeutet Erfolg, alles andere ist ein Fehler. Werte 1–125 sind für anwendungsdefinierte Fehler reserviert. Symfony Console definiert eigene Konstanten: Command::SUCCESS (0), Command::FAILURE (1) und Command::INVALID (2). Code 2 signalisiert einen Missbrauch des Commands – falsche Argumente, unbekannte Optionen. Exit-Code 127 bedeutet üblicherweise "Command not found", 128+Signal-Nummer signalisiert Beendigung durch ein Signal.

In einem PHPUnit-Test prüft man Exit-Codes mit assertSame(Command::SUCCESS, $exitCode) – nicht mit assertEquals, weil der Typ der Zahl (int) ebenso relevant ist wie der Wert. Bei der Symfony-Process-Komponente gibt $process->getExitCode() den Exit-Code als Integer zurück. Fehlt der explizite Return-Aufruf in einem Symfony-Command, gibt der Command 0 zurück – auch wenn eine Exception aufgetreten ist und intern abgefangen wurde. Das ist einer der häufigsten Bugs in Command-Implementierungen und einer der wichtigsten Dinge, die Tests explizit prüfen müssen.


<?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 für Console-Commands

Der CommandTester aus Symfony\Component\Console\Tester ist das wichtigste Werkzeug für Unit-Tests von Console-Commands. Er simuliert die Ausführung eines Commands ohne echten Prozess: Er injiziert Argumente und Optionen, fängt stdout und stderr ab und gibt den Exit-Code zurück. Das ermöglicht schnelle, isolierte Tests, die keine echten Dateien oder Datenbankverbindungen benötigen.

Die Einrichtung ist einfach: Command instanziieren (mit Mock-Abhängigkeiten), CommandTester erstellen, execute() aufrufen mit einem Array von Argumenten und Optionen, dann Exit-Code und Output prüfen. Wichtig: Der erste Schlüssel im Arguments-Array ist der Command-Name, danach kommen Argumente und Optionen in der definierten Reihenfolge. Die Methode getDisplay() gibt den gesamten stdout-Output als String zurück. getErrorOutput() gibt stderr zurück.


<?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: stdout, stderr und Formatierung testen

Output-Assertions sind ein oft vernachlässigter Teil des CLI-Testings. Dabei ist der Output eines Commands sein sichtbarstes Interface: Operatoren lesen ihn, Skripte parsen ihn, Monitoring-Systeme analysieren ihn. Wenn ein Command "Imported 0 products" statt "Imported 42 products" ausgibt, ist das ein Fehler – auch wenn der Exit-Code korrekt ist. Tests, die den Output ignorieren, prüfen nur die Hälfte des Interfaces.

Für strukturierten Output empfiehlt sich assertMatchesRegularExpression statt assertStringContainsString, wenn der genaue Wert variiert aber das Format konstant sein muss. Bei formatierten Tabellen und farbigen Outputs sollte man den Output ohne ANSI-Codes prüfen: $tester->execute([], ['decorated' => false]) deaktiviert Farbcodes. Für stderr gibt es $tester->getErrorOutput(). Wichtig: Fehlerausgaben gehören auf stderr, nicht auf stdout – das ist eine Unix-Konvention, die viele Commands verletzen.

5. Process-Komponente: echte Prozesse testen

Die Symfony-Process-Komponente ermöglicht es, externe Prozesse in PHP zu starten und ihren Output, Exit-Code und Fehleroutput zu lesen. Das ist der richtige Ansatz für Integrationstests, die das Command als echten Prozess ausführen – genau so, wie es ein Cron-Job oder ein Deployment-Skript tun würde. Der Unterschied zum CommandTester: Process startet einen echten PHP-Prozess, CommandTester führt den Command im selben PHP-Prozess aus.

Für den Test eines CLI-Scripts mit der Process-Komponente erstellt man ein Process-Objekt mit dem Kommando als Array, setzt das Arbeitsverzeichnis, startet den Prozess synchron mit run() und prüft danach getExitCode(), getOutput() und getErrorOutput(). Timeout setzen ist Pflicht – ein hängender Prozess soll nicht die gesamte CI-Pipeline blockieren. Process::setTimeout(30) ist ein vernünftiger Ausgangspunkt für Commands ohne externe I/O-Abhängigkeiten.


<?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. Integrationstests für CLI-Commands

Ein vollständiger Integrationstest für einen CLI-Command prüft das gesamte System: Das Command wird mit echten Abhängigkeiten ausgeführt (oder mit minimalen Stubs für externe Dienste wie APIs), und der Test prüft, ob der gewünschte Zustand nach der Ausführung erreicht wurde. Für einen Import-Command bedeutet das: Waren die Datensätze nach dem Aufruf wirklich in der Datenbank? Für einen Cleanup-Command: Wurden die richtigen Dateien gelöscht und die falschen nicht?

Integrationstests für Commands in Symfony können das Kernel-Framework nutzen: KernelTestCase bootstrappt den Application-Container, und $kernel->getContainer()->get(ImportProductsCommand::class) gibt das Command mit echten Abhängigkeiten zurück. Der CommandTester wird dann mit diesem Command erstellt. So werden reale Services verwendet, ohne einen echten Prozess zu starten. Die Datenbank wird vor jedem Test auf einen definierten Zustand zurückgesetzt – mit Fixtures oder Transaktionen, die nach dem Test zurückgerollt werden.

7. Typische Fehler beim CLI-Testen

Der häufigste Fehler: Tests prüfen Exit-Codes nicht. Ein Command-Test, der nur $tester->execute() aufruft und dann den Output prüft, ohne den Exit-Code zu assertieren, gibt keine Garantie, dass das System Fehler korrekt kommuniziert. CI-Pipelines scheitern, Deployments laufen trotz Fehler durch – weil niemand getestet hat, ob das Command im Fehlerfall auch wirklich einen Nicht-Null-Exit-Code zurückgibt.

Ein zweiter häufiger Fehler: Commands, die intern Exceptions abfangen und loggen, aber dann return Command::SUCCESS zurückgeben. Das Command "läuft durch", der Exit-Code ist 0, der Test besteht – aber die Operation hat nicht funktioniert. Diesen Bug findet man nur, wenn Tests explizit prüfen, dass bei simulierten Fehlern der Exit-Code Command::FAILURE ist.

Teststrategie Werkzeug Prüft Wann einsetzen
Unit-Test CommandTester Logik, Exit-Code, Output (isoliert) Schnelle Rückmeldung, Business-Logik
Integrationstest KernelTestCase + CommandTester Reale Services, Datenbank-Effekte Kritische Commands mit Nebeneffekten
End-to-End-Test Process-Komponente Echter Prozess, Signal-Handling CLI-Interface, Exit-Codes, Timeouts
Output-Test getDisplay() / getErrorOutput() stdout/stderr-Format und Inhalte Commands, die von Skripten geparst werden
Exit-Code-Test assertSame(Command::FAILURE, $code) Fehler-Signalisierung an das OS Immer – kein Command-Test ohne Exit-Code

8. Teststrategien im Vergleich

Die Entscheidung zwischen Unit-Test mit CommandTester und Integrationstest mit echten Services folgt demselben Prinzip wie überall in der Test-Pyramide: schnelle, isolierte Tests für die Business-Logik, langsamere, aber umfassendere Tests für das Zusammenspiel der Komponenten. Für CLI-Commands kommt noch eine dritte Ebene hinzu: der echte Prozesstest mit der Process-Komponente, der garantiert, dass Exit-Codes korrekt ans Betriebssystem propagiert werden.

Die Faustregel: Jeder Command bekommt mindestens einen Unit-Test mit CommandTester, der den Erfolgsfall und den wichtigsten Fehlerfall prüft. Commands, die Datenbankzustände verändern, bekommen zusätzlich einen Integrationstest. Commands, die in Produktionsskripten oder CI-Pipelines eingesetzt werden, bekommen einen Process-Test, der den Exit-Code als echten OS-Exitcode prüft.

9. Zusammenfassung

CLI-Commands mit PHPUnit testen bedeutet: Exit-Codes immer explizit assertieren. CommandTester für schnelle, isolierte Unit-Tests der Command-Logik. Die Symfony-Process-Komponente für echte Prozess-Tests, die das gesamte CLI-Interface prüfen. Output-Assertions für stdout und stderr getrennt. Und immer prüfen, dass Commands im Fehlerfall auch wirklich einen Nicht-Null-Exit-Code zurückgeben – das ist die häufigste Quelle von Bugs in Command-Implementierungen.

Exit-Codes sind kein Implementierungsdetail, sondern das öffentliche Interface des Commands gegenüber dem Betriebssystem. Wer sie nicht testet, lässt eine ganze Klasse von Fehlern unkontrolliert in Produktion.

CLI-Commands und Exit-Codes testen — Das Wichtigste auf einen Blick

Exit-Codes immer prüfen

assertSame(Command::SUCCESS, $exitCode) – kein Command-Test ohne Exit-Code-Assertion. Fehlerfall muss FAILURE (1) oder INVALID (2) zurückgeben.

CommandTester für Unit-Tests

Schnelle, isolierte Tests mit Mock-Abhängigkeiten. getDisplay() für stdout, getErrorOutput() für stderr. decorated: false für ANSI-freien Output.

Process-Komponente für E2E

Echter Prozess, echter Exit-Code. Timeout setzen. Fehlerausgabe prüfen. Garantiert, dass OS den korrekten Exit-Code empfängt.

Exception ≠ Failure

Commands, die intern Exceptions abfangen und Command::SUCCESS zurückgeben, sind ein Bug. Tests müssen explizit prüfen, dass Fehler als FAILURE gemeldet werden.

Mironsoft

PHP-Entwicklung, CLI-Testing und Deployment-Infrastruktur

CLI-Commands, die in CI und Produktion zuverlässig laufen?

Wir analysieren bestehende CLI-Commands auf korrekte Exit-Codes, fehlende Tests und schwache Fehlerbehandlung – und bauen eine vollständige Test-Suite aus Unit-, Integrations- und Process-Tests.

Command-Audit

Analyse aller CLI-Commands auf Exit-Code-Korrektheit und Fehlerbehandlung

Test-Aufbau

CommandTester, KernelTestCase und Process-Tests für vollständige Abdeckung

CI-Integration

PHPUnit in CI-Pipelines integrieren mit Exit-Code-Validierung und Report

10. FAQ: CLI-Commands und Exit-Codes mit PHPUnit testen

1Command::SUCCESS vs. Command::FAILURE?
SUCCESS = Exit-Code 0 (Erfolg), FAILURE = 1 (Laufzeitfehler), INVALID = 2 (falsche Argumente). CI-Pipelines und Skripte reagieren auf diese Codes.
2Was ist der CommandTester?
symfony/console-Klasse, die Commands im selben PHP-Prozess ausführt und Output/Exit-Code abfängt. execute() ruft auf, getDisplay() gibt stdout, getExitCode() den Exit-Code.
3Wann Process statt CommandTester?
Wenn echter OS-Exit-Code, Signal-Handling oder vollständiges CLI-Interface getestet werden soll. CommandTester für schnelle Unit-Tests der Logik.
4stderr getrennt von stdout testen?
CommandTester: getErrorOutput() für stderr. Process: getErrorOutput(). Fehler gehören auf stderr, nicht auf stdout – Unix-Konvention.
5Exit-Code 0 trotz Fehler – was tun?
Häufiger Bug: Exception intern abgefangen, aber SUCCESS zurückgegeben. Tests müssen explizit den Fehlerfall simulieren und FAILURE assertieren.
6ANSI-Codes in Assertions stören?
execute([], ['decorated' => false]) deaktiviert ANSI-Escape-Codes. Assertions auf reinen Text ohne Farbcodes herausfiltern.
7Interaktive Eingaben testen?
$tester->setInputs(['y', 'wert']) simuliert stdin-Eingaben in der Reihenfolge der Abfragen. Für Bestätigungsdialoge 'y' oder 'n' eingeben.
8Commands in PHPUnit-Suite einbinden?
Wie normale TestCase-Klassen. CommandTester braucht kein besonderes Setup. Für Integrationstests mit Container: KernelTestCase (Symfony).
9Mocks oder echte Services?
Beides: Mocks für Unit-Tests der Logik, echte Services für Integrationstests kritischer Commands. Beide Ebenen sind notwendig.
10Timeout-Verhalten testen?
Process::setTimeout(N) setzen, ProcessTimedOutException fangen. In Unit-Tests: Zeit als Parameter injizieren und mit Test-Clock mocken.