Aus Linux-Magazin 07/2019

Testen in C++

© yelenayemchuk, 123RF

Wer guten C++-Code schreiben möchte, sollte über testgetriebenes Entwickeln nachdenken. Der Artikel veranschaulicht, was Unittests für C++-Code leisten, wie Entwickler diese anlegen und welche Werkzeuge den doch recht umständlichen Testprozess vereinfachen.

Beginnen Einsteiger zu programmieren, testen sie eigentlich immer nur manuell. Das bedeutet: Sie schreiben zum Beispiel ein kleines Programm mit Feature A und testen es auf der Kommandozeile mit ein paar Eingaben. Funktionieren diese Tests, erklären sie Feature A für fertig und beginnen mit Feature B und testen dies mit anderen Eingaben.

Das mag für ein kleines Programm reichen, scheitert aber umgehend, sobald die Realität etwas komplexer wird. So kann die Implementierung von Feature B auch Feature A beeinträchtigen, etwa weil beide eine gemeinsame Hilfsfunktion verwenden. Testet der Nachwuchsentwickler Feature A nicht erneut, bemerkt er den Fehler nicht.

Beim nächsten Mal will er schlauer sein. Er stellt Feature B einen Tag später fertig, testet aber auch Feature A noch einmal. Nun erinnert er sich aber nicht mehr daran, mit welchen Werten er Feature A gefüttert hatte, um alle Probleme zu adressieren. Also nimmt er andere Werte und übersieht den in Feature A neu eingeschleppten Fehler.

Gibt der Entwickler die Testwerte spontan ein, besteht zudem die Gefahr, dass er nicht alle sinnvollen Testwerte überblickt und daher nicht umfassend testet. Gerade Fehler- und Sonderfälle fallen so oft unter den Tisch.

Und ganz ehrlich: Wer gibt schon gern verschiedenste Testwert-Kombinationen ein, wenn das Programm doch scheinbar reibungslos funktioniert? Da programmieren Entwickler doch lieber am nächsten Feature – das macht mehr Spaß. Kurzum: Wer auf diese Weise vorgeht, testet nicht komplett. Zugleich sind umfassende Tests dieser Art sehr zeitaufwändig.

Steine im Weg

Neben diesen relativ einfach zu überblickenden Problemen beim Testen gibt es auch die wirklichen Herausforderungen des täglichen Entwicklerlebens. Hier gilt es, verschiedenen Stolpersteinen geschickt auszuweichen. Zum Beispiel muss ein Programmierer für viele, auch automatische Tests nicht selten spezielle Voraussetzungen erfüllen, bevor er sie ausführen kann.

Er benötigt bestimmte Dateien, einen laufenden Webserver, Zugriff auf eine Datenbank, das Internet mit Google-Maps-Anbindung oder Ähnliches. Diese Erfordernisse machen das Aufsetzen oder Reproduzieren eines Tests sehr kompliziert – weil etwa das Netzwerk streikt oder die Datenbank nicht die richtigen Daten anbietet.

Als besonders herausfordernd erweisen sich Tests, die außergewöhnliche Fehlersituationen nachstellen sollen. Konkret kann es etwa um zeitabhängige Berechnungen während eines Tageswechsels oder um eine Zeitumstellung von Sommer- auf Winterzeit gehen. Oder der Test erfordert es, dass in einem ganz bestimmten Augenblick die Netzwerkverbindung ausfällt. Dabei muss der Entwickler auch bei solchen Tests dokumentieren, was er beim erneuten Ausführen testen will und was er, entgegen der ursprünglichen Planung, doch nicht getestet hat.

Versionschaos

Programme sind in der realen Welt außerdem nicht nur in einer, sondern in verschiedenen Versionen im produktiven Einsatz. Zusätzlich finden sich in der Entwicklung oft mehrere Branches, die sich in ihrer Funktionalität unterscheiden. Stellt sich die Frage: Welche Tests gehören zu welchem Versionsstand? Existieren die Tests nur in den Köpfen der Entwickler oder gibt es Dokumente darüber? Und wie gut funktionieren die Tests mit den existierenden Versionen?

Übernimmt später ein anderer Entwickler das Programm, muss er sich zunächst einarbeiten. Dazu gehört es, zu verstehen, wie und was die ursprünglichen Entwickler des Programms testen wollten. Spätestens nach einem bis zwei Monaten wirkt zudem selbst der eigene Code fremd, was den Originalentwickler quasi zu einem neuen Entwickler macht. Zugleich müssen die Tests auch auf einer anderen Maschine, mit anderen Benutzerrechten, in einem anderen Umfeld problemlos laufen.

Verändern Programmierer Quellcode, aber nicht dessen Funktionalität, heißt das auf Neudeutsch Refactoring. Das soll die Struktur verbessern oder die Performance erhöhen. Eigentlich müssten Entwickler danach auch dieses Programm komplett neu testen. Und für die Entwicklung wäre es zudem schön, diese Tests nach jedem Schritt auszuführen. Außerdem wäre es hilfreich, würden die Tests schnell durchlaufen. Auf diese Weise bekäme der Umschreiber möglichst früh eine Rückmeldung, ob noch alles funktioniert. Doch je aufwändiger die Tests sind, desto seltener passiert dieser Schritt.

Viele Projekte benötigen zudem spezielle Tests wie beispielsweise Last-, Performance- oder Usability-Tests. Auch sollten Entwickler in der Lage sein, Tests zu schreiben, ohne dabei ihre Umgebung und, im Idealfall, die Sprache wechseln zu müssen. Das vereinfacht es für sie, die Tests zu schreiben.

Zu guter Letzt gibt es Projekte, bei denen der Kunde zu jeder Release eine ausführliche Dokumentation der getesteten Fälle inklusive der Ergebnisse erwartet. Der Autor selbst durfte an einigen davon mitarbeiten und tagelang Excel-Tabellen anlegen – das wünscht sich niemand.

Challenge accepted

Gute Tests sollten also einfach zu erzeugen sein und vollständig, was die Testfälle angeht. Sie müssen reproduzierbar sein, unabhängig von äußeren Bedingungen funktionieren, simpel aufzusetzen sein und sich einfach, automatisch und schnell ausführen lassen. Sie unterliegen einer Versionierung, ermöglichen es, spezielle Testsituation abzudecken, sollten intern und extern gut dokumentiert sein, den gesamten Code abdecken, aber auch spezielle Testaspekte wie zum Beispiel Lasttests berücksichtigen.

Einen großen Teil dieser Herausforderungen, aber nicht alle, meistern Unittests. Sie stoßen lediglich bei Systemtests, die überprüfen, wie alle beteiligten Softwarekomponenten eines Systems miteinander interagieren, an ihre Grenzen. Auch spezielle Last- oder Performance-Tests decken Unittests oft nicht ab. Das ändert nichts daran, dass sie ein sehr wirksames Werkzeug für die Entwicklung darstellen.

Was Unittests erreichen

Unittests setzen direkt am Code an. Es handelt sich um Tests, die ein Entwickler selbst programmiert – meist in der gleichen Sprache wie den eigentlichen Code. Zwingend ist dies allerdings nicht. Er testet jeweils eine kleine Einheit von Code. Dies darf eine einzelne Klasse sein, aber auch eine einzelne Funktion, eine Gruppe von Funktionen oder eine Gruppe von Klassen und die ihnen zugeordneten Funktionen.

In Sprachen mit starkem Klassenbewusstsein, etwa Java oder C#, deckt eine Unit eigentlich immer eine einzelne Klasse ab. In C++, das freie Funktionen kennt und offenere Strukturen erlaubt, testet eine Unit meist eine Gruppe von zusammengehörigen Funktionen oder eine Klasse mit zugehörigen Funktionen.

So sieht’s aus

Wie das in der Praxis aussieht, zeigt der Blick auf ein sehr simples Beispiel, etwa die Implementierung der Fakultätsfunktion mit selbst implementierten Tests (Listing 1). Wer das nachvollziehen möchte, muss den Code mit der Option »–std=c++17« kompilieren.

Listing 1

Beispiel eines simplen Unittest

01 #include "iostream"
02 using namespace std;
03
04 unsigned int fac(unsigned int v)
05 {
06         unsigned int res = 1;
07         for (unsigned int i = 2; i <= v; ++i)
08         {
09                 res *= i;
10         }
11         return res;
12 }
13
14 int main()
15 {
16    if (auto res = fac(0); res != 1)
17    {
18       cout << "Fehler: fac(0) ist nicht 1 sondern " << res << endl;
19       return 1;
20    }
21    if (auto res = fac(1); res != 1)
22    {
23       cout << "Fehler: fac(1) ist nicht 1 sondern " << res << endl;
24       return 1;
25    }
26    if (auto res = fac(2); res != 2)
27    {
28       cout << "Fehler: fac(2) ist nicht 2 sondern " << res << endl;
29       return 1;
30    }
31    if (auto res = fac(3); res != 6)
32    {
33       cout << "Fehler: fac(3) ist nicht 6 sondern " << res << endl;
34       return 1;
35    }
36    if (auto res = fac(12); res != 479'001'600)
37    {
38       cout << "Fehler: fac(12) ist nicht 479'001'600 sondern " << res << endl;
39       return 1;
40    }
41 }

Jeder routinierte Entwickler bemängelt sofort, dass dies zu viel Schreibarbeit erfordert. Hier ließe sich Code in eine Bibliothek auslagern. Das muss der Entwickler aber zum Glück nicht selbst erledigen – es gibt hervorragende C++-Unittest-Frameworks. Eine Liste in der englischsprachigen Wikipedia unter [1] zählt über 75 verschiedene C++-Unittest-Frameworks auf. Setzt der Entwickler zum Beispiel auf das Catch-Framework [2], vereinfacht dies den Beispielcode stark (Listing 2).

Abbildung 1: Catch meldet, dass alle Unit-Tests erfolgreich waren.

Abbildung 1: Catch meldet, dass alle Unit-Tests erfolgreich waren.

Eine Ausführung des Beispiels mit Catch liefern die Abbildungen 1 und 2. Während die erste den Erfolgsfall zeigt, meldet die zweite einen Fehlschlag. Letzterer entsteht, wenn der Entwickler in der Zeile 9 »res *= i;« fehlerhaft in »res += i;« verwandelt.

So helfen Unittests

Bevor der Artikel einen intensiveren Blick auf C++-Unittest-Frameworks wie Catch wirft, sollte er noch definieren, was Unittests ausmacht. Folgende Aspekte wurden bereits angesprochen:

  • Da der Entwickler seine Tests (meist) in der gleichen Sprache schreibt wie den normalen Code, kann er Unittests einfach integrieren.
  • Da sie als Code vorliegen, kann er sie gut lesen und schnell nachvollziehen, was sie testen. Er kann problemlos neue Testfälle ergänzen, etwa für Sondersituationen (leere Container, negative Zahlen und so weiter). Die Testfälle dienen also auch als interne Dokumentation darüber, was der Entwickler testet und welches Verhalten er dabei erwartet. In gewisser Weise dokumentieren die Tests damit sogar die Anforderungen an die Unit. Viele Entwickler betrachten das Schreiben von Unittests daher als eine Art Requirements Engineering auf unterster Ebene.
  • Führt der Entwickler die Tests aus, triggert dies stets sämtliche Tests.
  • Da die Tests in Codeform vorliegen, gibt es ein ausführbares Unittest-Programm. Das vereinfacht den Zugang und erlaubt es, die Tests stets automatisch auszuführen.
  • Als Code lassen sich die Tests wie normaler Code in die Versionsverwaltung einchecken. Sie unterliegen derselben Versionierung wie der Code, den sie prüfen. Alte Versionen oder Branches bereiten so keine Probleme.

Unabhängig testen

Extrem wichtig für Unittests ist, dass sie komplett unabhängig von äußeren Abhängigkeiten funktionieren. Daher dürfen sie zum Beispiel niemals eine Datenbank oder das Internet nutzen. Sie sollten nicht auf das Dateisystem zugreifen, nicht die Uhrzeit abfragen und keinen Zufallszahlengenerator benutzen. Aber auch auf Benutzer-Interaktionen – egal ob über die Konsole oder mit GUI – verzichten Unittests besser.

Listing 2

Beispiel mit Catch-Framework

01 #define CATCH_CONFIG_MAIN
02 #include "catch.hpp"
03
04 unsigned int fac(unsigned int v)
05 {
06         unsigned int res = 1;
07         for (unsigned int i = 2; i <= v; ++i)
08         {
09                 res *= i;
10         }
11         return res;
12 }
13
14 TEST_CASE("Berechnung der Fakultaet", "[fakultaet]")
15 {
16         REQUIRE(fac( 0) ==           1);
17         REQUIRE(fac( 1) ==           1);
18         REQUIRE(fac( 2) ==           2);
19         REQUIRE(fac( 3) ==           6);
20         REQUIRE(fac( 4) ==          24);
21         REQUIRE(fac( 5) ==         120);
22         REQUIRE(fac(10) ==   3'628'800);
23         REQUIRE(fac(12) == 479'001'600);
24 }

Alle diese Dinge würden dafür sorgen, dass sich die Tests nicht oder nur sehr schwer reproduzieren lassen, dass sie nicht einfach und automatisch zu nutzen sind, nicht performant ablaufen und nicht ohne Zutun auf einer beliebigen Maschine funktionieren.

Probleme machen hier also all jene Codepassagen, die auf die Umgebung zugreifen und von ihr abhängen, indem sie etwa eine Datenbank verwenden. Der Entwickler sollte daher den Zugriff aus dem zu testenden Code entfernen oder ihn testen, indem er das externe Element simuliert. Diese Simulationen heißen wahlweise Fakes, Stubs oder Mocks – je nach Qualität der Simulation (siehe Abschnitte “Fakes” weiter unten). Eine ganz einfache Simulation, die das Prinzip verdeutlichen soll, zeigt Listing 3.

Listing 3

Abhängigkeiten simulieren

01 #define CATCH_CONFIG_MAIN
02 #include "catch.hpp"
03 #include <string>
04
05 struct IDatabase
06 {
07    virtual std::string getName() const = 0;
08 };
09
10 struct DatabaseStub : IDatabase
11 {
12    std::string getName() const override
13    {
14       return "Albert Einstein";
15    }
16 };
17
18 struct BusinessLogic
19 {
20    IDatabase& db;
21
22    explicit BusinessLogic(IDatabase& db) : db(db) {}
23
24    std::string doit()
25    {
26       // Hier folgt einiger komplizierter Code, der getestet werden muss...
27       auto name = db.getName();
28       // Hier folgt einiger komplizierter Code, der getestet werden muss...
29       return "<" + name + ">";
30    }
31 };
32
33 TEST_CASE("Abfrage der Datenbank", "[db]")
34 {
35    DatabaseStub db;
36    BusinessLogic bl(db);
37    REQUIRE(bl.doit() == "<Albert Einstein>");
38 }

Der zu testende Code ist in diesem Fall die Klasse »BusinessLogic«, genauer die Element-Funktion »doit()«, die, wie der Kommentar andeutet, viel komplizierten Code enthält. Teile dieses Codes wollen auf eine Datenbank zugreifen.

Das passiert nicht direkt, sondern indirekt über das Interface »IDatabase«. Die eigentliche Datenbank-Implementierung tauscht der Konstruktor aus. Wo also der reale Code den Datenbanktreiber anspricht, verwendet der Testcode eine eigene Test-Implementierung »DatabaseStub« mit speziellen Testdaten (hier »Albert Einstein«).

Das Ganze über den Konstruktor abzuwickeln ist nur eine von mehreren Möglichkeiten, Stubs oder Mocks in den realen Code zu injizieren. Alle bringen Vor- und Nachteile mit. Hier geht es darum, das Prinzip zu zeigen. Wer sich etwa an der Performance-Verschlechterung durch den virtuellen Funktionsaufruf stört, könnte alternativ auf einen Template-Parameter ausweichen.

Es gibt mehrere Vorteile, wenn Unittests die komplette Umgebung simulieren. Zum Beispiel ist das Verhalten der Simulation genau definiert und bleibt konstant. Die Unittests sind reproduzierbar. In der Simulation lassen sich besondere Situationen nachstellen, etwa das Umstellen von Uhren. Die Simulation macht diese testbar und hilft, sie problemlos zu reproduzieren. Auch beliebige Fehler, etwa Exceptions oder Null-Pointer, simuliert der Tester auf diesem Weg.

Zugleich führt dank der Unabhängigkeit von jeglicher Umgebung jeder Entwickler die Unittests auf jedem Rechner problemlos aus. Das bedeutet auch, dass ein Buildrechner die Tests nach einem erfolgreichen Build automatisch direkt ausführen kann. Nicht zuletzt verlangsamen Datenbanken oder eine Internetanbindung einen Test in der Regel stark. Simulationen sind meist einfach gestrickt und daher sehr performant – die Unittests laufen also sehr schnell ab. Das steigert die Chance, dass Entwickler sie oft und gern laufen lassen.

Doch es gibt auch Wermutstropfen. Das Abkoppeln und Simulieren der Umgebung erweist sich als eine der Herausforderungen beim Schreiben von Unittests. Es lässt sich oft nicht trivial erledigen.

Zwischenstand

Offen bleibt noch das Thema externe Dokumentation. Aber wie der Beispielcode für Catch zeigt, ergänzt der Entwickler meist jeden Testfall um eine Beschreibung – daraus sollte sich auch automatisch ein Dokument für den Kunden generieren lassen. Manche Unittest-Frameworks bringen dafür schon entsprechende Generatoren mit.

Tabelle 1

Übersicht der Anforderungen an Unittests

Kriterium

Trifft zu

Einfaches Erstellen

ja

Vollständigkeit der Testfälle

ja

Reproduzierbarkeit

ja

Unabhängigkeit

ja

Einfaches Implementieren

teilweise

Einfache Ausführung

ja

Automatische Ausführung

ja

Schnelle Ausführung

ja

Versionierung

ja

Spezielle Situationen

ja

Interne Dokumentation

ja

Externe Dokumentation

ja

Systemtests

nein

Spezielle Testaspekte

teilweise

Insgesamt lässt sich sagen, dass Unittests viele der Anforderungen an Tests gut abdecken (siehe auch Tabelle 1).

Das Catch-Framework

Wie anfangs erwähnt, gibt es für C++ viele Unittest-Frameworks. Die wohl bekanntesten sind Boost.Test [3] als Bestandteil der Boost-Bibliothek [4] und Google Test [5]. Beide gibt es schon sehr lange – mit den entsprechenden Vor- und Nachteilen. Sie sind ausgereift und bringen sehr viele Features mit.

Gerade Boost.Test bietet für fast jeden Problemfall eine Lösung und hat sehr viele Steuerungsmöglichkeiten. Aber beide sind entstanden, als es noch kein C++11, 14 oder 17 gab – daher wirkt ihr API häufig etwas veraltet und ist nicht so einfach und elegant wie möglich.

Ein alternatives kleines und modernes Unittest-Framework für C++, das auch Behaviour Driven Development (BDD) unterstützt, ist Catch. Sehr angenehm ist, dass die Catch-Bibliothek sich einfach über »catch.hpp« inkludieren lässt. Mittlerweile liegt Version 2 vor. Arbeitet der Entwickler mit einer älteren Version als C++11, greift er auf die alte Version 1 von Catch zurück.

Jedes C++-Programm muss eine »main()«-Funktion mitbringen. Catch stellt sie zur Verfügung. Dafür muss der Entwickler in seinem Test-Quelltext vor der »#include«-Zeile für »catch.hpp« das Makro »CATCH_CONFIG_MAIN« definieren (Listing 3). Im Normalfall jongliert der Entwickler aber mit vielen Test-Quelltexten. Daher sollte er zur Übersicht einen speziellen »main()«-Quelltext anlegen, der nur die zwei Zeilen für die Hauptfunktion von Catch enthält.

Testfälle definiert Catch mit dem Makro »TEST_CASE()«. Dem übergibt er zwei Argumente. Das erste beschreibt den Testfall, wodurch sich der Entwickler auch den Kommentar erspart. Das zweite enthält in eckigen Klammern Tags, auf die er später beim Einsatz der Tests zurückgreift (siehe Listing 3, Zeile 33). So führt er nur bestimmte Testfälle aus, die ein entsprechendes Tag kennzeichnet. Das ist eine Hilfe, um Testzeiten zu minimieren.

Der Rest sieht wie eine gewöhnliche Funktion aus, die den Test beschreibt. Das Makro »REQUIRE()« in Zeile 37 enthält schließlich die eigentliche Bedingung für den Test:

REQUIRE(fac(4) == 24);

Sehr elegant erscheint hier, dass es nur dieses eine Testmakro »REQUIRE()« gibt, in das der Entwickler die komplette Bedingung einfach hineinschreibt. Dennoch zeigt Catch im Fehlerfall die einzelnen Werte in der Ausgabe an (Abbildung 2). Andere Unittest-Frameworks benötigen an dieser Stelle eine ganze Batterie verschiedener Makros. Zudem muss sich der Entwickler merken, welche Reihenfolge an Argumenten er einhalten muss.

Abbildung 2: Aber hier hat sich wohl ein Fehler eingeschlichen.

Abbildung 2: Aber hier hat sich wohl ein Fehler eingeschlichen.

Catch lässt sich dagegen viel einfacher und intuitiver nutzen. Der Trick: Im Hintergrund setzt Catch auf die Expression-Templates von C++.

Vorteil: Catch

Andere Frameworks bieten häufig komplizierte Mechanismen an, um Tests vor- und nachzubereiten. Dazu zählen etwa »setup()«- und »teardown()«-Funktionen, die den Testcode logisch und visuell auseinanderreißen. Catch verwendet lieber Sections, die der Programmierer einfach in die Testfunktion integriert. Und wer auf BDD setzt, findet in Catch eine alternative Teststrukturierung mit »SCENARIO()«, »GIVEN()«, »WHEN()« und »THEN()«.

Sicherlich bietet Catch nicht so viele Features wie etwa Boost.Test. Es lässt sich dafür einfacher erlernen und nutzen. Und es punktet mit einem sehr schlichten, aber eleganten API.

Auch wenn das alles recht einfach klingt: Unittests zu schreiben hält auch zwei besondere Herausforderungen bereit. Die erste besteht im testbaren Design, die zweite im damit verbundenen Aufwand.

Testbares Design

Zunächst zum ersten Fehler, den Entwickler im Umgang mit Unittests machen: Sie wollen erst programmieren, später dann die Tests nachliefern. Das funktioniert aber, wie auch bei anderen Architekturthemen wie Performance oder Multithreading, so gut wie nie. Und wenn doch, ist es mit sehr viel Aufwand verbunden. Wer nicht von Anfang an Unittests mitschreibt, entwickelt kein testbares Design.

Eine testbare Unit muss der Entwickler halbwegs isoliert benutzen können, sie muss über eine klare Schnittstelle verfügen. Schreibt der Entwickler von Anfang an Unittests, erzwingt er ein solches Design und fördert damit nebenher auch noch die Wiederverwendbarkeit und Modularität seines Codes. Erstreckt sich ein Feature oder Algorithmus über verschiedene Codepassagen oder gar über mehrere Ebenen vom Userinterface bis hin zur Persistence-Schicht, eignet er sich nicht für Unittests.

Ein weiteres Problem besteht darin, die äußeren Abhängigkeiten zu isolieren und zu simulieren. Zieht der Entwickler diesen Schritt nicht von vornherein mit in Betracht, lässt sich das hinterher kaum noch integrieren. Eine Möglichkeit hierfür demonstriert Listing 3. Es zeigt, wie der Entwickler ein Interface herauszieht und eine testspezifische abgeleitete Klasse implementiert. Dafür muss der Code ein Interface oder Ähnliches vorsehen und der Programmierer soll die konkrete Implementierung von außen setzen können. In Listing 3 erledigt das der Konstruktor.

Wichtig ist auch, die nicht testbaren Anteile (also die Abhängigkeiten von der Umwelt) möglichst gut zu isolieren und den entsprechenden Code möglichst klein zu halten. GUI-Code lässt sich in einem Unittest nicht überprüfen, da er eine Benutzerinteraktion erfordert. Im Idealfall sollte der Entwickler solchen Code nur noch deklarativ an den Rest des Programms anbinden.

All diese Dinge zusammen ergeben ein testbares Design. Ohne dies kann ein Entwickler Unittests kaum umsetzen. Es empfiehlt sich, Tests von Anfang an mitzudenken und mitzuschreiben.

Das funktioniert natürlich nur bei neu gestarteten Projekten. In den meisten Fällen existiert das Projekt aber bereits – dann leider oft ohne Unittests. Wer in dieser Situation trotzdem umsatteln möchte, dem sei das Buch “Effektives Arbeiten mit Legacy Code” von Michael C. Feathers empfohlen.

Der Aufwand

Die vielleicht größte Herausforderung ist aber der mit dem Schreiben von Unittests verbundene zeitliche Aufwand. Schon beim weiter oben beschriebenen einfachen Fakultätsbeispiel zeigt der Einsatz von Catch, dass der Unittest-Code mehr Platz einnimmt als die eigentliche Implementierung. Und das ändert sich häufig nicht. Tatsächlich hat der Autor in einigen Fällen mehr als zehnmal so viel Testcode geschrieben wie Code für die eigentliche Implementierung. Zugegeben hat er auch einen Hang dazu, es mit Tests zu übertreiben.

Doch selbst wenn es nur dieselbe Menge an Code ist, muss ein Programmierer ihn schreiben. Und er muss sich vorher Gedanken über normale Testfälle, Grenzfälle, Sonderfälle, Fehlerfälle und vieles mehr machen. Das ist eine Menge trockener Arbeit. Sicher spart der Entwickler hinterher Aufwand beim manuellen Testen, hat ständig alle Testfälle automatisch ausführbar und so weiter. Aber dennoch scheuen viele Entwickler vor Testcode zurück oder vergessen das Testen in der Zeitplanung.

Ein weiteres wichtiges Thema ist das Mocking. Um die passende Umgebung zu simulieren, benötigt der Programmierer Stellvertreter-Elemente in Form von Fakes, Stubs oder Mocks.

Fakes

Fakes sind Funktionen oder Klassen, um die benötigte Schnittstelle zu adressieren, damit der Code übersetzt. Sie verfügen selbst über keine weitere Funktionalität und geben nur Standardwerte zurück. Reine Fakes kommen eigentlich nur selten zum Zug – in der Praxis finden sich viel häufiger Stubs.

Stubs

Dabei handelt es sich um aktivere Stellvertreter-Elemente. Sie geben zum Beispiel spezielle Testwerte zurück, die sich auch mal von Aufruf zu Aufruf unterscheiden. Auf diese Weise ließe sich etwa ein Sensor simulieren, der sicher nicht immer Null zurückgibt. Oder sie reagieren zum Beispiel auf die übergebenen Parameter mit unterschiedlichen Rückgaben oder werfen zum Beispiel in manchen Situationen Exceptions. So ist etwa die Elementfunktion »getName()« im Listing 3 oben ein sehr einfacher Stub, da sie den testspezifischen Wert »Albert Einstein« zurückliefert.

Mocks

Schließlich gibt es noch Mocks. Die sind im Prinzip auch Stubs, verifizieren aber zusätzlich ihre Benutzung. Ruft also die zu testende Implementierung eine Datenbank mehrfach auf, obwohl ein Aufruf ausreichend wäre, würde der Entwickler dies bei einem Stub nicht bemerken. Ein Mock dagegen zählt die Anzahl der Aufrufe mit und validiert sie am Ende, wie es Abbildung 3 und Listing 4 zeigen. Das erlaubt auch indirekte Tests, die häufig ebenfalls wichtig sind.

Abbildung 3: Stubs sind Simulationen, Mocks testen aktiv ihre Nutzung mit.

Abbildung 3: Stubs sind Simulationen, Mocks testen aktiv ihre Nutzung mit.

Der Entwickler muss nur darauf achten, eine Implementierung nicht in den Mocks nachzubilden und so gar keine echten Tests mehr zu absolvieren. Es gibt auch fertige Frameworks, um Fakes, Stubs und Mocks zu schreiben. Zu den bekanntesten zählen Google Mock [6] und die Mocking-Elemente in Boost.Test. Listing 4 demonstriert den Einsatz von Google Mock.

Wie Zeile 6 schön zeigt, vereinfacht es Google Mock, eine Datenbanksimulation zu erzeugen. Die besonderen Features finden sich aber in der Zeile 14. Das genaue Verhalten des »MockedDatabase«-Objekts »m« richtet der Code erst im Testfall ein – die Klasse lässt sich also problemlos wiederverwenden. Zu den Konfigurationen gehört der Rückgabewert der Funktion »getName()«. Im Gegensatz zu Listing 3 liegen die Definition der Simulation und ihre Nutzung im Testfall im Quelltext räumlich eng beieinander. Zudem prüft hier das Mock-Objekt, ob der Code die Funktion »getName()« nur einmal aufruft. Im Destruktor führt der Code dann die Validierungen aus.

Listing 4

Mocking mit Google Mock

01 struct IDatabase
02 {
03    virtual std::string getName() const = 0;
04 };
05
06 struct MockedDatabase : Idatabase
07 {
08         MOCK_METHOD0(getName, std::string());
09 };
10
11 TEST_CASE("Abfrage der Datenbank", "[db]")
12 {
13         MockedInterface m;
14         EXPECT_CALL(m, getName()).WillOnce (Return("Albert Einstein"s)));
15
16    DatabaseStub db;
17    BusinessLogic bl(m);
18    REQUIRE(bl.doit() == "<Albert Einstein>");
19 }

Natürlich bieten Mock-Frameworks noch viel mehr Konfigurationen und Validierungs-Möglichkeiten, als das obige Beispiel zeigt. Der Artikel dient nur als eine Art Appetithäppchen.

Code Coverage

Eine weitere sehr hilfreiche Technik für Unittests besteht darin, ein Code-Coverage-Tool wie Gcov [7] zu verwenden. Mit einem Unittest- und einem Mock-Framework bewaffnet schreibt der Entwickler eine Menge guter Tests. Aber am Ende bleibt dennoch die Frage: Hat er wirklich alles getestet?

Ein Code-Coverage-Tool liefert Antwort darauf: Es zeigt, welche Codezeilen die Tests durchlaufen haben und welche nicht. Gute Coverage-Tools gehen noch weiter und zeigen, ob die Tests alle Kombinationen von booleschen Ausdrücken abdecken, ob sie Schleifen mindestens n Mal durchlaufen haben.

Natürlich beweist auch eine Code-Abdeckung von 100 Prozent nicht, dass Entwickler alles getestet haben – aber eine Abdeckung unter 100 Prozent versichert, dass dies nicht zutrifft. In der Praxis ist ein Wert über 90 Prozent sehr gut, 100 Prozent sind kaum zu erreichen.

Neben einem beruhigenden Gefühl haben die Code-Coverage-Tools auch einen lehrreichen Effekt. Immer wieder ist es dem Autor passiert, dass er sicher war, viele und gute Unittests geschrieben zu haben – um dann doch keine Coverage von 100 Prozent zu erreichen.

Die Analyse, warum er bestimmte Codeteile nicht oder nur teilweise getestet hatte, brachte ihm so manche Erkenntnis über den eigenen Code ein. Das führte zum Löschen von überflüssigem Code oder zu einem tieferen Verständnis für den verwendeten Algorithmus bis hin zum Finden von Fehlern.

Test-driven Development

Wer bis hierhin gelesen hat und mittlerweile überzeugt ist, dass Unittests durchaus Sinn ergeben, kann noch einen Schritt weiter gehen und Test-driven Development (TDD, [8]) ausprobieren. Die Idee ist ganz einfach: Der Entwickler zerlegt sein Problem in viele kleine. Er implementiert diese, indem er erst einen Testfall schreibt und ihn zum Laufen bringt, bevor er sich an den nächsten Schritt wagt (Abbildung 4). Zudem schiebt er vor jedem neuen Testfall immer wieder eine Refactoring-Phase ein – dank vorhandener Unittests eine wahre Freude.

Abbildung 4: Test-driven Development geht noch einen Schritt weiter und denkt den Code von den Tests her.

Abbildung 4: Test-driven Development geht noch einen Schritt weiter und denkt den Code von den Tests her.

Die Idee ist, dass sich der Code, angetrieben durch die Testfälle, Schritt für Schritt entwickelt. Da der Entwickler immer zuerst die Testfälle schreibt, erzwingt er ein testbares Design. Da er nur implementiert, was die Testfälle von ihm fordern, liegt die Testabdeckung stets bei 100 Prozent. Zugleich führt er stets alle Unittests aus, das Programm ist also immer zu 100 Prozent getestet. Hinzu kommt, dass er nach jeder erfolgreichen Implementierung ein Refactoring startet, was den Code vereinfacht und ordnet.

Natürlich gibt es auch Kritikpunkte an TDD. Geübte Programmierer stören sich oft an der Kleinschrittigkeit. Die verlangt für jeden Testfall einen kompletten Durchlauf, obwohl der Programmierer doch schon weiß, wohin die Reise geht. Vertreter von TDD sehen darin gerade einen Vorteil, weil diese evolutionäre Methode häufig zu effizienteren Algorithmen führe [9].

Neue Testfälle sorgen zudem mitunter dafür, dass große Teile der bisherigen Lösung nicht mehr greifen und Codeteile anzupassen sind. Kritiker monieren dann, dass die ersten Testfälle in die falsche Richtung führten. Die Entwickler hätten vorher erkennen müssen, dass diese Richtung keinen Sinn ergibt.

Positiv ist, dass dieses Schritt-für-Schritt-Vorgehen dabei hilft, Probleme zu lösen und Algorithmen zu entwickeln. Beim Programmierer wächst ein gutes Verständnis des Codes, es entstehen extrem nützliche Unittests, ein testbares Design ist vorhanden und der Code besitzt aufgrund des ständigen Refactoring eine hohe Binnenqualität.

Wer sich für TDD interessiert, kann im Internet ausprobieren, ob es tatsächlich eine Alternative zum bisherigen Programmieren ist. Es gibt fertige Webseiten für Code-Dojos (Code-Übungen), die häufig TDD unterstützen. Die Lieblings-Webseite des Autors heißt Cyber-Dojo [10]. Sie bietet für viele Programmiersprachen und Test-Frameworks vorgegebene oder eigene Aufgaben in TDD-Art an – für Einzelkämpfer, aber auch für Gruppen. Auch wer sich nicht für TDD begeistert, könnte Spaß an den Übungen in der eigenen Sprache haben.

Der Autor selbst führt gern solche Dojos in kleinen Gruppen von drei bis sechs Leuten durch. Nach dem Grillen etwa werden dann detailliert die einzelnen TDD-Schritte diskutiert. Solche Abende machen viel Spaß und jeder lernt dazu.

Fazit

Unittests sind hilfreich und decken viele Anforderungen an Tests ab. Sie sind kein Allheilmittel. Entwickler brauchen weiterhin andere Tests, etwa Systemtests. Dennoch: Für die tägliche Entwicklung und Refactorings erweisen sie sich als eine Hilfe – und sind dank guter Frameworks auch schnell geschrieben. Wichtig ist, sie von Anfang an zu nutzen und sie vielleicht sogar vor der eigentlichen Implementierung zu schreiben.

Erich Gamma, Ex-Chefentwickler der Eclipse-IDE und aktueller Chefentwickler von Microsoft Visual Code sowie Mitautor des bekannten Design-Pattern-Buches, sagte einmal: Er sei nur ein mittelmäßiger Entwickler, aber er habe gute Angewohnheiten.

Der Autor

Detlef Wilkening programmiert seit über 20 Jahren in C++ und hat in dieser Zeit in den verschiedensten Anwendungsbereichen sowohl Desktop- als auch Serversysteme programmiert. Nebenher unterrichtet er C++ und Java an der Fachhochschule Aachen und hält Vorträge.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben