Unit-Tests unter C++ mit dem Framework CppUnit

Neben Unit-Tests mit JUnit, dem bekanntesten und wohl auch meistgenutzten Vertreter unter den Frameworks für Unit-Tests, gibt es Implementierungen für die verschiedensten Programmiersprachen. CppUnit ist ein solches Framework zur Programmierung von Software-Tests nach dem Prinzip der Unit-Tests für die Programmiersprache C++. Es handelt sich dabei um eine Portierung des Vorbildes JUnit nach C++, die auf einer ganzen Reihe von Unix-Derivaten, sowie unter Windows läuft. Dabei werden von diesem Framework, neben verschiedenen Unix Compilern wie dem GNU C++-Compiler, auch Visual C++ ab Version 6.0 und Borland C++ unterstützt. Sie benötigen also, neben dem eigentlichen Framework, lediglich einen der genannten Compiler und ein Unix/Linux- oder Windows-System. Nähere und eher allgemeine Informationen zum Thema Unit-Tests finden Sie auf der Seite Unit-Tests. Der Umgang mit dem Framework CppUnit wird in den folgenden Abschnitten in diesem Tutorium vermittelt.

Unit-Tests am Beispiel der Klasse Bruch

Im Folgenden möchte ich am Beispiel der Klasse Bruch, die einige grundlegende Operationen zum Umgang mit Brüchen implementiert, einen einfachen Einstieg vermitteln. Gegeben sei also die Klasse Bruch, die in der Lage ist Brüche zu kürzen, zu addieren und zu subtrahieren. Darüber hinaus enthält sie, neben einem Konstruktor und dem Zuweisungsoperator, einige Vergleichsoperatoren. Die Funktionen ggt (Berechnung des größten gemeinsamen Teilers) und kgv (Berechnung des kleinsten gemeinsamen Vielfachen) sind lediglich Hilfsfunktionen und gehören eigentlich nicht hierher. Der anschließende Code-Block enthält eine mögliche Definition der Klasse Bruch.

// CppUnit-Tutorial
// Datei: Bruch.h
#ifndef BRUCH_H
#define BRUCH_H

#include <iostream>

using namespace std;

// einige Hilfsmethoden (gehoeren eigentlich nicht hierher)
// Ermittelt den groessten gemeinsamen Teiler (zum kuerzen)
unsigned int ggt (unsigned int, unsigned int);
// Ermittelt das kleinste gemeinsame Vielfache (zum erweitern)
unsigned int kgv (unsigned int, unsigned int);

// Definition einer Exceptionklasse
class DivisionDurchNullException
{
};

// Einfache Definition einer Bruch-Klasse
class Bruch
{
    public:
        // Konstruktor
        Bruch (int = 0, int = 1) throw (DivisionDurchNullException);

        // Copy-Konstruktor und Zuweisungsoperator
        Bruch (const Bruch&);
        Bruch& operator= (const Bruch&);

        // Vergleichsoperatoren
        bool operator== (const Bruch&) const;
        bool operator!= (const Bruch&) const;

        // Arithmetische Operatoren
        friend Bruch operator+ (const Bruch&, const Bruch&);
        friend Bruch operator- (const Bruch&, const Bruch&);

        // Ausgabe auf stdout
        friend ostream& operator<< (ostream&, const Bruch&);

    private:
        // Methode zum Kuerzen
        void kuerzen (void);

        // Variablen zur Speicherung von Zaehler und Nenner
        int zaehler, nenner;
};

#endif

Es soll nun von einer Testklasse geprüft werden, ob die Additions- und Subtraktions-Operationen korrekt funktionieren. Darüber hinaus soll mit Hilfe von Unit-Tests (unter Verwendung des CppUnit-Frameworks) überprüft werden, ob bei Erzeugung eines ungültigen Bruchs – wenn der Nenner Null ist – die zugehörige Exception mit dem bezeichnenden Namen DivisionDurchNullException geworfen wird. Die Korrektheit der Methode kuerzen, die nach jeder Operation ausgeführt wird, die am Bruch etwas verändert, wird indirekt über die Vergleichsoperatoren getestet.

Testklasse

Nun geht es an die Definition und Implementierung der Testklasse, welche die geforderten Tests durchführen soll. Die Klassen des CppUnit-Frameworks sind in einem Namespace abgelegt, der durch das Makro CPPUNIT_NS definiert wird. Von der Klasse TestFixture, die in diesem Namespace abgelegt ist, wird nun die Testklasse abgeleitet. Eine TestFixture ist quasi eine Wrapper-Klasse, die dazu dient, bestimmte Rahmenbedingungen zu schaffen, die für eine Reihe von Tests benötigt werden. So können zum Beispiel Instanzen von Objekten erzeugt und initialisiert werden, die später von den einzelnen Tests benötigt werden. Zu diesem Zweck können die Methoden setUp und tearDown diese Vorarbeiten bzw. Nacharbeiten durchführen.

Das CppUnit-Framework definiert eine ganze Reihe von Makros, mit deren Hilfe einige lästige, wie auch fehlerträchtige Schritte erledigt werden können. Diese Makros werden im Header cppunit/extensions/HelperMacros.h definiert, der deshalb eingebunden werden muss. Darunter sind Makros zum Einleiten und Beenden einer Test-Suite (CPPUNIT_TEST_SUITE und CPPUNIT_TEST_SUITE_END). Unter einer Test-Suite versteht man eine Sammlung von Tests (Testreihe), die – meist mit Instanzen der zu testenden Klasse – durchgeführt werden sollen. Beim Einleiten einer Test-Suite muss stets der Typ (Name) der Testklasse, welche die durchzuführenden Tests enthält, übergeben werden. Dies ist in diesem Fall die Klasse bruchtest.

Darüber hinaus wird ein Makro definiert, mit dessen Hilfe man Tests in die Test-Suite integrieren kann. Im Beispiel wird dieses CppUnit-Makro mit dem Namen CPPUNIT_TEST verwendet. Als Parameter benötigt es den Namen der auszuführenden Testmethode. Dadurch wird der Test, der in der entsprechenden Methode implementiert ist, in der Test-Suite registriert.

// CppUnit-Tutorial
// Datei: bruchtest.h
#ifndef BRUCHTEST_H
#define BRUCHTEST_H

#include <cppunit/TestFixture.h>
#include <cppunit/extensions/HelperMacros.h>
#include "Bruch.h"

using namespace std;

class bruchtest : public CPPUNIT_NS :: TestFixture
{
    CPPUNIT_TEST_SUITE (bruchtest);
    CPPUNIT_TEST (addTest);
    CPPUNIT_TEST (subTest);
    CPPUNIT_TEST (exceptionTest);
    CPPUNIT_TEST (equalTest);
    CPPUNIT_TEST_SUITE_END ();

    public:
        void setUp (void);
        void tearDown (void);

    protected:
        void addTest (void);
        void subTest (void);
        void exceptionTest (void);
        void equalTest (void);

    private:
        Bruch *a, *b, *c, *d, *e, *f, *g, *h;
};

#endif

Die Methoden setUp und tearDown dienen, wie schon beschrieben, der Vor- und Nachbereitung der Tests. Sie werden innerhalb des CppUnit-Frameworks automatisch ausgeführt und bereiten so die Rahmenbedingungen vor. Im Beispiel werden in diesen Methoden die Bruch-Instanzen (a bis h) erzeugt bzw. nach den Tests wieder gelöscht. Der geschützte Bereich der Klasse enthält schließlich die Definition der einzelnen Test-Methoden. Diese Methoden enthalten in ihrer Implementation die eigentlichen Tests, die durchgeführt werden sollen.

Nach der Definition der Testklasse muss diese jetzt implementiert werden. Das CppUnit-Makro CPPUNIT_TEST_SUITE_REGISTRATION sorgt dafür, dass die in der Testklasse bruchtest definierte Test-Suite in eine globale Test-Liste, die sogenannte Registry, eingetragen wird.

// CppUnit-Tutorial
// Datei: bruchtest.cc
#include "bruchtest.h"

CPPUNIT_TEST_SUITE_REGISTRATION (bruchtest);

void bruchtest :: setUp (void)
{
    // Vorbereitungen treffen, indem Objekte initialisiert werden
    a = new Bruch (1, 2);
    b = new Bruch (2, 3);
    c = new Bruch (2, 6);
    d = new Bruch (-5, 2);
    e = new Bruch (5, -2);
    f = new Bruch (-5, -2);
    g = new Bruch (5, 2);
    h = new Bruch ();
}

void bruchtest :: tearDown (void)
{
    // Objekte alle wieder loeschen
    delete a; delete b; delete c; delete d;
    delete e; delete f; delete g; delete h;
}

void bruchtest :: addTest (void)
{
    // Ergebnisse der Subtraktionen pruefen
    CPPUNIT_ASSERT_EQUAL (*a + *b, Bruch (7, 6));
    CPPUNIT_ASSERT_EQUAL (*b + *c, Bruch (1));
    CPPUNIT_ASSERT_EQUAL (*d + *e, Bruch (-5));
    CPPUNIT_ASSERT_EQUAL (*e + *f, Bruch (0));
    CPPUNIT_ASSERT_EQUAL (*h + *c, Bruch (2, 6));
    CPPUNIT_ASSERT_EQUAL (*a + *b + *c + *d + *e + *f + *g + *h, Bruch (3, 2));
}

void bruchtest :: subTest (void)
{
    // Ergebnisse der Subtraktionen pruefen
    CPPUNIT_ASSERT_EQUAL (*a - *b, Bruch (-1, 6));
    CPPUNIT_ASSERT_EQUAL (*b - *c, Bruch (1, 3));
    CPPUNIT_ASSERT_EQUAL (*b - *c, Bruch (2, 6));
    CPPUNIT_ASSERT_EQUAL (*d - *e, Bruch (0));
    CPPUNIT_ASSERT_EQUAL (*d - *e - *f - *g - *h, Bruch (-5));
}

void bruchtest :: exceptionTest (void)
{
    // Hier muesste eine Exception geworfen werden
    CPPUNIT_ASSERT_THROW (Bruch (1, 0), DivisionDurchNullException);
}

void bruchtest :: equalTest (void)
{
    // Test erfolgreich, wenn er true liefert
    CPPUNIT_ASSERT (*d == *e);
    CPPUNIT_ASSERT (Bruch (1) == Bruch (2, 2));
    CPPUNIT_ASSERT (Bruch (1) != Bruch (1, 2));
    // Beide Ausdruecke muessen dasselbe Ergebnis liefern
    CPPUNIT_ASSERT_EQUAL (*f, *g);
    CPPUNIT_ASSERT_EQUAL (*h, Bruch (0));
    CPPUNIT_ASSERT_EQUAL (*h, Bruch (0, 1));
}

Jetzt können die bereits registrierten Test-Methoden implementiert werden. Die eigentlichen Tests werden mit Hilfe der CppUnit-Makros CPPUNIT_ASSERT_EQUAL, CPPUNIT_ASSERT und CPPUNIT_ASSERT_THROW definiert. Diese Makros fügen den zur Druchführung nötigen Source-Code automatisch in die Test-Methoden ein. Dabei prüft das CppUnit-Makro CPPUNIT_ASSERT_EQUAL, ob der erste Parameter dem zweiten gleicht. Im Falle des CppUnit-Makros CPPUNIT_ASSERT wird geprüft, ob der übergebene Ausdruck den Wert True liefert. Das CppUnit-Makro CPPUNIT_ASSERT_THROW wiederum wird eingesetzt um zu prüfen, ob der übergebene Ausdruck dazu führt, dass eine Exception des ebenfalls übergebenen Typs geworfen wird.

Testprogramm

Als nächstes muss das Testprogramm implementiert werden. Dazu benötigen wir zunächst eine Instanz der Klasse TestResult, die als Event-Manager fungiert. Diese Klasse dient dazu verschiedene Listener, welche beispielsweise die Testresultate für eine spätere Ausgabe sammeln, von den Testergebnissen und dem Testfortschritt (Events) zu informieren. Der hier verwendete Listener – eine Instanz der Klasse TestResultCollector – wird, indem er dem Event-Manager hinzugefügt wird, über den Testprozess informiert.

Alle registrierten Tests aus der Registry werden dann dem TestRunner hinzugefügt, der diese dann ausführt. Dabei wird der Event-Manager (TestResult) verwendet, um die Listener über den Testprozess zu informieren. Der CompilerOutputter dient dazu, die Testresultate in einem Format auszugeben, das dem eines Compilers gleicht. Dies bedeutet, dass jeder Fehler samt Zeilennummer und Dateiname ausgegeben wird. Auf diese Weise lassen sich die Fehlerquellen bei Verwendung einer IDE direkt durch anklicken an der Stelle öffnen, an welcher der Fehler aufgetreten ist.

Am Ende wird das Programm beendet und liefert, wenn alle Tests positiv liefen, den Rückgabewert 0. Traten bei den Tests Fehler auf, so wird 1 als Exit-Code geliefert. Dadurch ist es möglich, Testprogramme in den Built-Prozess einzubauen, denn im Falle aufgetretener Testfehler bricht auch der Built-Prozess mit einem Fehler ab. Das gesamte Testprogramm würde dann wie folgt aussehen:

// CppUnit-Tutorial
// Datei: btest.cc
#include <cppunit/CompilerOutputter.h>
#include <cppunit/extensions/TestFactoryRegistry.h>
#include <cppunit/TestResult.h>
#include <cppunit/TestResultCollector.h>
#include <cppunit/TestRunner.h>
#include <cppunit/BriefTestProgressListener.h>

int main (int argc, char* argv[])
{
    // Informiert Test-Listener ueber Testresultate
    CPPUNIT_NS :: TestResult testresult;

    // Listener zum Sammeln der Testergebnisse registrieren
    CPPUNIT_NS :: TestResultCollector collectedresults;
    testresult.addListener (&collectedresults);

    // Listener zur Ausgabe der Ergebnisse einzelner Tests
    CPPUNIT_NS :: BriefTestProgressListener progress;
    testresult.addListener (&progress);

    // Test-Suite ueber die Registry im Test-Runner einfuegen
    CPPUNIT_NS :: TestRunner testrunner;
    testrunner.addTest (CPPUNIT_NS :: TestFactoryRegistry :: getRegistry ().makeTest ());
    testrunner.run (testresult);

    // Resultate im Compiler-Format ausgeben
    CPPUNIT_NS :: CompilerOutputter compileroutputter (&collectedresults, std::cerr);
    compileroutputter.write ();

    // Rueckmeldung, ob Tests erfolgreich waren
    return collectedresults.wasSuccessful () ? 0 : 1;
}

Übersetzen

Zum Schluss geht es an die Erstellung eines Makefiles zur Übersetzung des Testprogramms, wobei ich davon aus gehe, dass das Zielsystem ein Unix/Linux-System mit installiertem GNU make ist. Ich möchte der Einfachheit halber an dieser Stelle auf die, sicherlich sinnvolle, Nutzung von autoconf und automake verzichten und verwende hier das unten stehende Makefile. Der folgende Abschnitt stellt den Inhalt der Datei mit den Namen makefile dar.

# CppUnit-Tutorial
# Datei: makefile
# In der folgenden Zeile muss evtl. der Pfad zum CppUnit-Framework geaendert werden
CPPUNIT_PATH=/opt/cppunit

btest: btest.o bruchtest.o bruch.o
    gcc -o btest btest.o bruchtest.o bruch.o -L${CPPUNIT_PATH}/lib -lstdc++ -lcppunit -ldl

bruch.o: bruch.cc bruch.h
    gcc -c bruch.cc

bruchtest.o: bruchtest.cc
    gcc -c bruchtest.cc -I${CPPUNIT_PATH}/include

btest.o: btest.cc
    gcc -c btest.cc -I${CPPUNIT_PATH}/include

Anzupassen ist hier lediglich der Pfad in der ersten Zeile des Makefiles, der zum Installationsort des CppUnit-Frameworks führt. Ein make in der Eingabeaufforderung (im gleichen Verzeichnis, in dem auch alle anderen Dateien liegen) übersetzt dann schließlich das Testprogramm.

Und los geht's ...

Das Testprogramm kann jetzt durch Eingabe von ./btest gestartet werden und sollte dann folgende Ausgabe generieren.

amue@rechenknecht # ./btest
bruchtest::addTest : OK
bruchtest::subTest : OK
bruchtest::exceptionTest : OK
bruchtest::equalTest : OK
OK (4)
amue@rechenknecht #

Alle vier Tests wurden demnach ohne Fehler durchlaufen und die Implementation der Klasse Bruch ist damit korrekt.

CppUnit-Tutorium: Download

Wer dieses Beispiel selbst einmal ausprobieren möchte, kann sich die zugehörigen Quellen zum Beispiel herunterladen. Ich hoffe, dass ich mit meinen Ausführungen einen kurzen Einstieg in die Nutzung des CppUnit-Frameworks vermitteln konnte und dieses CppUnit-Tutorial seinen Zweck damit erfüllt hat. Falls Sie Kritik oder Anregungen zum CppUnit-Tutorium haben, können Sie mir gerne über den Kontaktbereich eine E-Mail schicken.