thePHP.cc Logo English Kontakt
Wie ich Testinventar verwalte

Wie ich Testinventar verwalte

Vor einiger Zeit habe ich bemerkt, dass ich keine setUp() -Methoden mehr verwende wenn ich Tests schreibe. Das hat sich "einfach so" ergeben, dahinter stand keine bewusste Entscheidung. Eine Diskussion mit Marco Pivetta motivierte mich, diese Änderung zu reflektieren und meine Gedanken in diesem Artikel aufzuschreiben.

Zu Beginn eines Tests wird das so genannte Testinventar (Englisch: Test Fixture) vorbereitet. Soll beispielsweise eine Methode getestet werden, so wird ein Objekt der entsprechenden Klasse benötigt. Nach dieser Vorbereitung wird die Aktion ausgelöst, beispielsweise das Aufrufen einer Methode, deren Ergebnisse getestet werden sollen. Schließlich wird überprüft, ob die erwarteten Ergebnisse eingetreten sind.

Manchmal ist das Testinventar komplexer als ein einzelnes einfaches Objekt. Die Menge an Code, die zum Vorbereiten des Testinventars benötigt wird, wächst entsprechend. Wenn wir nicht aufpassen, leidet die Lesbarkeit der Testmethode unter der Vermischung von Code für die Vorbereitung des Testinventars mit dem eigentlichen Testcode. Es ist daher sinnvoll, den Code für die Vorbereitung des Testinventars in eine separate Methode zu verschieben.

Testinventar mit setUp()

Von Anfang an bietet PHPUnit die beiden Schablonenmethoden setUp() und tearDown() an, um den Code für die Verwaltung des Testinventars vom eigentlichen Testcode zu trennen:

<?php  declare ( strict_types = 1 ) ;
namespace   example ;
 
use   PHPUnit\Framework\TestCase ;
 
final   class   ExampleTest   extends   TestCase
{
     private   ? Example   $example ;
 
     public   function   testSomething ( ) :   void
     {
         $this -> assertSame (
             'the-result' ,
             $this -> example -> doSomething ( )
         ) ;
     }
 
     protected   function   setUp ( ) :   void
     {
         $this -> example   =   new   Example (
             $this -> createStub ( Collaborator :: class )
         ) ;
     }
 
     protected   function   tearDown ( ) :   void
     {
         $this -> example   =   null ;
     }
}

Vor jedem Test ruft PHPUnit die Schablonenmethode setUp() auf, nach jedem Test tearDown() . Die Dokumentation von PHPUnit sagt hierzu:

setUp() und tearDown() sind in der Theorie schön symmetrisch, aber nicht in der Praxis. In der Praxis müssen Sie tearDown() nur implementieren, wenn Sie externe Ressourcen wie Dateien oder Sockets in setUp() zugewiesen haben. Wenn Ihr setUp() nur einfache PHP-Objekte erzeugt, können Sie tearDown() im Allgemeinen ignorieren.

Wenn Sie jedoch viele Objekte in Ihrem setUp() erstellen, sollten Sie die Variablen, die diese Objekte enthalten, in Ihrem tearDown() mit unset() zurücksetzen, damit sie früher aufgeräumt werden können. Objekte, die innerhalb von setUp() (oder Testmethoden) erstellt und in Eigenschaften des Testobjekts gespeichert werden, werden erst am Ende des PHP-Prozesses, der PHPUnit ausführt, automatisch aufgeräumt.

Ein Problem der Schablonenmethoden setUp() und tearDown() ist, dass sie vor beziehungsweise nach jedem Test der Testklasse aufgerufen werden. Also auch für Tests, die das von diesen Methoden verwaltete Testinventar, im oben gezeigten Beispiel die Eigenschaft $this->example , nicht verwenden.

Ein weiteres Problem kann auftreten, wenn Vererbung ins Spiel kommt:

<?php  declare ( strict_types = 1 ) ;
namespace   example ;
 
use   PHPUnit\Framework\TestCase ;
 
abstract   class   MyTestCase   extends   TestCase
{
     protected   function   setUp ( ) :   void
     {
         // ...
     }
}
<?php  declare ( strict_types = 1 ) ;
namespace   example ;
 
use   PHPUnit\Framework\TestCase ;
 
final   class   ExampleTest   extends   MyTestCase
{
     protected   function   setUp ( ) :   void
     {
         // ...
     }
}

Wenn wir bei der Implementierung von ExampleTest::setUp() vergessen, parent::setUp() aufzurufen, so wird die von MyTestCase zur Verfügung gestellte Funktionalität nicht funktionieren. Um dieses Risiko zu reduzieren, wurden schon vor langer Zeit die Annotationen @before und @after eingeführt. Mit diesen können mehrere Methoden für die Ausführung vor beziehungsweise nach einem Test konfiguriert werden.

Testinventar ohne setUp()

Den eingangs gezeigten Test schreibe ich heute so:

<?php  declare ( strict_types = 1 ) ;
namespace   example ;
 
use   PHPUnit\Framework\TestCase ;
 
final   class   ExampleTest   extends   TestCase
{
     public   function   testSomething ( ) :   void
     {
         $this -> assertSame (
             'the-result' ,
             $this -> example ( ) -> doSomething ( )
         ) ;
     }
 
     private   function   example ( ) :   Example
     {
         return   new   Example (
             $this -> createStub ( Collaborator :: class )
         ) ;
     }
}

Im Testobjekt wird kein testspezifischer Zustand, beispielsweise in einer Eigenschaft wie $this->example , gespeichert. Nachdem die Ausführung der Testmethode beendet ist, hält der Test keine Referenz mehr auf das in der Methode example() erzeugte Objekt. Der Garbage Collector von PHP kann es daher automatisch aufräumen.

Die Methode example() wird nur von den Tests aufgerufen, die das so erzeugte Objekt benötigen. Darüber hinaus ist sie ein privates Implementierungsdetail der Testklasse, Probleme durch Vererbung sind daher ausgeschlossen.

Einen Typ für den Rückgabewert der Methode example() konnte ich übrigens schon deklarieren, bevor PHP 7.4 endlich Typdeklarationen für Eigenschaften einführte.

Ich glaube es sind diese drei Gründe, die dazu geführt haben, dass ich unterbewusst die Art und Weise, wie ich in meinen Tests das Testinventar verwalte, geändert habe.

Vor ein paar Jahren schrieb ich in einem anderen Artikel :

Es ist wichtig zu bedenken, dass Best Practices für ein Werkzeug 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.

Damals ging es um das Testen von Ausnahmen, jetzt um das Verwalten von Testinventar. Letzteres werde ich fortan ohne die Methoden setUp() und tearDown() tun.