Softwaretests

Jedes gute Buch über Programmierung geht mehr oder weniger ausführlich darauf ein, dass Software immer Fehler hat. Genauer gesagt, dass jeder Programmierer, und sei er noch so erfahren, Fehler macht. Statistisch sei in jeder X-ten Zeile Code ein Fehler. Hat man den Fehler dann nach meist qualvollem Debugging gefunden und ihn behoben, habe man dabei, rein statistisch, schon wieder zwei neue Fehler eingebaut. Je eher man sich mit dieser Tatsache abfindet und eine gewisse Demut entwickelt, um so eher kommt man als Programmierender in seinen eigenen Fähigkeiten voran. Doch der Mensch ist erfinderisch und so lässt er sich allerlei Tricks und Kniffe einfallen, um seine Fehlbarkeit ein wenig erträglicher zu machen, oder wenigstens deren Wirkung zu minimieren. Einer solcher Tricks sind Softwaretests.

Softwaretest gibt es in verschiedener Ausprägung: Unittests, Integrationstest, Behaviourtests usw. Wikipedia kennt sicher noch ein paar mehr. Die Grenzen sind meist fließend und bei einigen Testarten drängt sich mir der Eindruck auf, es ginge um Beschäftigungsmaßnahmen der Chefetagen, Stichwort »Akzeptanztest«. Im Entwickleralltag sind vor allem die Tests interessant, die sich automatisieren lassen. Solchen Tests gemein ist ihr grundlegender Ablauf: ich definiere eine Erwartung an den Ausgang, lasse dann das Programm, oder ein Teil davon laufen und vergleiche die Ergebnisse des Programmdurchlaufs mit meinen Erwartungen. Stimmen diese überein ist der Test bestanden. Wenn es – gerade zu Beginn eines Projektes – häufiger passiert, dass Tests bestanden werden, sollte einen das umgehend skeptisch stimmen. Denn wie alles was man programmiert sind auch Tests nie fehlerfrei. Der einzige Vorteil ist, dass die Wahrscheinlichkeit, dass sich ein Fehler im Programm durch einen Fehler im Test aufhebt, geringer ist, als die, überhaupt Fehler zu machen. Bei steigender Lebensdauer des Projekts wachsen sich so die Fehler in den Test in der Regel zuverlässig raus.

Um die Fehleranfälligkeit der Tests selber zu reduzieren, sollten diese möglichst simpel gehalten werden. Weder das erzeugen des Testobjekts noch die anschließenden Assertions (der Vergleich des Ergebnis mit der Erwartung) sollten unnötig kompliziert ausgeführt werden.

Unittest

Als eine hilfreiche Art des Tests schon während der Entwicklung hat sich für mich der Unittest (dt. Modultest) erwiesen. Testgegenstand ist dabei die kleinstmögliche Einheit (Modul) eines Programms, also eine einzelne Funktion oder eine Methode. Ein guter Unittest zeichnet sich dadurch aus, dass der Ort des Fehlers zweifelsfrei auf das Modul eingegrenzt werden kann (oder eben auf den Test selber), sollte der Test fehl schlagen.

Hier ein sehr simples Beispiel eines Unittests für die Methode Calculator::add():

class Calculator {
	/**
	 * @param int $a
	 * @param int $b
	 * @return int
	 */
	function add( $a, $b ) {

		return (int) $a + (int) $b;
	}
}

function test_add() {

	$pairs = [
		[ 1, 1, 2 ],
		[ -4, -6, -10 ],
		[ 0, 0, 0 ],
		[ 'foo', 'bar', 0 ],
		[ '1', '2', 3 ]
	];

	$testee = new Calculator;

	foreach ( $pairs as $n => $p ) {
		$result = $testee->add( $p[ 0 ], $p[ 1 ] );
		if ( $p[ 2 ] === $result)
			echo "Test $n passed.\n";
		else
			echo "Failed test: {$p[0]} + {$p[1]} = $result. Expected: {$p[2]} \n";
	}
}
test_add();

Das zu testende Modul, die Methode add(), wird mit mehreren Parameterpaaren aufgerufen und mit einem Erwartungswert, der Summe aus beiden Parametern, verglichen. [1] Schlägt dieser Test fehl, so liegt der Fehler zweifellos entweder im Test selbst, oder aber in der getesteten Methode. Zugegeben, acht Zeilen Testcode für gerade mal einer Zeile Code des Testkandidaten wirkt etwas übertrieben. Was aber, wenn man die Implementierung von Calculator::add() ändern möchte, oder muss, zum Beispiel in etwas komplexeres:

class IncrementCalculator {
	/**
	 * @param int $a
	 * @param int $b
	 * @return int
	 */
	function add( $a, $b ) {

		$result = 0;
		foreach( [ $a, $b ] as $addend ) {
			$addend = (int) $addend;
			$increment = ( abs( $addend ) === $addend );
			for ( $i = 0; $i < abs( $addend ); $i++ ) {
				if ( $increment )
					$result++;
				else
					$result--;
			}
		}

		return $result;
	}
}

Mit einem vorher geschriebenen Unittest ist das Refactoring eine sehr entspannte Sache. Man kann die neue Implementierung, völlig unabhängig vom Rest des Systems auf Herz und Nieren prüfen und sicher [2] sein, dass das Modul sein Werk im System fehlerfrei verrichten wird.

Apropos unabhängig. Wie gut sich ein Modul testen lässt, entscheidet sich an den Abhängigkeiten, die das Modul zu anderen Modulen hat. Ein Beispiel:

class Calculator {

	public function add_abs( $a, $b ) {
	
		return Math::abs( $a ) + Math::abs( $b );
	}
}

class Math {

	public static function abs( $a ) {
	
		return abs( $a );
	}
}

Die Methode Calculator::add_abs() hat eine Abhängigkeit von der statischen Methode Math::abs(). Ein Unittest für Calculator::add_abs() könnte zwar auf einen Fehler hinweisen, der Fehler könnte aber in einer oder beiden Implementierungen stecken. Die Lösung des Problems liegt darin, aus der statischen Abhängigkeit eine Dynamische zu machen:

class Calculator {

	private $math;

	public function __construct( Math $math ) {

		$this->math;
	}

	public function add_abs( $a, $b ) {

		return $this->math->abs( $a ) + $this->math->abs( $b );
	}
}

class Math {

	public function abs( $a ) {

		return abs( $a );
	}
}

Im Unittest wird dann das Object der Math-Klasse durch eine Attrappe, ein sogenanntes Mock ersetzt. Testing-Frameworks wie PHPUnit bieten für das »Mocken« eine API an, mit deren Hilfe man Attrappen zur Laufzeit konfigurieren kann. D.h. ihr verhalten wird im Rahmen des Tests definiert und hängt nicht mehr von deren eigentlicher Implementierung ab. Das Konfigurieren solcher Mocks mit PHPUnit ist eine kleine Wissenschaft für sich, daher hier nur ein kurzes Beispiel um das Prinzip zu verdeutlichen:

class Calculator_Test extends PHPUnit_Framework_TestCase {


	public function test_add_abs() {
	
		$math_mock = $this->getMockBuilder( 'Math' )
			->getMock();
		
		$math_mock->expects( $this->any() )
			->method( 'abs' )
			->willReturnCallback( function( $a ) {
				if ( $a < 0 )
					return -1 * $a;
				
				return $a;
			} );
			
		$testee = new Calculator( $math_mock );
		
		// execute tests …
	}
}

Zur Erläuterung: Das Mock erwartet beliebig oft (expects( $this->any() )) die Methode abs() und wird bei deren Aufruf den Callback ausführen der den Absolutwert berechnet (willReturnCallback()). Beim Schreiben solcher Tests muss man bedenken, dass zu detailliert konfigurierte Mocks (anstatt $this->any() könnte auch $this->exaclty( 1 ) stehen) den Test selbst schnell an die Implementierung des zu testenden Moduls binden können (sogenannte Whitebox-Tests), was bei einem eventuellen Refactoring dazu führen kann, dass auch der Test angepasst werden muss.

Das Mocken möglichst aller Abhängigkeiten macht die Unittests zu einem hilfreichen Werkzeug während der Entwicklung weil der Fokus auf den kleinsten Einheiten des Programms liegt und deren Funktionalität unabhängig voneinander sicher gestellt werden kann. Damit ist aber auch gleich die Grenze von Unittests klar gezogen: sie sagen nichts darüber aus, ob zum Beispiel das eigene Plugin auch mit der neuesten Version von WordPress noch fehlerfrei funktionieren wird.

Integrationstests

Dafür braucht es Integrationstests. Praktisch unterscheiden sich Integrationstests nur wenig von Unittests. Am Beispiel eines WordPress-Plugins sind die wesentlichen Unterschiede die, dass die Abhängigkeiten zum Core (zum Beispiel zu WP_Post) nicht gemockt werden, dafür aber der Core Teil des »Systems under tests« (SUT) ist, will heißen, dass er zur Laufzeit des Tests geladen wird.

Solche Tests stellen gewisse Anforderungen an das Kernsystem (in Beispiel: WordPress), da Tests im Allgemeinen als CLI Skript laufen. Der Bootstrap-Prozess bei WordPress sieht entsprechend… kreativ aus. Ein gutes System sollte sich daher möglichst kontextunabhängig initialisieren lassen.

Integrationstests sind im Vergleich zu reinen Unittests häufig mit geringerem Aufwand verbunden, da das Mocking wegfällt. Zur Entwicklung und zum Debuggen eignen sie sich aber weniger gut, aufgrund der beschriebenen Unschärfe bei möglichen Fehlerquellen. Dafür können sie langfristig aber den manuellen Testaufwand deutlich reduzieren, wenn ein Projekt über einen längeren Zeitraum gepflegt werden soll.

  • [1] Es ratsam, den Prüfling nicht nur mit sinnvollen und erwartbaren, sonder möglichst vielen verschiedenen, auch unsinnig erscheinenden Typen zu füttern und das Verhalten damit zu prüfen.
  • [2] Mit an Sicherheit grenzender Wahrscheinlichkeit.

Kommentare

Es wurden noch keine Kommentarte zu diesem Artikel geschrieben.

Fragen, Ideen oder Kritik? – Hier ist Platz dafür!

Dein Kommentar

Um ein Kommentar abzugeben, reicht der Text im Kommentarfeld. Die Angabe eines Namens wäre nett, ist aber nicht erforderlich.

Du darfst folgenden HTML-Code verwenden, musst aber nicht:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>