Hinterfragen von PHPUnit Best Practices

Hinterfragen von PHPUnit Best Practices

Es ist wichtig zu bedenken, dass Best Practices für ein Tool wie PHPUnit nicht in Stein gemeißelt sind. Vielmehr entwickeln sie sich im Laufe der Zeit weiter und müssen beispielsweise an Änderungen in PHP angepasst werden. Vor kurzem war ich an einer Diskussion beteiligt, die die aktuelle Best Practice für das Testen von Exceptions infrage 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 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 diesem Tweet hinzugefügt.