Unit Tests

Unit tests sind in der Entwicklung ein notwendiges Übel, weil die Vorgesetzten es als Funktionsbeweis haben wollen.

So habe ich es viele Jahre gesehen und auch heute erscheinen mir manche Testanforderungen nur als lästige Bürokratie, die selten wirklich etwas “abtestet”.

Doch nun beweist mir meine eigene Fehlbarkeit, dass Unit Tests IMMER eine Existenzberechtigung haben.


Es gibt einen Grund, warum ich Unit Tests immer etwas skeptisch gegenüberstand: In meiner Welt sind Codes ziemlich System-spezifisch und nur relativ schwer in Tests zu fassen.

Wie soll ich denn z.B. direkte Festplattenzugriffe “effektiv” abtesten. Oder wie soll die korrekte Funktion eines Windows-Service Starts oder eines Linux-Daemon Forks verifiziert werden?
Da kann man gleich return true schreiben, denn die “echten” Fehler findet man nur beim Debugging oder in Crash-Reports.

Das ist in vielen Business-Anwendungen anders, wo im Endeffekt immer nur Daten hin und her-konveriert werden. Denn da kann man einen künstlichen Input und erwarteten Output “leicht” durch die selbst geschriebene Funktion jagen und vergleichen. Ein solcher Unit test wird dann schnell Fehler in Algorithmen und Mappings aufzeigen können.

Doch genau das ist bei System-nahen Routinen weniger einfach und wenn das Abtesten den 10-fachen Aufwand des Quellenschreibens ausmacht, schwindet meine Motivation dafür deutlich.

Interface-Stabilität und Regression Tests

Wenn man eine Schnittstelle auf mehreren Plattformen implementiert um dadurch eine OS-Abstraktion zu erhalten, ist es essentiell, dass die Schnittstelle auf jeder Plattform das exakt gleiche Verhalten aufweist und sich dieses über die Zeit auch nicht ändert.

Schnell kann es passieren, man mal meint:

Ich füge da ein kleines Feature hinzu, aber das ist ungefährlich, es ändert sich nichts für bestehenden Code.

Und 1 Monat später hat man den A*sch offen, weil sich dennoch Seiteneffekte ergeben haben und die Resultate einfach nicht mehr stimmen.

Problematisch sind auch nicht konkret dokumentierte Erwartungen gegenüber Funktionen und Daten.
Mir ist nämlich folgendes passiert:

Die gate_atomic_* Funktionen stellen atomare Integer-Operationen bereit und wurden zuerst für Windows implementiert, wo die Interlocked-APIs zum Einsatz kamen. Einige davon geben als Return-Value den aktuellen Wert einer Variable zurück, andere geben den Wert vor der Operation zurück.

Als dann später die GCC Implementierung folgte, nahm ich bei einigen Funktionen ein Verhalten identisch zu Windows an und implementierte es auch so. Doch leider arbeitet der GCC hier teilweise anders.

Genau an der Stelle hätte nun ein ordentlicher Unit Test sofort ein Interface-Problem angezeigt. Doch ohne diesen Test verbrachte ich Monate später zahlreiche Stunden um einen Crash zu verstehen, der sich aus falsch interpretierten atomic-Aufrufen ergeben hatte.

Lektion gelernt: Immer Tests schreiben!

Test Frameworks

BOOST bietet mit seinem Test-Framework ein großes Set an Makros und Funktionen, um eigene Tests effizient schreiben und ausführen zu können.

 1#include <boost/test/unit_test.hpp>
 2
 3BOOST_AUTO_TEST_CASE(test_name)
 4{
 5    int expected_value = 42;
 6    int received_value;
 7    BOOST_REQUIRE_NO_THROW(
 8        received_value = func_to_be_tested();
 9        );
10    BOOST_CHECK_EQUAL(received_value, received_value);
11}

Test Cases werden über die Makros zu Klassen und Methoden und während REQUIRE Makros per Exception einen Test abbrechen, laufen CHECK Makros einfach durch, zählen aber Fehler mit, damit man sie am Ende gesammelt ausgeben kann.

GATE Implementierung

In C hat man das Problem, dass man keine Exceptions kennt und nicht eine Funktion “einfach so” abbrechen kann und danach zurückspringen kann. (Ich lasse setjmp/longjmp hier mal bewusst weg.)

Daher definiere ich eine Test-Routine als Funktion, die einen Boolean zurückgibt und nur beim vollständigen Durchlauf mit return true endet. Fehler führen zu einem return false. Auf diese Weise kann man zumindest auf einer Ebene auch REQUIRE und CHECK Makros etablieren.

Nun müssen ein paar Funktionen definiert werden, die Tests und Fehler hochzählen und einem registriertem Test zuordnen können. Dafür reichen einfache globale Variablen, denn jede Test-Funktion muss am Anfang ihren Namen registrieren und alle nachfolgenden Zählungen werden so lange dem Test zugeordnet, bis ein anderer Test startet.
Wie in BOOST kann immer nur ein Test gleichzeitig laufen.

 1#define GATE_TEST_UNIT_BEGIN(name) \
 2  gate_test_unit_begin(GATE_TEST_STR(name), __FILE__, __LINE__)
 3
 4#define GATE_TEST_UNIT_END \
 5  return true
 6
 7#define GATE_TEST_CHECK(expression) \
 8  do \
 9  { \
10    gate_test_count_test(); \
11    if( ! (expression) ) \
12    { \
13      gate_test_count_error(); \
14      gate_test_error_message( GATE_TEST_STR(expression), \
15        __FILE__, __LINE__ ); \
16    } \
17  } while(0)
18
19#define GATE_TEST_REQUIRE(expression) \
20  do \
21  { \
22    gate_test_count_test(); \
23    if( ! (expression) ) \
24    { \
25      gate_test_count_error(); \
26      gate_test_error_message( GATE_TEST_STR(expression), \
27        __FILE__, __LINE__ ); \
28      return false; \
29    } \
30  } while(0)

Mit diesem Set an Makros, kann man schon seinen ersten Test schreiben:

 1#include "gate/test.h"
 2
 3gate_result_t func_to_be_tested(int* ptr_output);
 4
 5static gate_bool_t test_someting()
 6{
 7    int expected_value = 42;
 8    int received_value;
 9
10    GATE_TEST_UNIT_BEGIN(test_someting);
11
12    GATE_TEST_REQUIRE(
13        GATE_RESULT_OK == func_to_be_tested(&received_value));
14
15    GATE_TEST_CHECK(received_value == expected_value);
16
17        GATE_TEST_UNIT_END;
18}

Fazit

Das ganze steht noch am Anfang und wird sicher noch einige Updates erfahren. Und wenn man die Ergebnisse am Ende in einem handelsüblichen Format, wie z.B. JUnit als XML ausgibt, dann kann man auch bestehende Tools zur Überprüfung und Anzeige der eigenen Ergebnisse einsetzen.

Natürlich würde es auch Sinn machen, hier auf eine bestehende Lösung zu vertrauen und im Beruf sehe ich das als unbedingt notwendig.

Doch im GATE Projekt geht es mir ja auch darum zu lernen, wie man solche Frameworks selbst aufbaut und unabhängig von Megabyte großen Runtimes auch zu annehmbaren Ergebnissen kommen kann.


Meine Dokus über:
 
Weitere externe Links zu:
Alle extern verlinkten Webseiten stehen nicht in Zusammenhang mit opengate.at.
Für deren Inhalt wird keine Haftung übernommen.



Wenn sich eine triviale Erkenntnis mit Dummheit in der Interpretation paart, dann gibt es in der Regel Kollateralschäden in der Anwendung.
frei zitiert nach A. Van der Bellen
... also dann paaren wir mal eine komplexe Erkenntnis mit Klugheit in der Interpretation!