Es ist wichtig zu bedenken, dass Best Practices für ein Werkzeug wie PHPUnit nicht in Stein gemeißelt sind. Sie entwickeln sich vielmehr im Laufe der Zeit weiter und müssen beispielsweise an Änderungen in PHP angepasst werden. Kürzlich war ich an einer Diskussion beteiligt, die die aktuelle Best Practice für das Testen von Exceptions in Frage stellte. Diese Diskussion führte zu Änderungen in PHPUnit 5.2, die ich in diesem Artikel erläutern möchte.
Die ursprüngliche Best Practice
Vor langer Zeit, als PHP 5.0 gerade erschienen war und PHPUnit 2 die neueste und beste Version war, galt der im folgenden Beispiel gezeigte Ansatz als Best Practice für das Testen von Exceptions:
<?php class ExampleTest extends PHPUnit_Framework_TestCase { public function testExpectedExceptionIsRaised() { // Arrange $example = new Example; // Act try { $example->doSomething(); } catch (ExpectedException $e) { return; } // Assert $this->fail('ExpectedException was not raised'); } }
Wie jeder andere Test hat der oben gezeigte Test drei verschiedene Phasen: In der Arrange-Phase wird das zu testende Objekt vorbereitet, in der Act-Phase wird die zu testende Aktion durchgeführt und in der Assert-Phase wird das Ergebnis der Aktion überprüft. Die damalige Best Practice für das Testen von Exceptions bestand darin, den Code der Act-Phase in einen try-Block einzuschließen. Das return-Statement im entsprechenden Catch-Block signalisierte PHPUnit einen Erfolg. Der Aufruf der fail()-Methode signalisierte PHPUnit ein Testversagen.
Die aktuelle Best Practice
Der oben gezeigte Ansatz für das Testen von Exceptions war umständlich und führte oft zu schwer lesbarem Testcode. Zusammen mit der Tatsache, dass DocBlock-basierte Annotationen in der PHP-Welt immer beliebter wurden, brachte mich das zu der Überzeugung, dass folgender Ansatz besser wäre:
<?php class ExampleTest extends PHPUnit_Framework_TestCase { /** * @expectedException ExpectedException */ public function testExpectedExceptionIsRaised() { // Arrange $example = new Example; // Act $example->doSomething(); } }
Im obigen Beispiel teilt die @expectedException-Annotation PHPUnit mit, dass dieser Test erwartet, dass der zu testende Code eine Exception eines bestimmten Typs auslöst. Wenn diese Exception ausgelöst wird, gilt der Test als erfolgreich. Wenn die Exception nicht ausgelöst wird, gilt der Test als fehlgeschlagen.
Die Implementierung der @expectedException-Annotation verwendet die Methode setExpectedException(), um die Erwartung zu konfigurieren. Diese Methode kann auch direkt verwendet werden, zum Beispiel wenn du es vorziehst, die Erwartung explizit im Code auszudrücken anstatt in einem Kommentar:
<?php class ExampleTest extends PHPUnit_Framework_TestCase { public function testExpectedExceptionIsRaised() { // Arrange $example = new Example; // Expect $this->setExpectedException(ExpectedException::class); // Act $example->doSomething(); } }
Im Laufe der Zeit kamen die Annotationen @expectedExceptionCode, @expectedExceptionMessage und @expectedExceptionMessageRegExp hinzu. Diese ermöglichen die Konfiguration von Erwartungen für Exception-Codes und -Meldungen. Leider wurden sie jedoch nicht sauber implementiert, was die Methode setExpectedException() als Alternative zu den Annotationen immer unbequemer zu nutzen machte.
Seit ich die @expectedException-Annotation zu PHPUnit hinzugefügt habe, betrachtete ich ihre Verwendung als Best Practice. Dies spiegelte sich sowohl in der PHPUnit-Dokumentation als auch in meinen Konferenzvorträgen und Trainings wider.
Die neue Best Practice
Eines Morgens erhielt ich einen Anruf von Stefan Priebsch. Er hatte das Thema Testen von Exceptions gerade mit den Studierenden seiner Meisterklasse an der Hochschule Rosenheim diskutiert. Eine der Studierenden hatte in seiner Hausaufgabe die Methode setExpectedException() verwendet. Als Stefan ihm sagte, dass er die Annotation @expectedException hätte verwenden sollen, stellte der Student diese Best Practice in Frage. Nach einer Diskussion am Telefon musste ich zugeben, dass der Student recht hatte. Obwohl die Annotation @expectedException bequem zu verwenden ist, ist sie auch problematisch. Schauen wir uns die Probleme an, die der Student aufgezeigt hat.
Als die Annotation zu PHPUnit hinzugefügt wurde, gab es in PHP noch keine Unterstützung für Namespaces. Heutzutage werden Namespaces jedoch häufig in PHP-Code verwendet. Und da eine Annotation wie @expectedException technisch gesehen nur ein Kommentar und kein Teil des Codes ist, musst du einen vollqualifizierten Klassennamen wie vendor\project\Example verwenden, wenn du sie einsetzt. In einem Kommentar kannst du keinen unqualifizierten Klassennamen verwenden – zum Beispiel Example –, den du im Code verwenden könntest, wenn diese Klasse im aktuellen Namespace vorhanden oder importiert ist.
<?php class ExampleTest extends PHPUnit_Framework_TestCase { public function testExpectedExceptionIsRaised() { $this->expectException(ExpectedException::class); // ... } }
Im obigen Beispiel verwenden wir die in PHPUnit 5.2 eingeführte Methode expectException(), um PHPUnit mitzuteilen, dass der Test erwartet, dass eine Exception eines bestimmten Typs ausgelöst wird. Dank der Klassenkonstante, die den vollqualifizierten Namen einer Klasse enthält, müssen wir vendor\project\ExpectedException nicht im Testcode ausschreiben – wir können stattdessen ExpectedException::class schreiben. Das verbessert die Lesbarkeit des Tests und macht automatisierte Refactorings in modernen IDEs wie PhpStorm zuverlässig.
Ein weiterer Vorteil, die Erwartung im Testcode zu konfigurieren, hängt mit den drei Phasen eines Tests zusammen, die wir zuvor besprochen haben. Wenn wir die Annotation @expectedException verwenden, betrachtet PHPUnit den Test als erfolgreich, wenn die Exception zu irgendeinem Zeitpunkt während der Ausführung der Testmethode ausgelöst wird. Das ist fast nie das, was du willst:
<?php class ExampleTest extends PHPUnit_Framework_TestCase { public function testExpectedExceptionIsRaised() { // Arrange $example = new Example; // Expect $this->expectException(ExpectedException::class); // Act $example->foo(); } }
PHPUnit betrachtet den im obigen Beispiel gezeigten Test dann und nur dann als erfolgreich, wenn die erwartete Exception nach dem Aufruf der Methode expectException() ausgelöst wird. Wenn der Konstruktor der Klasse Example eine Exception desselben Typs auslöst, wird dies von PHPUnit als Fehler gewertet.
Zusätzlich zur Methode expectException() führt PHPUnit 5.2 auch die Methoden expectExceptionCode(), expectExceptionMessage() und expectExceptionMessageRegExp() ein, um Erwartungen für Exceptions programmatisch zu konfigurieren. Es gibt nichts, was du mit diesen neuen Methoden tun kannst, was nicht auch mit der alten Methode setExpectedException() möglich gewesen wäre. Da setExpectedException() jedoch alles kann, was diese neuen Methoden können, war ihre API unübersichtlich und unbequem. Separation of Concerns lässt grüßen. Die Methode setExpectedException() wurde als veraltet markiert und wird in PHPUnit 6 entfernt.
Ein alternativer Ansatz
Der Vollständigkeit halber sollte ich auch folgende Idee erwähnen, die ins Gespräch gebracht wurde:
<?php class ExampleTest extends PHPUnit_Framework_TestCase { public function testExpectedExceptionIsRaised() { // Arrange $example = new Example; // Assert $this->assertException( ExpectedException::class, function () use ($example) { // Act $example->foo(); } ); } }
Im obigen Beispiel würde die hypothetische Methode assertException() den Code der Act-Phase ausführen und dann die Assertion durchführen. Das Argument für diesen Ansatz ist, dass er die Prüfung mehrerer Exceptions innerhalb eines einzigen Tests ermöglicht. Meiner Meinung nach ist das jedoch ein Argument dagegen. Ein Unit-Test sollte feingranular sein und nur einen einzelnen Aspekt eines Objekts testen. Warum solltest du in einem einzigen Test auf verschiedene Exceptions prüfen müssen?
Fazit
Im Nachhinein ist man immer klüger. Ich bin mir nicht mehr sicher, ob das Hinzufügen der Annotation @expectedException mehr Nutzen als Schaden gebracht hat. Ich plane derzeit nicht, sie aus PHPUnit zu entfernen. Aber für die Zukunft werde ich empfehlen, expectException() etc. für das Testen von Exceptions zu verwenden. Ich möchte euch alle einladen, Best Practices auf diese Weise zu hinterfragen. Nichts kann in Stein gemeißelt sein, wenn wir PHP und sein Ökosystem aus Werkzeugen, Frameworks und Bibliotheken weiterentwickeln wollen.
Update vom 6. Februar 2016: Auf Grundlage des Feedbacks von Andrea Faulds wurde die Diskussion über die Verwendung von Closures für das Testen von Exceptions ergänzt.