Die ursprüngliche Best Practice
Vor langer Zeit, als PHP 5.0 gerade veröffentlicht worden war und PHPUnit 2 das Neueste und Beste war, war der im Beispiel unten gezeigte Ansatz die Best Practice für das Testen von Exceptions:
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 auch 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 ausgeführt und in der Assert-Phase
wird das Ergebnis der Aktion überprüft. Die Best Practice für das Testen von Exceptions war damals, den Code der Act-Phase
in einen try
-Block zu packen. Die return
-Anweisung im jeweiligen catch
-Block würde PHPUnit einen Erfolg signalisieren. Der Aufruf von fail()
würde PHPUnit einen Testfehler signalisieren.
Die aktuelle Best Practice
Der im obigen Beispiel gezeigte Ansatz zum 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, führte dies zu der Überzeugung, dass der folgende Ansatz besser wäre:
class ExampleTest extends PHPUnit_Framework_TestCase |
{ |
/** |
* @expectedException ExpectedException |
*/ |
public function testExpectedExceptionIsRaised ( ) |
{ |
// Arrange |
$example = new Example ; |
// Act |
$example -> doSomething ( ) ; |
} |
} |
Im obigen Beispiel teilt die Annotation @expectedException
PHPUnit mit, dass dieser Test erwartet, dass der zu testende Code eine Exception eines bestimmten Typs auslöst. Wenn diese Ausnahme ausgelöst wird, wird der Test als Erfolg gewertet. Wird die Exception nicht ausgelöst, wird der Test als fehlgeschlagen gewertet.
Die Implementierung der Annotation @expectedException
verwendet intern die Methode setExpectedException()
, um die Erwartung einzurichten. Diese Methode kann auch direkt verwendet werden, zum Beispiel wenn Sie es vorziehen, die Assert-Phase
explizit im Code und nicht in einem Kommentar auszudrücken. Aufgrund der Natur von Exceptions sind im unten gezeigten Testcode die Assert-Phase
und die Act-Phase
vertauscht:
class ExampleTest extends PHPUnit_Framework_TestCase |
{ |
public function testExpectedExceptionIsRaised ( ) |
{ |
// Arrange |
$example = new Example ; |
// Assert |
$this -> setExpectedException ( ExpectedException :: class ) ; |
// Act |
$example -> doSomething ( ) ; |
} |
Mit der Zeit wurden die Annotationen @expectedExceptionCode
, @expectedExceptionMessage
und @expectedExceptionMessageRegExp
hinzugefügt. Diese erlauben die Konfiguration von Erwartungen für Ausnahmecodes und -meldungen. Leider wurden sie aber nicht sauber implementiert, sodass die Methode setExpectedException()
immer weniger als Alternative zu den Annotationen verwendet werden kann.
Seitdem ich die @expectedException
-Annotation zu PHPUnit hinzugefügt habe, habe ich ihre Verwendung als Best Practice angesehen. Dies spiegelte sich sowohl in der Dokumentation von PHPUnit als auch in meinen Konferenzpräsentationen und Schulungen wider.
Eine neue Best Practice
Eines Morgens vor nicht allzu langer Zeit bekam ich einen Anruf von Stefan Priebsch
. Er hatte gerade mit den Studenten seiner Vorlesung an der Hochschule Rosenheim das Thema Testen von Exceptions diskutiert. Einer seiner Studenten hatte die Methode setExpectedException()
in seiner Hausarbeit verwendet. Als Stefan ihn darauf hinwies, dass er die Annotation @expectedException
hätte verwenden sollen, stellte der Student diese Best Practice infrage. Nachdem ich das Thema am Telefon besprochen hatte, musste ich zugeben, dass der Student recht hatte. Die Annotation @expectedException
ist zwar bequem zu verwenden, aber auch problematisch. Schauen wir uns die Probleme an, auf die der Student hingewiesen hat.
Damals, als die Annotation zu PHPUnit hinzugefügt wurde, gab es keine Unterstützung für Namespaces in PHP. Heutzutage werden Namespaces jedoch üblicherweise in PHP-Code verwendet. Und da eine Annotation wie @expectedException
technisch gesehen nur ein Kommentar und nicht Teil des Codes ist, müssen Sie einen vollqualifizierten Klassennamen wie vendor\project\Example
verwenden, wenn Sie ihn benutzen. In einem Kommentar können Sie keinen unqualifizierten Klassennamen verwenden, beispielsweise Example
, den Sie im Code verwenden könnten, wenn sich diese Klasse im aktuellen Namespace befindet oder in diesen importiert wird.
namespace vendor\project ; |
class ExampleTest extends \PHPUnit_Framework_TestCase |
{ |
public function testExpectedExceptionIsRaised ( ) |
{ |
$this -> expectException ( ExpectedException :: class ) ; |
// ... |
} |
} |
Im obigen Beispiel verwenden wir die Methode expectException()
, die in PHPUnit 5.2
eingeführt wurde, um PHPUnit mitzuteilen, dass der Test die Auslösung einer Ausnahme eines bestimmten Typs erwartet. Dank der Klassenkonstante, die den vollqualifizierten Namen einer Klasse enthält, müssen wir im Testcode nicht Vendor\project\ExpectedException
schreiben, sondern 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 der Ausnahme so im Testcode auszudrücken, hat mit den drei Phasen eines Tests zu tun, die wir bereits besprochen haben. Wenn wir die Annotation @expectedException
verwenden, dann wird PHPUnit den Test als erfolgreich betrachten, wenn die Exception zu irgendeinem Zeitpunkt während der Ausführung der Testmethode ausgelöst wird. Dies ist vielleicht nicht immer das, was Sie wollen:
namespace vendor\project ; |
class ExampleTest extends \PHPUnit_Framework_TestCase |
{ |
public function testExpectedExceptionIsRaised ( ) |
{ |
// Arrange |
$example = new Example ; |
// Assert |
$this -> expectException ( ExpectedException :: class ) ; |
// Act |
$example -> foo ( ) ; |
} |
} |
PHPUnit wird den im obigen Beispiel gezeigten Test dann und nur dann als Erfolg betrachten, wenn nach dem Aufruf der Methode expectException()
die erwartete Exception ausgelöst wird. Wenn der Konstruktor der Klasse Example
eine Exception des gleichen Typs auslöst, dann 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 programmatisch Erwartungen für Ausnahmen zu setzen. Es gibt nichts, was Sie mit diesen neuen Methoden tun können, was Sie nicht auch mit der alten Methode setExpectedException()
hätten tun können. Da setExpectedException()
jedoch alles kann, was diese neuen Methoden können, war seine API unübersichtlich und umständlich. Von wegen Separation of Concerns und so. Die Methode setExpectedException()
ist nun deprecated und wird in PHPUnit 6 verschwinden.
Der Vollständigkeit halber sollte ich auch die folgende Idee erwähnen:
namespace vendor\project ; |
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, das für diesen Ansatz angeführt wird, ist, dass er Prüfungen auf mehrere Ausnahmen innerhalb eines einzigen Tests ermöglicht. Meiner Meinung nach ist dies jedoch ein Argument dagegen. Ein Unit Test sollte feingranular sein und nur einen einzigen Aspekt eines Objekts testen. Warum sollten Sie in einem einzigen Test auf verschiedene Ausnahmen prüfen?
Hinterher ist man immer schlauer. Ich bin mir nicht mehr sicher, ob das Hinzufügen der @expectedException
mehr Nutzen als Schaden gebracht hat. Ich plane aber derzeit nicht, es aus PHPUnit zu entfernen. Aber in Zukunft werde ich empfehlen, expectException()
usw. für das Testen von Exceptions zu verwenden. Ich möchte Sie alle dazu einladen, solche Best Practices zu hinterfragen. Nichts kann in Stein gemeißelt sein, wenn wir PHP und sein Ökosystem von Werkzeugen, Frameworks und Bibliotheken weiterentwickeln wollen.
Update (6. Februar 2016): Diskussion über die Verwendung von Closures zum Testen von Ausnahmen basierend auf Feedback von Andrea Faulds hinzugefügt.