Entwicklungstests
Automatisierte Tests sind aus modernen Entwicklungsprozessen nicht mehr wegzudenken. Sie
tragen nicht nur dazu bei, die Qualität der Software sicherzustellen, sondern
ermöglichen es dem Entwickler auch, Fehler und Probleme frühzeitig zu entdecken und zu
beheben.
Hierbei sind verschiedene Arten von Tests zu unterscheiden. Als Beispiele seien
Unit-Tests zum Testen einer Komponente und Integration-Tests, die das Zusammenspiel
mehrerer Komponenten prüfen, genannt.
Für die meisten Programmiersprachen gibt es zudem Frameworks, die das Erstellen und
Ausführen bedeutend erleichtern, so auch für C++.
Frameworks gtest und gmock
Die Google Frameworks gtest und gmock wurden zunächst in separaten Projekten entwickelt,
inzwischen jedoch zusammengeführt. gtest ist ein Test-Framework für C++, welches das
Testen und Asserten von Anwendungen erleichtern soll. Beinhaltete Funktionalitäten sind
z.B. die Organisation der Tests in Test Cases und das Anlegen von Fixtures zur
Wiederverwendung. Mit gmock können dagegen Mock-Objekte erstellt werden, um z.B. zu
testen, ob eine Funktion mit den erwarteten Parametern aufgerufen werden kann. Im ersten
Teil des Beitrags, soll zunächst näher auf das Framework gtest eingegangen werden.
gtest und gmock im Vergleich mit anderen C++ Unit Testing Frameworks
Die Vielzahl von C++ Unit Testing Frameworks am Markt erschwert die richtige Wahl
erheblich. Daher ist es sinnvoll, die Frameworks im Vorfeld unter verschiedenen
Gesichtspunkten miteinander zu vergleichen. Bereits 2004 wurde einige dieser Frameworks
wie CppUnit oder Boost.Test gegenübergestellt. Auch wenn gtest zum damaligen Zeitpunkt
noch nicht existiert hat, ist es doch interessant, einen Blick auf die verwendeten
Metriken zu werfen:
- Wie viel Aufwand ist nötig, um einen einfachen Test zu schreiben?
- Ist eine Organisation der Tests möglich?
- Wird das Framework vom Herausgeber noch unterstützt und weiterentwickelt?
- Ist eine gute Auswahl von Assertions vorhanden?
- Wie groß ist der Aufwand, das Framework in ein bestehendes Projekt zu integrieren?
- Werden verschiedene Ausgaben zur Weiterverarbeitung der Testergebnisse angeboten?
Diese Fragen sollen nun im Verlauf dieses Beitrags beantwortet werden. Bereits vorab ist
zu sagen, dass gtest alle genannten Anforderungen erfüllt. Trotz allem ist die Wahl des
Frameworks immer abhängig davon, in welchem Projekt es zum Einsatz kommen soll und muss
individuell festgelegt werden.
gtest
Dieser Beitrag beschäftigt sich zwar mit gtest, jedoch sind alle allgemeinen Themen
auch für gmock gültig, da die Funktionalität in einer Bibliothek zusammengeführt
wurde.
Zur Beschreibung muss eine eindeutige Nomenklatur festgelegt werden. gtest verwendet für
einzelne Tests die Bezeichnung TEST() bzw. Test, die Gruppierung von Tests für eine
Komponente wird Test Case genannt. Ein Begriff der im weiteren Verlauf häufiger
auftauchen wird, ist der des Test Fixtures. Dieser bezeichnet eine Klasse, die
Funktionen und Daten enthält, welche in mehreren Tests wiederverwendet werden.
Setup
Das Framework funktioniert auf einer Vielzahl von Plattformen sowie mit verschiedenen
Compilern. Zu den unterstützten Systemen zählen u.a. Windows, Mac OS X und Linux. Es ist
möglich, gtest als statische und dynamische Library zu bauen und zu linken. Die
Verwendung von cmake vereinfacht die Integration. So lässt sich gtest als Stand-Alone
bauen oder auch in andere Projekte integrieren.
add_subdirectory (${ CMAKE_CURRENT_BINARY_DIR }/ googletest -src ${
CMAKE_CURRENT_BINARY_DIR }/ googletest - build EXCLUDE_FROM_ALL )
Vor der eigentlichen Implementierung, wie oben dargestellt, muss der Download von GitHub
erfolgen. Hierbei besteht die Möglichkeit, den Code direkt zu integrieren oder immer als
Dependency automatisiert zu laden.
Allgemeine Struktur
Prinzipiell ist es sinnvoll, Tests ähnlich der Projektstruktur aufzubauen. Das macht es
für andere Entwickler leichter, Tests für eine bestimmte Klasse zu finden. Auch kann so
schneller herausgefunden werden, ob für eine Klasse Tests vorliegen. Tests, welche
dieselben Ressourcen
benötigen, sollten in einer Datei geschrieben werden. Dies macht es sehr einfach, die
Fixtures einzusetzen und die Ressourcen nur an einer Stelle vorzubereiten.
Testbeispiele
Ein einfacher Test wird in einem Makro geschrieben:
TEST ( TestCaseName , TestName ) {
... test body ...
}
Der TestCaseName sowie der TestName sind frei wählbar. Die Namen der Tests dürfen sich
auch in verschiedenen TestCases wiederholen, nur innerhalb eines TestCases muss der
TestName eindeutig sein.
Fixture Tests dienen dazu, in verschiedenen Testfällen auf gleiche Ressourcen zugreifen
zu können. Das kann ein Dummy Objekt sein, welches mit Testdaten gefüllt wird, es kann
sich aber auch um eine komplexe Initialisierung handeln. Um diese Funktionalität
verwenden zu können, muss zuvor eine Klasse angelegt werden, welche dann als Fixture
dient.
(1) Test Fixture MyFixture.h
# include <gtest / gtest .h>
class MyFixture : public testing :: Test {
protected :
void SetUp () override ;
bool myFunction ();
};
(2) Test Fixture MyFixture.cpp
void BaseTest :: SetUp () {
Test :: SetUp ();
... my additional setup ...
}
TEST_F ( MyFixture , TestName ) {
bool myBool = myFunction ();
... my aditional testing ...
}
Im Header hat die Funktion „myFunction“ die Sichtbarkeit „protected“. Alle Funktionen und
Eigenschaften, die im Testmakro aufgerufen werden, müssen „protected“ oder „public“
sichtbar sein. Die SetUp Funktion kann verwendet werden, um komplexe Initialisierungen
durchzuführen
und wird vor jedem Test ausgeführt. Analog kann auch eine „TearDown“ implementiert
werden. Das Makro in (2) hat sich im Vergleich zu (1) leicht verändert. Aus TEST ist
TEST_F
geworden. Das F steht dabei für Fixture. Der Name der Fixture kann frei gewählt werden,
er muss nur in der Fixture Klasse und im Makro identisch sein.
Überprüfungen (Assertions)
Assertions zählen zu den wichtigsten Funktionen jedes Test-Framewors. gtest stellt eine
Anzahl verschiedenster Überprüfungen zur Verfügung mit deren Hilfe Werte oder Objekte
verglichen werden können.
Assertions gibt es als fatalen oder nicht fatalen Check. Bei einer fatalen Überprüfung
wird der aktuell ausgeführte Test abgebrochen, falls der Vergleich als falsch bewertet
wird. Ein nicht fataler Check lässt den Test zu Ende laufen, markiert ihn jedoch
anschließend als fehlgeschlagen.
Fatale Vergleiche beginnen mit „ASSERT_“, nicht fatale mit „EXPECT_“. Wenn möglich,
sollten immer nicht fatale Überprüfungen erfolgen.
In den folgenden Beispielen ist die Verwendung einiger Überprüfungen dargestellt. Es
handelt sich jedoch nur um einen kleinen Ausschnitt, der von Google bereitgestellten
Funktion.
(1) Verwendung nicht fataler Überprüfungen
EXPECT_TRUE ( MyClass :: myBoolFunc ());
std :: string expectedResult (" this result ");
EXPECT_STREQ ( expectedResult . c_str () , MyClass :: myStringFunc (). c_str ());
EXPECT_EQ (42 , MyClass :: myIntFunc ());
EXPECT_GT (0, MyClass :: myLongFunc ());
Die verschiedenen Makros in (1) testen mehrere Bedingungen. Das Makro mit
_STREQ überprüft zwei Strings auf ihre Gleichheit, über _EQ wird die Gleichheit von
Zahlen abgeglichen und _GT überprüft, ob die erste Zahl größer als die zweite ist.
(2) Verwendung fataler und nicht fataler Überprüfungen
User * user = MyClass :: getMyObject ();
ASSERT_NE ( nullptr , user );
EXPECT_EQ (25 , user ->age);
EXPECT_EQ (female , user -> gender );
In (2) wird die Existenz des Objektes mit ASSERT_NE geprüft. Natürlich ist
es nicht sinnvoll, den Test weiterlaufen zu lassen, wenn das Objekt nicht existiert. Die
Überprüfung des Alters erfolgt hingegen über EXPECT_EQ, da hier auch weitere
Eigenschaften geprüft werden können, auch wenn das Alter nicht korrekt ist.
Im Folgenden soll nicht immer auf beide Varianten eingegangen werden. Wenn also die Rede
von Assert oder Expect ist, sind, wenn nicht explizit erwähnt, immer beide Varianten
gemeint.
Es ist möglich, einer Überprüfung eine individuelle Ausgabe zu übergeben. Diese wird im
Fall eines Fehschlages ebenfalls im Log ausgegeben und macht es leichter, die Ursache zu
identifizieren. Dazu wird die Nachricht mit Hilfe des Stream Operators « an die
Assertion angehangen.
TEST_F ( MyTestFixture , checkitemCount ) {
int itemCount = _sut -> getItemCount ();
EXPECT_EQ (0, itemCount ) << " Item count was not the expected size .";
}
Death Tests
Mit Death Tests kann geprüft werden, ob das Programm bei einer bestimmten Ausführung
abstürzt. Warum aber sollte man dies testen wollen? In C++ ist es üblich, Vorbedingungen
durch Checks mit abort() abzusichern. Insbesondere innerhalb der st1 oder anderer
Bibliotheken wird dieser Art der Sicherung verwendet.
Um Death Tests zu verwenden, muss innerhalb des Tests der Tod bzw. Absturz des Programms
erwartet werden:
EXPECT_DEATH (_sut -> myDeadlyFunction () , ".* this did happen .*")
Bei der Verwendung des Makros kommen zwei Parameter zum Einsatz. Im ersten Parameter muss
der Aufruf definiert werden, der das Programm zum Abstürzen bringen soll. Das zweite
Argument ist eine Regex, die du zu erwartende Fehlermeldung beschreibt. Der Test schlägt
so unter zwei Bedingungen fehl: Zum einen wenn das Programm beim Aufruf von
myDeadlyFunction nicht abstürzt, zum anderen wenn die Fehlermeldung nicht die übergebene
Regex erfüllt.
Warum ist die Fehlermeldung entscheidend? Wenn die Software nicht mit der erwarteten
Meldung terminiert ist, wurde nicht die Logik angesprochen, die eigentlich getestet
werden sollte. Eine Bedingung war also nicht erfüllt oder ein Systemfehler ist vorher
bzw. nachher aufgetreten. Somit ist unklar, ob die zu testende Bedingung erfüllt ist.
Die Regex ermöglicht es, das Programm exakt an der zu erwartenden Stelle zu
terminieren.
Exception Tests
Im Grund handelt es sich hierbei um eine Gruppe von Asserts und nicht um eine eigene
Testart. Da Exceptions aber auch für C++ immer mehr an Bedeutung gewinnen, sei darauf
hingewiesen, dass auch bei diesen eine Überprüfung mit gtest möglich ist. Die Assertions
gibt es ebenfalls als fatale und nicht fatale Variante.
(1) Funktion mit Exception
EXPECT_THROW (_sut -> myThrowingFunc () , std :: exception );
ASSERT_THROW (_sut -> myThrowingFunc () , std :: exception );
(2) Funktion ohne Exception
EXPECT_NO_THROW (_sut -> myNotThrowingFunc ());
ASSERT_NO_THROW (_sut -> myNotThrowingFunc ());
Floating Point Vergleiche
Aufgrund interner Darstellungen und Rundungsproblemen bei Gleitkommazahlen ist ein
Vergleich dieser meist schwierig, denn zwei Gleitkommazahlen werden fast nie den exakt
selben Wert besitzen. Daher wird der Vergleich mit ASSERT_EQ fehlschlagen. Zur Lösung
dieses Problems gibt ein spezifisches Makro zum Vergleichen von Gleitkommazahlen. Diese
gleichen auf vier Units in the Last Place (ULP) ab. Es sind spezifische Versionen für
float und double verfügbar:
ASSERT_FLOAT_EQ (0.0f, _sut -> myFloat ());
ASSERT_DOUBLE_EQ (0.0 , _sut -> myDouble ());
EXPECT_FLOAT_EQ (0.0f, _sut -> myFloat ());
EXPECT_DOUBLE_EQ (0.0 , _sut -> myDouble ());
Assertion mit Matchern
gmock bringt eigene Matcher zu gtest. Da es möglich ist, eigene Matcher zu schreiben,
können auch individuelle Vergleiche erstellt werden. Es können also komplexe Vergleiche
in Matchern ausgelagert und mit ordentlichen Fehlermeldungen versehen werden. Auch eine
Mehrfachverwendung der Matcher ist denkbar.
Will man zum Beispiel den Inhalt
eines Vektors von Pointern vergleichen, gibt es dafür keinen Standardvergleich. Der
Nutzer muss also manuell den Vektor iterieren und den Inhalt vergleichen. Hat man
mehrere Tests, die diese Vektoren vergleichen, entsteht schnell viel doppelter Code.
Wird der Code in Funktionen ausgelagert, so erhält man unübersichtliche Tests. Mittels
Matchern lässt sich eine mit gtest funktionierende Vergleichsoperation für die Vektoren
erstellen. Ein einfaches Beispiel hierfür sieht wie folgt aus:
MATCHER_P ( vectorContentEq , expectedVector , " Expected vector content does
not match .") {
bool result = (arg. size () == expectedVector . size ());
for ( int i = 0; i < std :: min(arg . size () , expectedVector . size ()); ++i) {
auto firstItem = arg.at(i);
auto secondItem = expectedVector .at(i);
if( firstItem != secondItem ){
result = false ;
break ;
}
}
return result ;
}
MATCHER_P definiert einen neuen Matcher mit dem Namen vectorContentEq. Das P steht für
einen Matcher, der einen Paramater erwartet. In diesem Fall ist der Parameter
expectedVector. Es werden die Länge der Vektoren und der Inhalte der Elemente
verglichen.
Es sind zudem mehr Matcher als Assertion Makros vorhanden. Es gibt also Situationen, in
denen das Testen mit den von Google bereitgestellten Matchern sinnvoller ist als mit
Makros. So können manche Matcher etwa Teile von Strings vergleichen:
EXPECT_THAT ( myString , StartsWith ("My string Starts with "));
Zur Verwendung von Matchern wird das Makro EXPECT_THAT verwendet und im zweiten Argument
der Matcher übergeben. Matcher sind jedoch keine einzigartige Implementierung von gmock.
Ein ähnliches Konzept kann in neueren jUnit Versionen gefunden werden. Auch dort wird
der Ausdruck Matcher verwendet.
Parametrisierte Tests
Funktionen mit Eingabeparameter zu testen, gehört zu den alltäglichen Aufgaben eines
Entwicklers. Soll sich die Funktion bei verschiedenen Parametern gleich verhalten, so
muss derselbe Test mehrfach geschrieben werden. Bei gtest gibt übergibt man einem Test
Parameter und legt anschließend fest, mit welchen Werten in dem Parameter die Tests
ausgeführt werden sollen.
Definition eines parametrisierten TestCases
TEST_P ( MyParamTest , TestName ) {
std :: string myParam = GetParam ();
... normal testing with usage of myParam ...
}
Es sind einige kleine Unterschiede zu erkennen. Das Makro endet auf P für parameterised
Test. Bei der Implementierung wurde der Aufruf GetParam() verwendet, um auf den
Parameter zugreifen zu können. Der Parameter wird aus Gründen der Lesbarkeit einer
lokalen Variable zugewiesen. Diese Vorgehensweise ist jedoch nicht zwingend
erforderlich. Wenn die Testklasse nun ausgeführt wird, ist kein Ergebnis zu erwarten.
Grund dafür ist, dass zunächst noch definiert werden muss, mit welchen Werten die Tests
ausgeführt werden sollen. Mehrere Tests können dabei problemlos mit denselben Parametern
ausgeführt werden.
Definition der parametrisierten Werte
INSTANTIATE_TEST_CASE_P ( myParamTestInstance , MyParamTest , :: testing ::
Values (" value1 ", " value2 ", " value3 "));
Nun werden alle Tests im Test Case „MyParamTest“ dreimal ausgeführt, einmal mit jedem
Parameter.
Platzierung von Assertions
Im Gegensatz zu anderen Frameworks können Assertions bei gtest in nahezu beliebige
Funktionen geschrieben werden. Es müssen hierfür lediglich zwei Bedingungen erfüllt
sein. Zum einen muss der Include-Befehl von gtest enthalten sein, zum anderen müssen
fatale Assertions gesondert behandelt werden. Dies betrifft also alle mit „ASSERT_“
beginnenden sowie das direkte Makro „Fail“, die nur in Funktionen mit dem Rückgabetyp
„void“ eingesetzt werden können.
Assertions in Funktionen
Es ist gute Praxis, sich wiederholende Blöcke von Assertions in Hilfsklassen auszulagern.
Ein Beispiel hierfür ist etwa ein User Objekt mit mehreren Eigenschaften. Möchte man
dieses überprüfen, so stehen eine ganze Reihe von Assertions zur Verfügung. Es liegt auf
der Hand, diesen Block in eine Hilfsklasse bzw. Hilfsfunktion auszulagern. Jedoch ergibt
sich hierdurch das Problem, dass die eventuelle Ausgabe einer fehlgeschlagenen Assertion
nicht mehr sonderlich aussagekräftig ist. Denn es ist nicht mehr auf den ersten Blick
ersichtlich, aus welchem Test heraus die Assertion fehlgeschlagen ist.
gtest hat für diesen Fall das Makro „SCOPED_TRACE“ eingeführt, das dem log im aktuellen
Kontext eine Nachricht mit Datei und Zeilennummer anfügt. Wird der aktuelle Scope
verlassen, wird auch die Nachricht entfernt. Für das Beispiel von eben sieht das wie
folgt aus:
TEST_F ( MyTestFixture , testUserWithSomething ) {
if( something ()){
SCOPED_TRACE (" Something ");
User * user = sut -> getUser ();
testUser ( user );
}
User * user = sut -> getUser ();
testUser ( user );
}
Ohne die „SCOPED_TRACE“ Makros wäre es bei Assertion Fehlern in der „testUser“ Funkion
sehr schwer herauszufinden, welcher der beiden Fälle zu dem Failure geführt hat. Mit dem
Makro jedoch würde bei einem Fehlschlag im „if“ ein Log dieser Art angezeigt:
path / MyTestFixture .cpp :198: Something
Schlägt der Test jedoch erst bei dem zweiten Anruf fehl, so ist die Nachricht im Log
nicht vorhanden.
Testausgabe verändern
Die Standardausgabe von gtest sieht wie folgt aus:
... a lot mor tests and logs ...
[ OK ] MyTestCase . myTest (4 ms)
[----------] 4 tests from DeviceOnboardingManagerTest (18 ms total )
[----------] Global test environment tear - down
[==========] 452 tests from 40 test cases ran. (16823 ms total )
[ PASSED ] 452 tests .
Problematisch ist hierbei jedoch die Tatsache, dass die gtest Ausgaben mit den
eigentlichen Ausgaben auf „std::count“ vermischt werden können. Es ist jedoch möglich,
diese Ausgaben zu verändern. Folgende Anpassungen können vorgenommen werden:
- –gtest_color
Farbige Ausgabe zur besseren Unterscheidung der gtest Ausgaben von
sonstigen Logs aktivieren
- –gtest_print_time=0
Laufzeitausgabe des Tests deaktivieren
- –gtest_print_utf8=0
Ausgabe der String-Werte in UTF-8 deaktivieren
- -gtest_output
Ausgabe der Testergebnisse in den maschinenlesbaren Formaten XML
oder JSON aktivieren
An dieser Stelle sei außerdem erwähnt, dass gtest von integrierten Entwicklungsumgebungen
unterstützt wird. So ist es beispielsweise möglich, nur einzelne Tests oder Test Cases
auszuführen. In der Entwicklungsumgebung CLion sieht das wie folgt aus:
Abbildung 1: gtest in CLion
Einschränkungen
gtest gilt auf Systemen, die „pthreads“ zur Verfügung stellen, als Threadsafe. Das
Attribut gibt Auskunft darüber, ob es sicher ist, Programmcode von verschiedenen Threads
aufzurufen. Auf anderen Systemen wie etwa Windows ist es derzeit jedoch nicht sicher,
Assertions von zwei Threads auszuführen. Für die meisten Applikationen ist dies jedoch
nicht von Bedeutung.
Im zweiten Teil der Blogreihe stellen wir das Mock Framework gmock vor und geben unsere
Einschätzung zum automatisierten Testen mit den beiden vorgestellten Frameworks ab. Stay
tuned!