Experience Embedded

Professionelle Schulungen, Beratung und Projektunterstützung

Effizient zum Unit-Test unter C++ und C

Autor: Franco Chiappori, Schindler Aufzüge AG

Beitrag - Embedded Software Engineering Kongress 2016

 

Continuous Integration und automatisierte Tests sind erprobte Mittel, um die Qualität von Software 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. Als Entwickler schätzt man zudem die schnellen Feddbackzyklen 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 Abb. 1 (siehe PDF) 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.

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 Protokoll (siehe Abb. 2, PDF) 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. Abb. 3 (siehe PDF) 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 diese 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. Abb. 4 (siehe PDF) 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. Abb. 5 (siehe PDF) zeigt das zugehörige Klassendiagramm, ein möglicher Unit-Test ist in Abb. 6 (siehe PDF) 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 Abb. 6 (siehe PDF) 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 anderen 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 Abb. 7 (siehe PDF) gezeigt.

Durch diese Trennung entfällt das Mock-Objekt und der Unit-Test wird vereinfacht (Abb. 8, siehe PDF). 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 Abb. 7 (siehe PDF) 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 Abb. 9 (siehe PDF) 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 wurden 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 angeknüpft werden (siehe Abb. 10, PDF). Im regulären Code wird das Symbol UNIT_TEST nie definiert, und registers.h wird inkludiert. 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. Anschließend kreiert man ein Objekt von diesem Typ unter UartaRegs.txFifo, so dass der Treibercode auf das FakeFifo schreibt. Abb. 11 (siehe PDF) 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

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 testbarem 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".

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


Beitrag als PDF downloaden

 


Test, Qualität & Debug - unsere Trainings & Coachings

Wollen Sie sich auf den aktuellen Stand der Technik bringen?

Dann informieren Sie sich hier zu Schulungen/ Seminaren/ Trainings/ Workshops und individuellen Coachings von MircoConsult zum Thema Test, Qualität & Debug.

 

Training & Coaching zu den weiteren Themen unseren Portfolios finden Sie hier.


Test, Qualität & Debug -Fachwissen

Wertvolles Fachwissen zum Thema Test, Qualität & Debug steht hier für Sie zum kostenfreien Download bereit.

Zu den Fachinformationen

 
Fachwissen zu weiteren Themen unseren Portfolios finden Sie hier.