expectException, Codes, Nachrichten und eigene Hierarchien
Wer Exceptions nicht gezielt testet, lässt Fehlerbehandlung ungeprüft – der gefährlichste blinde Fleck in einer PHP-Testsuite. PHPUnit bietet präzise Methoden, um sicherzustellen, dass der richtige Ausnahmetyp, die passende Nachricht und der korrekte Fehlercode ausgelöst werden.
Inhaltsverzeichnis
- 1. Warum Exception-Tests unverzichtbar sind
- 2. expectException: den Typ korrekt prüfen
- 3. expectExceptionMessage und MessageMatches
- 4. expectExceptionCode: Fehlercodes gezielt testen
- 5. Eigene Exception-Hierarchien strukturieren
- 6. Exceptions mit DataProvider kombinieren
- 7. Typische Fallstricke und falsche Patterns
- 8. Exception-Test-Methoden im Vergleich
- 9. Exception-Tests in CI und Coverage integrieren
- 10. Zusammenfassung
- 11. FAQ
1. Warum Exception-Tests unverzichtbar sind
Jede PHP-Applikation, die in der Produktion läuft, enthält Codepfade, die bei ungültigen Eingaben, fehlenden Ressourcen oder verletzten Geschäftsregeln eine Exception auslösen sollen. Ob diese Exception tatsächlich ausgelöst wird, welchen Typ sie hat und ob die Fehlermeldung für den Aufrufer verständlich ist – das bleibt ohne gezielte Tests vollständig ungetestet. Entwickler verlassen sich dann auf manuelle Überprüfung oder entdecken Fehler erst im Produktionsbetrieb, wenn ein Stack-Trace im Logging auftaucht.
PHPUnit bietet eine saubere, deklarative API für genau diese Anforderung. Die Methoden expectException(), expectExceptionMessage() und expectExceptionCode() werden vor dem Aufruf des zu testenden Codes platziert und legen fest, was PHPUnit erwartet. Wird keine oder eine andere Exception ausgelöst, schlägt der Test fehl – präzise und nachvollziehbar. Das macht Exception-Tests zu einem zentralen Bestandteil einer vollständigen Testsuite, nicht zu einer optionalen Ergänzung.
In modernen PHP-Projekten mit strikten Typen und domänengetriebenen Exception-Hierarchien ist das Testen von Ausnahmen besonders wertvoll. Eine InvalidArgumentException, die bei falschen Konstruktorargumenten geworfen wird, oder eine domänenspezifische InsufficientStockException – beide repräsentieren explizites Systemverhalten, das getestet und als Regression abgesichert werden muss.
2. expectException: den Typ korrekt prüfen
Die grundlegendste Form des Exception-Tests in PHPUnit ist $this->expectException(ExceptionClass::class). Diese Methode muss vor dem Aufruf des Codes stehen, der die Exception auslösen soll. PHPUnit registriert die Erwartung, führt den Test-Body aus und prüft, ob eine Exception des angegebenen Typs geworfen wurde. Wird keine Exception ausgelöst, markiert PHPUnit den Test als fehlgeschlagen. Wird eine Exception eines anderen Typs geworfen, schlägt der Test ebenfalls fehl.
Ein wichtiger Aspekt: expectException() prüft den Typ einschließlich der gesamten Klassenhierarchie. Wenn man expectException(\RuntimeException::class) schreibt und der Code tatsächlich eine eigene DatabaseConnectionException extends \RuntimeException wirft, besteht der Test, weil DatabaseConnectionException ein Subtyp von RuntimeException ist. Will man ausschließlich den exakten Typ prüfen, muss man den konkreten Klassennamen angeben. Diese Unterscheidung ist in Projekten mit tiefen Exception-Hierarchien entscheidend.
<?php
declare(strict_types=1);
namespace Mironsoft\Shop\Tests\Unit\Domain;
use Mironsoft\Shop\Domain\Product\Exception\InvalidPriceException;
use Mironsoft\Shop\Domain\Product\PriceCalculator;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(PriceCalculator::class)]
final class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PriceCalculator();
}
#[Test]
public function throwsInvalidPriceExceptionForNegativePrice(): void
{
// Expectation must be declared BEFORE the code that throws
$this->expectException(InvalidPriceException::class);
// This call must throw — PHPUnit verifies the expectation
$this->calculator->calculateNetPrice(-9.99, 0.19);
}
#[Test]
public function throwsInvalidArgumentExceptionForZeroTaxRate(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->calculator->calculateNetPrice(100.00, 0.0);
}
}
3. expectExceptionMessage und MessageMatches
Der Typ einer Exception allein ist häufig nicht ausreichend, um das korrekte Verhalten zu verifizieren. Eine InvalidArgumentException kann viele verschiedene Ursachen haben, und die Fehlermeldung transportiert für Entwickler und Logging-Systeme entscheidende Kontextinformationen. expectExceptionMessage() prüft auf exakte Übereinstimmung, während expectExceptionMessageMatches() einen regulären Ausdruck erwartet – nützlich, wenn die Nachricht dynamische Anteile wie Dateinamen, IDs oder Timestamps enthält.
Die Wahl zwischen exakter Nachricht und Regex-Pattern hat eine praktische Konsequenz für die Wartbarkeit von Tests. Exakte Nachrichten machen Tests fragil gegenüber Formulierungsänderungen. Regex-Patterns wie '/Preis darf nicht negativ sein/i' oder '/ID \d+ nicht gefunden/' sind robuster und decken trotzdem genug ab, um sicherzustellen, dass die richtige Fehlermeldung erzeugt wird. In Projekten mit mehrsprachigen Fehlermeldungen oder Konfigurationen sollte man die Assertion auf den unveränderlichen Kern der Nachricht beschränken.
<?php
declare(strict_types=1);
use Mironsoft\Shop\Domain\Order\Exception\OrderNotFoundException;
use Mironsoft\Shop\Domain\Order\OrderRepository;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
final class OrderRepositoryTest extends TestCase
{
#[Test]
public function throwsWithExactMessageWhenOrderNotFound(): void
{
$repository = $this->createStub(OrderRepository::class);
$repository->method('findById')
->willThrowException(new OrderNotFoundException('Order 42 not found'));
$this->expectException(OrderNotFoundException::class);
// Exact match — brittle if message text changes
$this->expectExceptionMessage('Order 42 not found');
$repository->findById(42);
}
#[Test]
public function throwsWithDynamicIdInMessage(): void
{
$repository = $this->createStub(OrderRepository::class);
$repository->method('findById')
->willThrowException(new OrderNotFoundException('Order 9876 not found'));
$this->expectException(OrderNotFoundException::class);
// Regex match — robust against message reformulation
$this->expectExceptionMessageMatches('/Order \d+ not found/');
$repository->findById(9876);
}
}
4. expectExceptionCode: Fehlercodes gezielt testen
PHP-Exceptions tragen neben der Nachricht auch einen ganzzahligen Fehlercode, der in vielen Domänen eine wichtige Rolle spielt. HTTP-Status-Codes, interne Fehlercodes für API-Clients oder datenbankspezifische Fehlernummern werden häufig über den Exception-Code transportiert. expectExceptionCode() stellt sicher, dass der Code korrekt gesetzt ist – unabhängig von der Formulierung der Nachricht. Das macht Tests stabiler in Umgebungen, in denen Fehlermeldungen lokalisiert werden oder sich mit der Zeit ändern, der semantische Code aber konstant bleibt.
Eine sinnvolle Praxis in komplexen Systemen ist es, Exception-Codes als Konstanten in den jeweiligen Exception-Klassen zu definieren. Das macht Tests lesbar: expectExceptionCode(PaymentException::CARD_DECLINED) drückt die Intention direkt aus, statt eine kryptische Zahl wie 4003 hineinzuschreiben. Diese Konstanten dienen gleichzeitig als Dokumentation der möglichen Fehlerszenarien und können in API-Referenzen und Logging-Systemen verwendet werden.
5. Eigene Exception-Hierarchien strukturieren
In domänengetriebenen PHP-Projekten ist eine flache Exception-Hierarchie, die ausschließlich Standard-PHP-Exceptions verwendet, ein Zeichen mangelnder Modellierung. Eigene Exception-Klassen transportieren Domänenwissen, ermöglichen gezieltes Catching im Anwendungscode und machen Tests ausdrucksstärker. Das Grundprinzip: Jedes Modul oder jede Bounded Context bekommt eine eigene Basis-Exception, von der alle modul-spezifischen Exceptions erben.
Das Muster DomainException extends \DomainException, mit modulspezifischen Unterklassen wie ProductNotFoundException extends ProductException, ermöglicht sowohl spezifisches als auch generisches Catching. In Tests kann man wahlweise auf den exakten Typ oder auf die Basis-Exception testen – je nach dem, was auf der jeweiligen Testebene sinnvoll ist. Integrationstests prüfen häufig nur die Basis-Exception, Unit-Tests den konkreten Subtyp. Diese Flexibilität ist ein direkter Vorteil gegenüber der Verwendung von Standard-PHP-Exceptions überall.
<?php
declare(strict_types=1);
namespace Mironsoft\Shop\Domain\Product\Exception;
// Base exception for the Product domain
class ProductException extends \DomainException {}
// Specific exceptions extend the domain base
class ProductNotFoundException extends ProductException
{
public static function forId(int $id): self
{
return new self(
message: sprintf('Product with ID %d was not found', $id),
code: 404
);
}
}
class InsufficientStockException extends ProductException
{
public static function forProduct(int $productId, int $requested, int $available): self
{
return new self(
message: sprintf(
'Product %d: requested %d units but only %d available',
$productId, $requested, $available
),
code: 409
);
}
}
// Test: verifies both specific type and base-type catching
final class ProductExceptionTest extends \PHPUnit\Framework\TestCase
{
public function testNotFoundIsSubtypeOfProductException(): void
{
$exception = ProductNotFoundException::forId(99);
$this->assertInstanceOf(ProductException::class, $exception);
$this->assertInstanceOf(\DomainException::class, $exception);
$this->assertSame(404, $exception->getCode());
$this->assertStringContainsString('99', $exception->getMessage());
}
}
6. Exceptions mit DataProvider kombinieren
Viele Validierungsregeln erzeugen bei verschiedenen ungültigen Eingaben ähnliche, aber nicht identische Exceptions. Statt für jeden Eingabefall einen separaten Testmethode zu schreiben, lassen sich DataProvider-Attribute effizient einsetzen, um denselben Test-Body mit verschiedenen Kombinationen aus Eingabe, erwartetem Exception-Typ, Nachricht und Code zu parametrisieren. Das reduziert Redundanz und stellt sicher, dass alle Grenzfälle konsistent getestet werden.
Bei der Kombination von DataProvider und Exception-Tests ist die Reihenfolge entscheidend: Die expect*()-Aufrufe müssen innerhalb der Testmethode stehen, nicht im DataProvider selbst. Der DataProvider liefert nur Rohdaten. Das ermöglicht, unterschiedliche Exception-Typen pro Datensatz zu prüfen, indem man den Klassennamen als Parameter übergibt und ihn im Test-Body an expectException() weitergibt. Diese Technik spart erheblich Code bei gleichzeitig vollständiger Abdeckung aller Varianten.
7. Typische Fallstricke und falsche Patterns
Der häufigste Fehler beim Exception-Testing in PHPUnit ist, Code nach dem auslösenden Aufruf zu platzieren, der nicht mehr ausgeführt wird. Da die Exception den Test-Body unterbricht, sind alle Assertions nach dem werfenden Aufruf nutzlos und können eine falsche Sicherheit erzeugen. PHPUnit führt die expect*()-Assertions am Ende des Tests automatisch aus – aber benutzerdefinierte Assertions nach dem werfenden Code werden nie erreicht.
Ein zweites gefährliches Anti-Pattern ist das Wrappen des werfenden Codes in einem try-catch-Block innerhalb des Tests und das manuelle Ausführen von Assertions auf der gefangenen Exception. Das funktioniert zwar technisch, umgeht aber die PHPUnit-Infrastruktur vollständig. Wenn keine Exception ausgelöst wird, läuft der Test durch und wird als bestanden markiert – obwohl der Fehlerfall nicht eingetreten ist. Das korrekte Muster ist immer expectException() ohne umgebenden try-catch.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
final class ValidationExceptionTest extends TestCase
{
// WRONG: try-catch hides missing exception — test passes even without throw
public function wrongPattern(): void
{
try {
$this->someService->validate('');
// If no exception is thrown, test still passes — dangerous!
} catch (\InvalidArgumentException $e) {
$this->assertStringContainsString('required', $e->getMessage());
}
}
// RIGHT: expectException before the throwing call
#[Test]
public function correctPattern(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/required/i');
// PHPUnit catches the exception and verifies the expectation
$this->someService->validate('');
// Nothing after this line — code here never executes
}
/** @return array<string, array{input: string, exClass: class-string, msgRegex: string}> */
public static function invalidInputProvider(): array
{
return [
'empty string' => ['input' => '', 'exClass' => \InvalidArgumentException::class, 'msgRegex' => '/required/i'],
'negative number' => ['input' => '-5', 'exClass' => \RangeException::class, 'msgRegex' => '/negative/i'],
'too long' => ['input' => str_repeat('a', 256), 'exClass' => \LengthException::class, 'msgRegex' => '/255/'],
];
}
#[Test]
#[DataProvider('invalidInputProvider')]
public function throwsCorrectExceptionForInvalidInput(
string $input,
string $exClass,
string $msgRegex
): void {
$this->expectException($exClass);
$this->expectExceptionMessageMatches($msgRegex);
$this->someService->validate($input);
}
}
8. Exception-Test-Methoden im Vergleich
PHPUnit bietet mehrere Mechanismen für Exception-Tests, und die Wahl des richtigen Ansatzes beeinflusst Lesbarkeit, Wartbarkeit und Präzision der Tests erheblich.
| Methode | Prüft | Empfehlung | Hinweis |
|---|---|---|---|
expectException() |
Exception-Typ (inkl. Subklassen) | Immer verwenden | Pflicht für jeden Exception-Test |
expectExceptionMessage() |
Exakter Nachrichtentext | Mit Vorsicht | Fragil bei Textänderungen |
expectExceptionMessageMatches() |
Regex-Pattern im Nachrichtentext | Bevorzugt | Robuster als exakter Match |
expectExceptionCode() |
Ganzzahliger Fehlercode | Bei API-Codes wichtig | Mit Konstanten lesbar machen |
| try-catch im Test | Manuell, was man hineinschreibt | Vermeiden | Schlägt nicht fehl ohne Exception |
9. Exception-Tests in CI und Coverage integrieren
Exception-Tests tragen zur Code-Coverage bei – aber nur, wenn der Codepfad, der die Exception wirft, tatsächlich durchlaufen wird. Ein häufiges Missverständnis: Wer expectException() setzt, aber keine Assertions auf den eigentlichen Code darunter hat, bekommt keine Coverage für den werfenden Pfad. PHPUnit zählt die Ausführung des Codes, der die Exception wirft, als gedeckt – aber nur wenn @covers oder das #[CoversClass]-Attribut korrekt gesetzt ist.
In CI-Pipelines sollte die Exception-Coverage als separates Kriterium betrachtet werden. Branch-Coverage, nicht nur Line-Coverage, zeigt, ob sowohl der Erfolgspfad als auch der Exception-Pfad durch Conditional-Logik getestet sind. PHPUnit mit dem --coverage-clover-Flag erzeugt Berichte, die Infection (Mutation Testing) als Input nutzt – Mutation Testing ist das zuverlässigste Werkzeug, um zu prüfen, ob Exception-Tests wirklich aussagekräftig sind oder nur die Exception-Auslösung simulieren.
10. Zusammenfassung
Exceptions sind explizites Systemverhalten und müssen als solches getestet werden. expectException() prüft den Typ, expectExceptionMessageMatches() die Nachricht robust per Regex, expectExceptionCode() den semantischen Fehlercode. Die expect*()-Aufrufe müssen vor dem werfenden Code stehen, try-catch-Blöcke im Test-Body sind ein Anti-Pattern. Eigene Exception-Hierarchien nach Domänen strukturieren und Fehlercodes als Konstanten definieren, um Tests lesbar und wartbar zu halten.
DataProvider-Attribute ermöglichen das effiziente Testen verschiedener Eingaben mit unterschiedlichen Exception-Erwartungen ohne Code-Duplizierung. In CI-Pipelines sorgt Branch-Coverage dafür, dass sowohl Erfolgs- als auch Fehlerpfade abgedeckt sind. Mutation Testing mit Infection zeigt, ob Exception-Tests wirklich das Systemverhalten sichern oder nur die Ausführung ohne Aussagekraft dokumentieren.
Exceptions in PHPUnit testen — Das Wichtigste auf einen Blick
Korrekte Reihenfolge
expectException() muss vor dem werfenden Code stehen. Alles danach wird nicht ausgeführt. Niemals try-catch im Test-Body als Ersatz verwenden.
Nachrichten testen
expectExceptionMessageMatches() mit Regex statt exaktem Text – robuster gegen Formulierungsänderungen, gerade bei dynamischen Werten in der Nachricht.
Exception-Hierarchien
Domänenspezifische Basis-Exception pro Modul. Konkrete Subklassen mit statischen Named-Constructor-Methoden für lesbare Test-Fixtures.
Coverage & CI
Branch-Coverage aktivieren. Infection Mutation Testing nutzen, um zu prüfen, ob Exception-Tests echte Aussagekraft haben oder nur die Ausführung dokumentieren.
Mironsoft
PHP-Entwicklung, Testing-Strategien und Code-Qualität
Testsuiten, die wirklich schützen?
Wir analysieren bestehende PHPUnit-Testsuiten, identifizieren ungetestete Exception-Pfade und ergänzen gezielte Tests – mit Fokus auf Branch-Coverage, Mutation Testing und domänenspezifische Exception-Hierarchien.
Test-Audit
Analyse bestehender Testsuiten auf ungetestete Exception-Pfade und Anti-Patterns
Exception-Design
Domänenspezifische Exception-Hierarchien entwerfen und dokumentieren
Mutation Testing
Infection-Integration in CI-Pipeline für aussagekräftige Test-Qualitätsmessung