Effizient zum Unit-Test unter C++ und C

Autor / Redakteur: Franco Chiappori* / Sebastian Gerstl

Continuous Integration und automatisierte Tests sind erprobte Mittel, um die Qualität von in C oder C++ geschriebenem Code zu fördern. Gerade den automatisierten Unit-Tests kommt große Bedeutung zu, garantieren sie doch als Basis der Testpyramide auch die Basis der Qualität. Ein Beispiel aus der Entwicklerpraxis.

Anbieter zum Thema

Unit Tests bzw. Funktionsstests sind gerade bei sicherheitskritischen Anwendungen essentiell. Doch gerade hardwarenahen oder echtzeitkritischen C-Code effizient zu testen gestaltet sich oft als schwierig. Mit einigen Tricks aus dem Repertoire von C++ kann dies aber auch elegant gelingen.
Unit Tests bzw. Funktionsstests sind gerade bei sicherheitskritischen Anwendungen essentiell. Doch gerade hardwarenahen oder echtzeitkritischen C-Code effizient zu testen gestaltet sich oft als schwierig. Mit einigen Tricks aus dem Repertoire von C++ kann dies aber auch elegant gelingen.
(Bild: Clipdealer)

Als Entwickler schätzt man zudem die schnellen Feedbackzyklen von Unit-Tests. In der Praxis fangen die Probleme aber oftmals schon beim Isolieren des zu testenden Codes an. Wie löse ich meine C++ Klasse oder meine C-Funktion aus ihren Abhängigkeiten? Die Testpyramide in Bild 1 zeigt, wie eine Applikation idealerweise durch Tests abgedeckt wird [1].

Die Basis bilden Unit-Tests, welche eine kleine Software-Einheit überprüfen. Schlägt ein solcher Test fehl, kann das Problem nur in der getesteten Unit liegen. Unit-Tests sind meist einfach zu erstellen, können innerhalb weniger Sekunden ausgeführt werden und gefundene Fehler können leicht lokalisiert werden. Daher sollte der größte Teil der Funktionalität durch Unit-Tests abgedeckt werden. Ein Unit-Test bedingt jedoch, dass der entsprechende Code isoliert wird.

Bildergalerie
Bildergalerie mit 10 Bildern

Standardansatz Dependency Injection

Die verschiedenen Ansätze zur Isolation lassen sich am besten mit einem konkreten Beispiel aufzeigen. Im vorliegenden Projekt werden binäre Daten über eine UART-Schnittstelle kommuniziert. Um die einzelnen Meldungen abzugrenzen, wird das Framing des Point-to-Point Protokolls eingesetzt [2]. Grob zusammengefasst:

  • Ein Flag-Byte (0x7E) wird am Anfang und Ende hinzugefügt
  • Ein Escape-Byte (0x7D) ist definiert
  • Taucht ein Flag- oder Escape-Byte im Frame auf, wird es ersetzt mit dem Escape-Byte, gefolgt vom originalen Byte XOR 0x20

Ein solches Framing lässt sich einfach in Code umsetzen. Bild 3 zeigt eine mögliche Implementierung in C. Diese ist simpel und direkt, lässt sich aber nur schwer für Unit-Tests isolieren, da sie direkt auf den UART-Treiber zugreift (uart_put).

Eine bewährte Methode um dieses Problem zu umgehen ist Dependency Injection. Der Framing-Code benötigt ein zeichenorientiertes Device, an das die codierten Bytes weitergereicht werden können. Wenn man diese Abhängigkeit von außen vorgibt (bildlich gesprochen einimpft) spricht man von Dependency Injection. Bild 4 zeigt eine mögliche Implementierung in C++.

Mit dieser Erweiterung lässt sich der Code relativ einfach isolieren. Anstelle des UART wird beim Unit-Test ein sogenanntes Mock-Objekt mitgegeben. Dieses Objekt implementiert die Device Schnittstelle, aber speichert die geschriebenen Zeichen, damit sie vom Unit-Test inspiziert werden können. Bild 5 zeigt das zugehörige Klassendiagramm, ein möglicher Unit-Test ist in Bild 6 aufgelistet.

Mit Hilfe von Dependency Injection und Mock-Objekten lassen sich Abhängigkeiten durchbrechen und nahezu jeder Code kann für Unit-Tests isoliert werden. Doch die Bedeutung von Dependency Injection geht weit über das Thema Unit-Test hinaus. Im Prinzip geht es darum, einzelne Problembereiche zu trennen (Separation of Concerns). Das Framing an sich hat nichts mit dem UART zu tun.

Dank Dependency Injection können diese Aspekte auch im Design sauber getrennt werden. Diese Vorteile haben aber einen gewissen Preis. Durch die zusätzliche Abstraktion geht Kontextinformation verloren. Es ist nicht mehr auf den ersten Blick ersichtlich, wozu das Framing eingesetzt wird.

Zudem muss mehr Code erstellt, dokumentiert und gewartet werden. Im vorliegenden Beispiel ist das nicht viel, aber in einem echten Projekt gibt es hunderte von Abhängigkeiten, und entsprechend viele Schnittstellen müssen abstrahiert werden. Für den Unit-Test an sich ergibt sich auch ein gewisser Aufwand, die Mock-Objekte müssen implementiert und aufgesetzt werden.

Mock-Objekte vermeiden durch Trennung von Kernlogik und Vernetzung

Wie im Unit-Test Code von Bild 6 ersichtlich, bedeutet der Einsatz von Mock-Objekten immer einen gewissen Aufwand und macht den Test komplexer. Man kann umgekehrt Fragen: Welcher Code lässt sich möglichst direkt und ohne Aufwand testen? Die Antwort ist nicht schwer: reine Funktionen ohne Abhängigkeiten und Seiteneffekte sind am einfachsten zu testen.

Der Output hängt nur vom Input ab und es gibt keine Abhängigkeiten, die uns das Leben schwermachen. Wenn Code aus diesem Blickwinkel betrachtet wird, kann man oft feststellen, dass Klassen und Funktionen zwei Aspekte haben. Zum einen eine Kernlogik, welche die Verarbeitung von Daten und Events festlegt. Zum andern eine Vernetzung, welche den Code mit seiner Umwelt verknüpft.

Im Framing Beispiel ist die Kernlogik das Erstellen des Frames, während die Verknüpfung das Weiterleiten an den UART ist. Diese zwei Aspekte lassen sich trennen, wie in Bild 7 gezeigt. Durch diese Trennung entfällt das Mock-Objekt und der Unit-Test wird vereinfacht (Bild 8). Der Code für die Vernetzung ist oft so banal, dass kein eigener Unit-Test nötig ist. Dieser Ansatz ist auch als Humble Object Pattern bekannt [3].

Herausforderungen beim Test von laufzeitkritischem Treibercode

Im vorliegenden Projekt wurde zunächst Kernlogik und Vernetzung wie in Bild 7 getrennt. Performance-Messungen auf dem Zielsystem ergaben jedoch, dass dieser Code bei weitem zu langsam war. Neben anderen Faktoren kostete das mehrfache Kopieren der Daten und der Funktionsaufruf für jedes gesendete Byte zu viel Zeit.

Man war gezwungen, die Logik in die Treiberschicht zu verschieben. Nach mehreren Optimierungsschritten sah der Treibercode wie in Bild 9 aus. Dieser optimierte Code stellt dem Unit-Test drei Hürden in den Weg.

Erstens wird der Treibercode auf dem PC nicht mitkompiliert. Zweitens kollidieren Definitionen im referenzierten registers.h mit anderen Header-Dateien. Drittens werden Daten direkt in UART Register geschrieben: die Adresse von UartaRegs.txFifo entspricht auf dem Target der Registeradresse des Sende-FIFO.

Lösungsansatz Treibercode patchen

Der erste Lösungsansatz war es, den Treibercode für den Unit-Test zu patchen. Mit einem gezielten Patch wurde das #include abgeändert sowie die direkten Zugriffe auf die UART Register durch Funktionsaufrufe ersetzt. Das resultierende File wurde auf dem PC kompiliert und getestet. Vorteil dieses Ansatzes ist, dass man den Treibercode beliebig manipulieren kann, um ihn testfähig zu machen.

Auf der anderen Seite gibt es auch viele Nachteile. Wird der Treibercode geändert, muss der Patch angepasst werden. Da Codeteile ersetzt werden, können Fehler verdeckt werden. In der Praxis stellte sich heraus, dass die Tests brüchig waren, und immer wieder geflickt werden mussten.

Lösungsansatz Source-File inkludieren

Ein zweiter Lösungsansatz besteht darin, das Source-File des Treibers im Unit-Test zu inkludieren. So wird die erste Hürde, das Mitkompilieren des Treibercodes, überwunden. Um die zweite Hürde zu nehmen, kann das #include im Treiber an eine Bedingung geknüpft werden (siehe Bild 10). Im regulären Code wird das Symbol UNIT_TEST nie definiert, und registers.h wird inkludiert.

Bildergalerie
Bildergalerie mit 10 Bildern

Im Unit-Test kann dieses Symbol definiert werden um das #include zu unterdrücken. Technisch am interessantesten ist die dritte Hürde. Wie kann der Zugriff auf eine Variable abgefangen werden? Der zu testende Code schreibt während einem Aufruf mehrfach auf das Register, und der Test muss nicht nur das letzte Byte, sondern alle geschriebenen Bytes kennen. Hier kommt C++ und seine mächtigen Sprachmittel zu Hilfe.

Es wird eine Klasse FakeFifo definiert, die den Zuweisungsoperator für uint8_t überschreibt. Anschliessend kreiert man ein Objekt von diesem Typ unter UartaRegs.txFifo, so dass der Treibercode auf das FakeFifo schreibt. Bild 11 zeigt den gesamten Code, inklusive eines Unit-Tests.

Mit diesem Ansatz kann der originale Treibercode mit minimalsten Anpassungen ausgiebig getestet werden. Das funktioniert auch in die andere Richtung, wenn Daten aus einem FIFO Register gelesen werden. Hierzu muss die Klasse FakeFifo den Umwandlungsoperator für uint8_t überschreiben.

Fazit: Auch hardwarenaher C-Code lässt sich effizient testen

Mit ein paar kleinen Tricks aus der Schatulle von C/C++ lässt sich auch hardwarenaher und laufzeitkritischer C-Code effizient testen. Auf Stufe Unit-Test kann so die Logik auf Herz und Nieren geprüft werden. Für weniger laufzeitkritischen Code empfiehlt sich das Humble Object Pattern, um Kernlogik und Vernetzung zu trennen. Dies führt zu verständlichem und leicht testbaren Code.

All diese Techniken ermöglichen es, die Testabdeckung zu erhöhen. Eine Abdeckung von 100% kann nicht erreicht werden, aber um es mit den Worten von Martin Fowler zu sagen [4]: «Es ist besser, unvollständige Tests zu schreiben und laufen zu lassen, als vollständige Tests bleiben zu lassen».

(Dieser Beitrag wurde mit freundlicher Genehmigung des Autors dem Tagungsband Embedded Software Engineering Kongress 2016 entnommen.)

Literatur- und Quellenverzeichnis

[1] Mike Cohn: Succeeding with Agile. Addison-Wesley, 2009;

[2] W. Simpson, Editor: RFC1662, PPP in HDLC-like Framing. IETF, July 1994;

[3] Gerard Meszaros: xUnit Test Patterns. Addison-Wesley, 2007;

[4] Martin Fowler: Refactoring. Addison-Wesley, 1999



* Franco Chiappori arbeitet seit über 20 Jahren als Entwickler und Architekt von Embedded Software. Er hat Erfahrung in der Spezifikation, Entwicklung und Test von C/C++ Anwendungen auf kleinen Mikrokontrollern bis hin zu Linux Multi-Prozess-Systemen. Seit 2015 arbeitet er für Schindler Aufzüge AG als Software-Architekt im Bereich elektrische Antriebe.

(ID:44851159)