thePHP.cc Logo English Kontakt
Mocke nicht, was Dir nicht gehört

Mocke nicht, was Dir nicht gehört

Es klingt ganz einfach: teste Units of Code ohne ihre echten Abhängigkeiten, verwende stattdessen Stubs oder Mocks. Wenn man aber die ersetzten Abhängigkeiten nicht kontrolliert, kann das schnell außer Kontrolle geraten.

Webanwendungen verarbeiten typischerweise HTTP-Anfragen. Üblicherweise werden Objekte verwendet, um Anfragedaten zu kapseln. Je nach Framework haben wir eine Schnittstelle wie

interface   HttpRequest
{
     public   function   get ( string   $name ) :   string ;
 
     // ...
}

oder sogar eine konkrete Klasse wie

class   HttpRequest
{
     public   function   get ( string   $name ) :   string
     {
         // ...
     }
 
     // ...
}

die wir für den Zugriff auf Request-Daten benutzen können (und sollten).

Symfony hat beispielsweise Request::get() . Für das Beispiel werden wir uns keine Gedanken darüber machen, welche Art von HTTP-Anfrage wir verarbeiten (GET, POST oder eine andere).

Konzentrieren wir uns stattdessen auf implizite APIs wie HttpRequest::get() und die damit verbundenen Probleme.

Wenn wir Request-Daten verarbeiten, beispielsweise in einem Controller, dann müssen wir dieselbe get() -Methode für die verschiedenen Informationen verwenden, die wir abfragen wollen. Es gibt keine spezifische Methode mit einem expliziten Namen für die einzelne Information, die uns interessiert. Stattdessen wird der Name nur als String-Argument an die generische get() -Methode übergeben:

class   SomeController
{
     public   function   execute ( HttpRequest   $request ) :   HttpResponse
     {
         $id       =   $request -> get ( 'id' ) ;
         $amount   =   $request -> get ( 'amount' ) ;
         $price    =   $request -> get ( 'price' ) ;
 
         // ...
     }
}

Wir werden nicht weiter vertiefen, ob ein Controller eine oder mehrere Action-Methoden haben sollte (Hinweis: er sollte nur eine haben ). Der Punkt hier ist, dass der Controller auf die Daten einer HTTP-Anfrage zugreifen können muss, um diese verarbeiten zu können.

Wenn wir das HttpRequest -Objekt durch einen Test Stub oder ein Mock Objekt ersetzen, um SomeController isoliert vom Web und vom Framework, das wir zur Abstraktion vom Web verwenden, zu testen, dann haben wir mehrere Aufrufe Methode get() , und zwar mit jeweils unterschiedlichen Argumenten, die allesamt Strings sind: 'id' , 'amount' und 'price' .

Wir müssen für jeden Aufruf sinnvolle Rückgabewerte sicherstellen, sonst werden die Daten nicht validiert und wir kommen nicht durch den Happy Path unserer Controller-Action.

Um SomeController isoliert vom eigentlichen HttpRequest -Objekt zu testen, können wir einen Test Stub in einem Unit Test mit PHPUnit wie folgt verwenden:

$request   =   $this -> createStub ( HttpRequest :: class ) ;
 
$request -> method ( 'get' )
         -> willReturnOnConsecutiveCalls (
               '1' ,
               '2' ,
               '3' ,
           ) ;
 
$controller   =   new   SomeController ;
 
$controller -> execute ( $request ) ;

Wollen wir auch die Kommunikation zwischen den Objekten SomeController und HttpRequest verifizieren, so benötigen wir ein Mock Objekt, auf dem wir in unserem Test Erwartungen konfigurieren müssen:

$request   =   $this -> createMock ( HttpRequest :: class ) ;
 
$request -> expects ( $this -> exactly ( 3 ) )
         -> method ( 'get' )
         -> withConsecutive (
             [ 'id' ] ,
             [ 'amount' ] ,
             [ 'price' ]
         )
         -> willReturnOnConsecutiveCalls (
             '1' ,
             '2' ,
             '3' ,
         ) ;
 
$controller   =   new   SomeController ;
 
$controller -> execute ( $request ) ;

Der oben gezeigte Code ist etwas schwer zu lesen, was ein Code Smell ist.

Wir drücken jedoch aus, dass HttpRequest::get() dreimal aufgerufen werden muss: zuerst mit dem Argument 'id' , dann mit 'amount' und schließlich mit 'price' .

Wenn wir die Implementierung von SomeController::execute() ändern, um die Aufrufe von HttpRequest::get() in einer anderen Reihenfolge auszuführen, wird unser Test fehlschlagen. Das sagt uns, dass wir unseren Testcode zu eng an den Produktionscode gekoppelt haben. Dies ist ein weiterer Code Smell.

Das eigentliche Problem ist, dass wir HttpRequest mit einer impliziten API nach Informationen fragen, indem wir ein String-Argument, das den Namen eines HTTP-Parameters angibt, an eine generische get() -Methode übergeben. Und um die Sache noch schlimmer zu machen, mocken wir einen Typ, der uns nicht gehört: HttpRequest wird vom Framework bereitgestellt und unterliegt nicht unserer Kontrolle.

Die Weisheit „Mocke nicht, was Dir nicht gehört“ hat ihren Ursprung in der „London School of Test-Driven Development“. Steve Freeman und Nat Pryce schrieben 2009 in „ Growing Object-Oriented Software Guided by Tests “:

We find that tests that mock external libraries often need to be complex to get the code into the right state for the functionality we need to exercise. The mess in such tests is telling us that the design isn't right but, instead of fixing the problem by improving the code, we have to carry the extra complexity in both code and test.

Wenn wir nicht mocken sollten, was uns nicht gehört, wie sollen wir dann unseren Code von dem Code Dritter isolieren? Steve Freeman und Nat Pryce fuhren fort:

We [...] design interfaces for the services our objects need – which will be defined in terms of our objects' domain, not the external library. We write a layer of adapter objects [...] that uses the third-party API to implement these interfaces [...]

Lasst uns genau das tun:

interface   SomeRequestInterface
{
     public   function   getId ( ) :   string ;
 
     public   function   getAmount ( ) :   string ;
 
     public   function   getPrice ( ) :   string ;
}

Anstatt nur string zurückzugeben, könnten wir nun noch spezifischere Typen oder sogar Wertobjekte verwenden. Für die Zwecke dieses Beispiels bleiben wir jedoch bei string .

Das Erstellen eines Test Doubles für SomeRequestInterface ist einfach und unkompliziert:

$request   =   $this -> createStub ( SomeRequestInterface :: class ) ;
 
$request -> method ( 'getId' )
         -> willReturn ( 1 ) ;
 
$request -> method ( 'getAmount' )
         -> willReturn ( 2 ) ;
 
$request -> method ( 'getPrice' )
         -> willReturn ( 3 ) ;

Aus der Sicht eines Frameworks ist ein generisches HTTP-Request-Objekt die richtige Abstraktion, da es die Aufgabe des Frameworks ist, die eingehende HTTP-Anfrage zu repräsentieren.

Dies sollte uns aber nicht davon abhalten, das Richtige zu tun. Wir können das generische HTTP-Request-Objekt des Frameworks auf unser spezifisches Request-Objekt mappen. Wir brauchen nicht einmal einen separaten Mapper. Wir können einfach die generische Anfrage wrappen:

class   SomeRequest   implements   SomeRequestInterface
{
     private   HttpRequest   $request ;
 
     public   function   __construct ( HttpRequest   $request )
     {
         $this -> request   =   $request ;
     }
 
     public   function   getId ( ) :   string
     {
         return   $this -> request -> get ( 'id' ) ;
     }
 
     public   function   getAmount ( ) :   string
     {
         return   $this -> request -> get ( 'amount' ) ;
     }
 
     public   function   getPrice ( ) :   string
     {
         return   $this -> request -> get ( 'price' ) ;
     }
}

So schaffen wir es, dass die Objekte zusammen funktionieren:

class   SomeController
{
     private   SomeHandler   $handler ;
 
     public   function   __construct ( SomeHandler   $handler )
     {
         $this -> handler   =   $handler ;
     }
 
     public   function   execute ( HttpRequest   $request )
     {
         return   $this -> handler -> process (
             new   SomeRequest ( $request )
         ) ;
     }
}
 
class   SomeHandler
{
     public   function   process ( SomeRequest   $request )
     {
         // ...
     }
}

Selbst wenn SomeController eine Unterklasse einer vom Framework bereitgestellten Controller -Basisklasse ist, bleibt Euer eigener Code unabhängig von der HTTP-Abstraktion des Frameworks.

Ihr müsst natürlich die Informationen nach Bedarf mappen, spezifisch für jeden Controller. Euer Code benötigt bestimmte Header? Erstellt eine Methode, um nur diese zu erhalten. Euer Code benötigt eine hochgeladene Datei? Erstellt eine Methode, um genau diese abzurufen.

Eine vollständige HTTP-Anfrage kann Header, Werte, vielleicht hochgeladene Dateien, einen POST-Body usw. enthalten. Wenn Ihr für all das einen Test Stub oder ein Mock Objekt konfigurieren wollt, die Schnittstelle Euch aber nicht gehört, dann hält Euch das von der Arbeit ab. Das Definieren einer eigenen Schnittstelle macht die Dinge viel einfacher.

Update: Wir haben die Namensgebung im letzten Beispiel verbessert, nachdem wir per E-Mail und auf Twitter dazu Feedback erhalten hatten. Vielen Dank an alle, die sich bei uns gemeldet haben.