Testbaren Code schreiben (Teil 2)

Im Post Testbaren Code schreiben haben wir in ein paar kleinen Schritten einen nicht testbaren Code so modifiziert, dass wir ihn testen können.

Als Basis benutzen wir das Projekt aus PHPUnit Tutorial in 5 Schritten. Der vollständige Quellcode zu diesem Tutorial findet sich auf github: PhpUnit LegacyCode Tutorial

In das Projekt fügen wir unseren Fake Legacy Code ein den wir als Basis benutzen und Schritt für Schritt, und Test für Test verbessern werden.

Dependency Injection und Inversion of Control

Um unseren Code testbar zu machen werden wir mit Dependency Injection (DI) arbeiten die dem Prinzip des Inversion of Control (IoC) folgt. Wir injizieren die Abhängigkeiten externer Komponenten die für die korrekte Funktion unserer Klasse notwendig sind über den Konstruktor.

Durch die Injizierung der Abhängigkeiten sorgen wir für eine Entkopplung der Abhängigkeiten, und sind somit in der Lage die einzelnen Komponenten selbst zu kontrollieren.

Schrittweises Refactoring

Im vorigen Post Testbaren Code schreiben habe ich ein paar Abkürzungen genommen und ein paar Kleinigkeiten weggelassen die wir jetzt implementieren werden.

Test Doubles und Mocks

Wir wollen für unsere Tests nicht die echten Objekte verwenden von der SomeClassabhängig ist, deshalb werden wir mit Objekten arbeiten die so tun als ob: Das sind dann unsere Test Doubles oder Mocks.

Es gibt unterschiedliche Methoden und Präferenzen um an das Ziel zu kommen. Wir werden aber Mockery verwenden um unsere Implementierung zu testen.

Legacy Code

In der Verzeichnisstruktur findet sich unser Legacy Code in src/Tutorial/LegacyCode

Die Testimplementierung findet sich unter src/Project

Die Testimplementierung wird im schrittweisen Umbau des Codes ebenfalls verändert und an die neuen Gegebenheiten angepasst. Die Ausführung der Testimplementierung ergibt folgendes Ergebnis:

$ php php src/Project/index.php

Status saved for Customer: 1 with status: active
Customer Status updated
active

Schritt 1

Im ersten Schritt werden wir new DatabaseLayer(); aus der Funktion getCustomerStatus in den Konstruktor verschieben und eine neue Instanzvariable für den DatabaseLayer.

An der Funktionsweise der Klasse hat sich nichts geändert, und alles funktioniert noch wie vor der Anpassung.

Schritt 2: Konstruktor Injizierung

Wir werden im zweiten Schritt dafür sorgen, dass alle Abhängigkeiten über den Konstruktor injiziert werden, um dann folgendes Ergebnis zu erhalten:

1 <?php
2 public function __construct(
3     ThirdPartyApi $api, Logger $logger, DatabaseLayer $db) {
4     $this->api = $api;
5     $this->logger = $logger;
6     $this->db = $db;
7 }

Wir benutzen hier das Type-Hinting von PHP um sicherzustellen, dass auch die richtigen Objekte an dieser Stelle übergeben werden. Wir verzichten erst einmal bewusst auf Type-Hintng mit Interfaces, da wir uns noch in einer frühen Phase befinden und unsere aktuelle Aufgabe das nicht vorsieht.

Wir werden einige Tests schreiben die im späteren Verlauf überflüssig werden und wieder entfernt werden, und die man mit mehr Erfahrung auch nicht zwingend benötigt.

Wir wollen jetzt testen ob unser Konstruktor korrekt funktioniert.

Der erste unvollständige Test (ohne dass die Klasse SomeClass modifiziert wurde) sieht so aus:

 1 <?php
 2     public function testConstructorInitialization()
 3     {
 4         $db = \Mockery::mock('DatabaseLayer');
 5         $logger = \Mockery::mock('Logger');
 6         $api = \Mockery::mock('ThirdPartyApi');
 7 
 8         $someClass = new \SomeClass($db, $logger, $api);
 9         // ?  
10     }

Wir führen jetzt den Test aus:

$ bin/phpunit -c tests/phpunit.xml --strict
…
There was 1 risky test:

1) Tutorial\LegacyCodeTest\SomeClassTest::testConstructorInitialization
This test did not perform any assertions

OK, but incomplete, skipped, or risky tests!
Tests: 1, Assertions: 0, Risky: 1.

Uns fehlt an dieser Stelle die Möglichkeit zu verifizieren, ob unsere Implementierung dem Test genügt. Wie weiter oben schon geschrieben werden wir einige Tests schreiben die später wieder entfernt werden können. Dasselbe gilt auch für ein paar Erweiterungen der Klasse SomeClass. Aber hier kann man sehr gut die Parallele zu Hardwaretests ziehen, wo man Messpunkte über temporär angebrachte Pins aus eienr Hardwarekomponente führt.

In unserem Fall werden wir SomeClass um die jeweiligen getter-Methoden erweitern.

Wir erwarten, dass wir dieselben Objekte von den gettern bekommen, die wir injiziert haben:

 1 <?php
 2     public function testConstructorInitialization()
 3     {
 4         $db = \Mockery::mock('DatabaseLayer');
 5         $logger = \Mockery::mock('Logger');
 6         $api = \Mockery::mock('ThirdPartyApi');
 7 
 8         $someClass = new \SomeClass($db, $logger, $api);
 9         $this->assertEquals($db, $someClass->getDb());
10         $this->assertEquals($logger, $someClass->getLogger());
11         $this->assertEquals($api, $someClass->getApi());  
12     }

Der Test schlägt schon bei der ersten Assertion fehl, weil weil das Db Objekt nicht existiert. Die anderen Assertions werden fehlschlagen weil die Objekte nicht identisch sind. Damit der Test sauber durchläuft passen wir den Konstruktor entsprechend an:

1 <?php    
2     public function __construct(
3     DatabaseLayer $db, Logger $logger, ThirdPartyApi $api) {
4         $this->db = $db;
5         $this->api = $api;
6         $this->logger = $logger;
7     }

Durch diesen einfachen Schritt haben wir schon eine Menge erreicht. Die Abhängigkeiten werden nun im Konstruktor injiziert, und wir können die Funktion getCustomerStatus fast schon testen.

Legacy Code anpassen

Wir müssen unsere Testimplementierung so anpassen, dass wir die Objekte jetzt vor der Instanziierung von SomeClass erzeugen und dann im Konstruktor mitgeben.

Schritt 3: getGustomerStatus Test

Durch die vorbereitenden Schritte können wir getCustomerStatus jetzt testen. Wir werden dafür die Abhängigkeiten mocken und schreiben unseren Test der so aussieht:

 1 <?php
 2     public function testGetCustomerStatusReturnsActive()
 3     {
 4         $db = \Mockery::mock('DatabaseLayer');
 5         $db->shouldReceive('saveCustomerStatus')
 6             ->once()
 7             ->with(1, "active");
 8 
 9         $logger = \Mockery::mock('Logger');
10         $logger->shouldReceive('log')
11             ->with("Customer Status updated");
12 
13         $api = \Mockery::mock('ThirdPartyApi');
14         $api->shouldReceive('getCustomerStatus')
15             ->once()
16             ->with(1)
17             ->andReturn("active");
18 
19 
20         $someClass = new \SomeClass($db, $logger, $api);
21         $status = $someClass->getCustomerStatus(1);
22         $this->assertEquals("active", $status);
23     }

Wir machen in diesem Test eine Menge. Vor allem validieren wir an dieser Stelle auch sehr viel internes Verhalten über die Mock-Objekte die wir erzeugen. Das ist ein kontroverses Thema, aber für unser Beispiel wird es benötigt und ich tendiere sehr häufig dazu die meisten Tests mit Mocks zu verwenden.

Mock Verhalten

Wir erwarten, dass unsere Mocks in bestimmter Art und Weise aufgerufen werden. Dass bestimmte Funktionen mit bestimmten Parametern aufgerufen werden, und bestimmte Ergebnisse geliefert werden. Diese Mocks werden dann in die zu testende Klasse injiziert und verrichten dort dann anstelle der richtigen Objekte ihre Arbeit.

Wenn wir unseren Test jetzt ausführen erhalten wir folgendes Ergebnis:

$ bin/phpunit -c tests/phpunit.xml --strict
…
..

Time: 41 ms, Memory: 4.50Mb

OK (2 tests, 4 assertions)

Fazit

Wir haben 3 Schritte benötigt um unseren Legacy Code testbar(er) zu machen. Die Abhängigkeiten können durch Mocks ersetzt werden und wir haben eine Funktion durch einen ersten Unit Test (nicht vollständig) abgedeckt.

Damit sollte die prinzipielle Vorgehensweise zur schrittweisen Verbesserung von nicht-testbarem Code transparenter geworden sein.

Wichtig ist es immer kleine Schritte zu machen, und nicht zu viel auf einmal ändern zu wollen. Mit mehr Erfahrung lassen sich immer ein paar Dinge zusammenfassen, aber übersichtlicher bleibt es in kleinen Schritten.