4.2 Test-Based Programming#

Unit-Tests : Unit-Tests prüfen, ob einzelne Teile des Codes wie vorgesehen funktionieren, z. B. indem der Code unter Bedingungen ausgeführt wird, für die (bei korrekter Implementierung) das Verhalten bekannt ist. Immer wenn neuer Code geschrieben wird, sollte vorher ein Unit Test erstellt werden, um zu überprüfen, ob der entsprechende Code-Teil funktioniert. Die Einbindung des Tests in den automatisierten Build-Prozess stellt sicher, dass der Code auch nach zukünftigen Aktualisierungen noch wie vorgesehen funktioniert.

Tip

Allgemein: Lasse genauso viel Sorgfalt beim schreiben von Tests walten, wie beim Schreiben des “eigentlichen” Codes.

(Teile der Einführung zu Unit Tests folgen [Developers, 2016])

Unit Tests mit Boost Test schreiben#

Als Beispiel verwenden wir das weitverwendete Testframework als Teil der Boost-Library. Das Boost Unit Test Framework ist eine Bibliothek, die praktische Makros für Unit-Tests einfach bereitstellt. So muss der Programmierer nur die Testfunktionen oder Testfunktionsvorlagen nutzen. Darüber hinaus stellt die Bibliothek aber schon eine Menge zusätzliche Funktionen zum testen zur Verfügung.

Die Bibliothek selbst verpackt alles in eine richtige ausführbare Datei, die sogar zusätzliche Argumente annehmen kann.

Aufbau von Quelldateien mit Tests#

Tests können direkt eingebunden werden in den Quellcode. Beim erstellen einer neuen C++ Quelldatei wird dazu:

  1. Deklarieren des unit tests: #define BOOST_TEST_MODULE My unit test

  2. einbinden des zugehörigen Boost-Header: #include <boost/test/unit_test.hpp>

Schreiben eines Unit Tests#

Um einfach eine einzelne Testfunktion zu deklarieren, verwende BOOST_AUTO_TEST_CASE. Das erste Argument dieser Funktion ist der Name des Testfalls. Verwende dort das Assertion-Makro BOOST_TEST für die zu prüfenden Teile.

    BOOST_AUTO_TEST_CASE(case1)
    {
        int i = 0;
        BOOST_TEST(i == 0);
        int j = 1;
        BOOST_TEST(i != j);
    }

Damit ist bereits ein funktionierender Unit-Test geschrieben. Boost-Test kümmert sich um den Rest. Es benötigt nichtmals eine main Funktion oder Ausnahmebehandlung. Denn: wichtig in BOOST_TEST ist, dass die Ausführung des Programmablaufs fortgesetzt wird, selbst wenn eine Assertion fehlgeschlagen ist. So erhält der Benutzer einen vollständigen Bericht darüber, welche Tests funktionieren und welche fehlgeschlagen sind. Um zu vermeiden, dass dies zu undefiniertem Verhalten führt, kann über BOOST_REQUIRE aber die weitere Ausführung abhängig gemacht werden von einem erfolgreichen Test.

    BOOST_AUTO_TEST_CASE(case2)
    {
        int* i = get_pointer_to_int();
        BOOST_REQUIRE(i != nullptr);
        BOOST_TEST(*i == 0); // (*i) is valid if we reach this point
    }

Der Vergleich von Fließkommazahlen erfordert oft eine Toleranz, um stabil und unabhängig von dem spezifischen System zu sein, auf dem ein Test ausgeführt wird. Mit Boost Test kann eine Toleranz sowohl auf der Ebene eines Testfalls als auch für einzelne Assertions definiert werden. Wenn beides angegeben wird, hat die letztere Vorrang vor der ersteren, wie in diesem Beispiel gezeigt:

    #define BOOST_TEST_MODULE tolerance
    #include <boost/test/included/unit_test.hpp>
    namespace utf = boost::unit_test;
    namespace tt = boost::test_tools;

    // Test case with updated tolerance setting
    BOOST_AUTO_TEST_CASE(test1, * utf::tolerance(0.00001))
    {
        double x = 10.0000000;
        double y = 10.0000001;
        double z = 10.001;
        BOOST_TEST(x == y); // irrelevant from tolerance
        BOOST_TEST(x == y, tt::tolerance(0.0));

        BOOST_TEST(x == z); // relevant from tolerance
        BOOST_TEST(x == z, tt::tolerance(0.001));
    }

Mehr Hintergund siehe Dokumentation.

Vergleich von Collections: Standardmäßig werden Collections über ihren entsprechenden Vergleichsoperator verglichen. Allerdings kann es oft nützlich sein, einen elementweisen Vergleich durchzuführen. Dies ist direkt umsetzbar in Boost:

    #define BOOST_TEST_MODULE boost_test_sequence_per_element
    #include <boost/test/included/unit_test.hpp>
    #include <vector>
    #include <list>
    namespace tt = boost::test_tools;

    BOOST_AUTO_TEST_CASE( test_sequence_per_element )
    {
        std::vector<int> a{1,2,3};
        std::vector<long> b{1,5,3};
        std::list<short> c{1,5,3,4};

        BOOST_TEST(a == b, tt::per_element()); // nok: a[1] != b[1]

        BOOST_TEST(a != b, tt::per_element()); // nok: a[0] == b[0] ...
        BOOST_TEST(a <= b, tt::per_element()); // ok
        BOOST_TEST(b < c, tt::per_element()); // nok: size mismatch
        BOOST_TEST(b >= c, tt::per_element()); // nok: size mismatch
        BOOST_TEST(b != c, tt::per_element()); // nok: size mismatch
    }

Mehr Informationen zu vergleichen in Collections.

Aufrufe von Unit Tests in der Boost#

In Boost Unit Tests gibt es drei unterschiedliche Arten von Anweisungen und damit verbundenen Checks.

  • REQUIRE – implementiert eine feste Anforderung: Die angegebene Behauptung muss gültig sein für die folgenden Operationen. Nur dann werden diese auch noch ausgeführt.

  • CHECK – für Standardprüfungen: Wenn die Aussage als falsch bewertet wird, wird der Testfall zwar als fehlgeschlagen gekennzeichnet, aber die weitere Ausführung von möglichen folgenden Tests wird fortgesetzt.

  • WARN – für Warnungen: Die Ausführung des Testfalls wird fortgesetzt und es wird nur eine Warnmeldung ausgegeben. Die Warnung ändert aber nichts am Erfolgsstatus eines Testfalls. So können Aspekte überprüft werden, die weniger wichtig sind als die Korrektheit: Leistung, Portabilität, Benutzerfreundlichkeit usw.

Level

Test log content

Errors counter

Test execution

WARN

warning in : condition is not satisfied

not affected

continues

CHECK

Fehler in : Test fehlgeschlagen

erhöht

weiter

REQUIRE

fataler Fehler in : kritischer Test fehlgeschlagen

erhöht

bricht ab

Meist genutzter Aufruf ist einfach BOOST_TEST(statement);: Check eines allgemeinen Statements. Kann auch zusätzliche Rückmeldungen enthalten – BOOST_TEST(a == b, "a should be equal to b: " << a << "!=" << b); .

Unit Tests auf Templates und Fixtures#

Unit Tests sind auch auf Templates über verschiedenen Datentypen möglich, indem man eine Testfunktion deklariert, die mehrere Typen annimmt und für jeden Typ einzeln ausgeführt wird. Weitere Informationen siehe Boost Test-Dokumentation.

Fixtures : Fixtures sind standardisierte Objekte, die für jede einzelne Testfunktion instanziiert werden. So muss nicht für jeden Aufruf einer Testfunktion jeweils ein Objekt instantiiert werden, sondern es kann mit einem bestehenden weitergearbeitet werden. Siehe Boost Test-Dokumentation Test Fixtures.

Benutzerdefinierte Typen vergleichen#

Über das assertion Makro können alle integralen Typen von C++ in den Unit Tests verglichen werden (Um benutzerdefinierte Typen zu vergleichen, müssen jedoch zusätzliche Informationen zur Verfügung gestellt werden, damit Fehler ordnungsgemäß gemeldet werden können. Insbesondere müssen Benutzer Vergleichsfunktionen und eine Überladung des << Stream-Operators definieren.).

Grundsätze für gute Unit Tests:

Allgemein: Schreibe kleine Tests und organisiere sie in logischen Einheiten, sogenannten Testsuiten.

  • Testsuiten helfen dabei, Informationen darüber zu erhalten, wo ein Fehler aufgetreten ist und welche Testsuiten zusammengehören. Du kannst sie als alternative Art der Modularisierung betrachten.

  • Benutze kurze Tests: idealerweise sind Testfälle so kurz wie möglich geschrieben und testen einzelne Eigenschaften.

  • Vermeide Copy-Paste - wie bei regulärem Code wird ansonsten der Testcode sonst schwer zu pflegen sein.

  • Stelle nützliche Test-Debug-Meldungen zur Verfügung, wenn ein Test fehlschlägt. Dadurch weiß man idealerweise sofort, wo und warum dies fehlschlägt.

Um das zu erreichen, solltest du die Möglichkeiten des Test-Frameworks, das dir zur Verfügung steht, voll ausschöpfen.

Unit Tests bei eigener main Funktion#

Wenn Boost Test nicht die main()-Funktion generieren soll, müssen zusätzlich noch einige Makros definiert werden, bevor die Header der Bibliothek eingefügt werden:

  • BOOST_TEST_NO_MAIN und

  • BOOST_TEST_ALTERNATIVE_INIT_API

Danach muss in der bereitgestellten main()-Funktion, der Standard-Testrunner unit_test_main() explizit aufgerufen werden, mit einer zusätzlichen Standard-Initialisierungsfunktion init_unit_test() als Argument:

#define BOOST_TEST_NO_MAIN
#define BOOST_TEST_ALTERNATIVE_INIT_API
#include <boost/test/included/unit_test.hpp>
#include <iostream>

BOOST_AUTO_TEST_CASE(first_test_function)
{
	int i = 0;
   BOOST_TEST(i == 0);
   int j = 1;
   BOOST_TEST(i != j);
}

bool custom_init_unit_test()
{
	std::cout << "test runner custom init\n";
	return true;
}

int main(int argc, char* argv[])
{
	return boost::unit_test::unit_test_main(
		custom_init_unit_test, argc, argv);
}

Aufgabe 4.2 A) Nutzen von Unit Tests für die eben erstellten Klassen#

In dem git finden sie unter 4_2_Tests verschiedenen Programmcode, in dem sie Unit Tests einüben sollen. Ein einfaches Beispiel ist in simpletest.cpp gegeben (dies können sie durch make simpletest bauen).

Als erste Aufgabe sollen sie die Lösung von Aufgabe 4.1 und die dort per Hand durchgeführten Tests in Unit Tests der Boost umsetzen.

ToDo:
  • In customer.cpp: Ersetzen sie die Beispielaufrufe in der main Methode durch Unit-Tests. Dafür müssen sie eine neue BOOST_AUTO_TEST_CASE( test_customer ) { ... } Umgebung einführen.

  • Und die bisherige main entfernen oder auskommentieren.

  • Aufruf make: make customer.

Aufgabe 4.2 B) Erstellen von Unit Tests für eine Funktion#

Für eine vorgegebene Funktionalität wurden automatisch (durch ChatGPT als Werkzeug, Kommunikation mit ChatGPT am 28.4.2023, Prompts unten angegeben) zwei Implementierungen erstellt in closestsum_dp.cpp und closestsum_simple.cpp.

Diese sollen getestet werden.

ToDo:
  • Versuchen sie zuerst zu verstehen, was diese Funktionen genau tun und wie sie es genau umsetzen durch Inspektion des Codes.

Die Anweisung zur Erzeugung der Funktionen war allgemein:

a function that takes as an input a vector of int and one int and gives as an output the closest but lower sum from the ints in the vector that are lower compared to the one additional int

Dies wurde in der Rückmeldung besser erläutert als

First, let’s define the problem more clearly: We have a vector of integers and a target integer. Our task is to find the closest sum of numbers from the vector that doesn’t exceed the target integer. In other words, we’re trying to achieve the target integer or come as close as possible without going over.

Als erste Lösung wurde eine Version über Dynamic Programming angeboten (closestsum_dp.cpp): “To solve this problem, we’ll use a dynamic programming approach.”

Als zweites wurde nach einer leichter zu verstehenden Lösung gefragt (closestsum_simple.cpp):

A simpler, yet more inefficient way to approach this problem is to generate all possible subsets of the input array and keep track of the maximum sum that does not exceed the target. This approach is simpler in terms of understanding, but it’s less efficient since generating all subsets of a set takes O(2^n) time where n is the size of the set.

ToDo:
  • Schreiben sie in Boost Unit tests, die diese beiden Funktionen überprüfen und testen, ob sie die gegebene Aufgabenstellung erfüllen.

  • Aufruf make: make closestsum_dp bzw. make closestsum_simple.

Referenzen:#

Die obigen Hinweise folgen [Developers, 2016], die eine weitreichendere Einführung in Unit Tests dazu noch anbieten.

Nützliche Links zu Tests (Boost Dokumentation):