Eine Einführung in Unit Testing mit PHP: Makelloser Code durch effizientes Testen
Die große Frage ist häufig: „Wozu brauchen wir denn Unit Tests, unsere Applikation läuft doch einwandfrei?“ Doch selbst wenn ein Programm fehlerfrei zu laufen scheint, können sich Fehler eingeschlichen haben, die auf den ersten Blick nicht ersichtlich sind. Teilweise funktionieren bestimmte Programmteile monatelang problemlos und erst durch umfangreiches Unit Testing, das alle Eventualitäten abdeckt, werden Fehler im Quellcode aufgedeckt.
Fehler aufzufinden und zu beheben ist das Ziel
Damit haben wir also bereits einen Punkt, der für Unit Testing spricht: Fehler können bereits auf der Ebene der Funktionen aufgespürt und behoben werden. Daneben gibt es aber noch mindestens einen weiteren Punkt, der für maschinelle Softwaretests spricht: Bei einfachen Funktionen ist manuelles Prüfen möglich und je nach verfügbaren Ressourcen auch praktikabel. Erreicht die Software allerdings eine bestimmte Komplexität, so sind manuelle Tests nur noch in beschränktem Rahmen möglich. Mit Unit Testing dagegen können leicht 500, 1.000 oder 10.000 Tests innerhalb von Minuten abgearbeitet werden.
Sebastian Bergmann hat mit PHPUnit [1] ein Framework entwickelt, das Unit Tests für PHP-Applikationen durchführt; gleichartige Module gibt es bereits für Java und andere Programmiersprachen. Mit PHPUnit können Entwickler auf einfache Weise eigene Tests für ihre Klassen und Funktionen schreiben und dann repetitiv durchlaufen lassen.
Innerhalb von PHPUnit existieren eine oder mehrere Testsuiten, die die einzelnen Test Cases beinhalten. Beim Aufruf von PHPUnit kann zunächst ausgewählt werden, welche Tests genau durchlaufen werden sollen.
Im Regelfall sind das alle Tests hintereinander – manchmal ist es allerdings für die effiziente Fehlersuche besser, sich auf den Durchlauf eines einzelnen Test Cases zu beschränken, bis dieser nicht mehr fehlschlägt.
Ein einfaches Beispiel zum Anfangen
static function addition($a, $b) { return $a + $b; }
Listing 1
Diese sehr übersichtliche Funktion wird über zwei Parameter gesteuert. Der Rückgabewert ist variabel, da er vom Wert der übergebenen Parameter abhängt. Ein simpler Test für diese Funktion könnte folgendermaßen aussehen:
public function testAddition() { $this->assertEquals( 18, addition(12, 6) ); }
Listing 2
Der Test besteht in diesem Fall aus einer einzelnen Überprüfung. Solange die Funktion addition() mit diesen Werten das richtige Resultat zurück gibt, ist der Test bestanden. Sollte ein Entwickler die Funktion addition() verändern, so schlägt der Test beim nächsten Durchlauf Alarm, falls das Resultat von dem erwarteten Wert abweicht.
Auf der grünen Wiese
Im Normalfall werden allerdings nicht einzelne Funktionen, sondern ganze Klassen mit jeweils mehreren Funktionen getestet. Je nach Aufbau der zu testenden Klasse muss diese sauber initialisiert oder gegebenenfalls instantiiert werden, damit die Funktionen sinnvoll getestet werden können.
Zudem sollte jeder Test unter gleichbleibenden, reproduzierbaren Rahmenbedingungen ablaufen. In PHPUnit gibt es zu diesem Zweck die so genannten Fixtures, die für jeden Test eine feste Testumgebung aufbauen. Die Fixtures werden durch die beiden Funktionen setUp() und tearDown() kontrolliert, die vor beziehungsweise nach jedem einzelnen Test durchlaufen werden. In diesen Funktionen wird das eigentliche Fixture je nach Bedarf initialisiert und wieder gelöscht. Zusätzlich wird jeder einzelne Test innerhalb einer neuen Instanz der Test-Runner-Klasse von PHPUnit ausgeführt.
class Article() { private $title; public function __construct($uid) { // Objekt wird abhängig von der übergebenen UID instantiiert } public function getTitle() { return $this->title; } }
Listing 3
Die oben skizzierte Klasse repräsentiert einen Artikel, der beispielsweise in einem Onlineshop vorkommen kann. Auf die private Variable $title kann ausschließlich über die öffentliche Funktion getTitle() zugegriffen werden. Die folgende Testsuite ist eine eigene Klasse, die sich von der Klasse TestCase aus PHPUnit ableitet. Dadurch stehen automatisch sämtliche Hilfsfunktionen von PHPUnit zur Verfügung.
public function setUp() { $this->uid = createDummyArticleInDatabase( array('title' => 'Test Title') ); $this->fixture = new Article($this->uid); } public function tearDown() { unset($this->fixture); $this->removeDummyArticleFromDatabase($this->uid); } public function testGetTitle() { $this->assertEquals( 'Test Title', $this->fixture->getTitle() ); }
Listing 4
In diesem Beispiel wird in der Funktion setUp() bei jedem Testdurchlauf ein Dummy-Datensatz in die Datenbank geschrieben. Damit sich in der Datenbank keine Datenleichen ansammeln, wird der erzeugte Dummy in der Funktion tearDown() wieder gelöscht.
In der Funktion testGetTitle() wird der eigentliche Test durchgeführt. In der Variable $this->fixture ist jetzt ein sauber instantiiertes Article-Objekt abgelegt, auf das ohne Umwege zugegriffen werden kann. Der große Vorteil der Fixtures wird allerdings erst ersichtlich, wenn die Testsuite eine Vielzahl von Tests beinhaltet. Es ist dann jedes Mal sichergestellt, dass einzelne Testläufe in der Variable $this->fixture auf ein frisch instantiiertes Objekt zugreifen und somit in einer klar definierten Umgebung testen.
Sebastian Bergmann schreibt in der PHPUnit-Dokumentation, dass auf die Funktion tearDown() auch verzichtet werden kann, solange keine externen Ressourcen involviert sind. In den meisten Fällen wird es aber so sein, dass beispielsweise Datenbanken in den Tests beteiligt sind.
Wir testen doch alles, oder?
Die Testfunktionen der vorhergehenden Beispiele überprüften bislang nur, ob die Funktionen das erwartete Ergebnis für einen Standardfall liefern. Bei komplexeren Funktionen gibt es dagegen noch weitere zu überprüfende Aspekte:
Aspekte | Grund |
Kontrolle der Funktionalität | Sicherstellen, dass alle Anforderungen erfüllt werden. |
Exceptions abfangen | Funktionen liefern, z. B. bei illegalen Parametern, Exceptions zurück. Fehlen diese Exceptions, handelt es sich um einen Fehler. |
Edge-Cases überprüfen | Was passiert, wenn zum Beispiel eine viel größere Zahl übergeben wird als im „Normalfall“? Verhält sich die Funktion genau so wie erwartet? |
Regression | Gefixte Bugs sollen durch Unit Tests abgedeckt werden. Sollte der Bug erneut auftreten, wird durch den fehlgeschlagenen Test automatisch darauf aufmerksam gemacht. |
Wenn ein Team beginnt, in seinen Projekten Unit Tests einzusetzen, wird anfangs sicherlich die Überprüfung der Anforderungen im Mittelpunkt stehen. Ist genug Zeit vorhanden, können auch noch die Exceptions/Edge Cases geprüft werden. Diese Tests bringen aber häufig sehr viel Aufwand mit sich.
Neben all den Vorteilen, die sauberer Code und die Tests mit sich bringen, gibt es auch einige negative Punkte: Neben dem enormen Aufwand zu Beginn ist mit jeder Änderung am Programmcode meist auch Arbeit an den Tests nötig. Zudem kann die ganze Testerei leicht süchtig machen, was zu noch mehr Tests führt. Die größte Gefahr lauert jedoch in den Tests selbst: Man sollte immer daran denken, dass die Tests von Menschen entwickelt wurden und deshalb ebenfalls Fehler enthalten können.
Und jetzt alles umgekehrt, bitte!
Bis jetzt wurde in allen Beispielen davon ausgegangen, dass zuerst der eigentliche Programmcode entwickelt wird, um daraufhin mit Unit Testing die einwandfreie Funktionalität abzusichern. Test Driven Development verfolgt einen umgekehrten Ansatz: Zuerst werden anhand von Spezifikationen die Tests für jedes zu entwickelnde Modul geschrieben. Anfangs werden erwartungsgemäß alle diese Tests fehlschlagen, da der Code noch gar nicht existiert.
Nach und nach wird dann der eigentliche Programmcode entwickelt und die Tests werden fehlerfrei durchlaufen. Ziel ist es, am Schluss alle Tests zu bestehen – denn dann funktioniert der Programmcode genau so, wie er laut den Spezifikationen soll. Ein großer Vorteil dieser Vorgehensweise ist die detailliertere Auseinandersetzung mit dem strukturellen Aufbau einer Applikation vor dem effektiven Entwicklungsprozess.
Wer häufig testet
Martin Fowler beschreibt auf seiner Website [2] die Vorgehensweise der Continuous Integration. Diese basiert auf dem Konzept, dass möglichst der gesamte Sourcecode durch Unit Tests abgedeckt sein sollte. Entwickler werden dazu angehalten, vor jeder Änderung alle Tests durchlaufen zu lassen. Zudem wird nach dem Einchecken einer Änderungen im Repository erneut ein kompletter Durchlauf aller Unit Tests gestartet. Durch das häufige Einchecken selbst kleinster Änderungen ist die Fehlersuche und Fehlerbehebung mit viel weniger Aufwand verbunden.
Fazit
Zusammenfassend kann man sagen, dass die Einführung von Unit Testing in einem Softwareprojekt einen nicht zu vernachlässigenden Aufwand bedeutet. Die kurzfristige Investition schlägt sich aber merklich in besseren Prozessen und saubererem Quellcode nieder und zahlt sich mittel- bis langfristig auf jeden Fall aus. Aus der Sicht eines Entwicklers ist der Einsatz von Unit Testing jedem Entwicklungsteam wärmstens zu empfehlen.
Sehr guter Artikel!
Ich habe auch einen Artikel über Unit Testing mit PHPUnit geschrieben, dort sind noch weiter Informationen zu diesem Thema vorhanden: http://webelutions.de/blog/unit-testing-mit-php/