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.