TDD für Embedded-Plattformen – Praxiseinsatz und Framework-Vergleich

vor 6 Stunden 1

Die Entwicklung von Embedded-Software wird immer komplexer. Dank leistungsfähigerer Mikrocontroller, Vernetzung und höherer Ansprüche an Sicherheit und Zuverlässigkeit unterscheiden sich Firmware-Projekte in ihrer Komplexität oft kaum mehr von klassischen Softwareprojekten auf dem PC. Doch die besonderen Rahmenbedingungen für Embedded-Anwendungen gelten nach wie vor: strenge Ressourcenlimits, unmittelbare Hardwareabhängigkeiten, Echtzeitanforderungen und die Notwendigkeit, ein System schon in sehr frühen Entwicklungsphasen stabil und fehlerfrei zu gestalten.

Klaus Rodewig ist Mitglied im Expertenkreis Cybersicherheit des Bundesamtes für Sicherheit in der Informationstechnologie und entwickelt mit seiner Firma Appnö Apps und andere Software.​

An dieser Stelle kann testgetriebene Entwicklung (Test Driven Development, TDD) helfen. TDD ist eine Entwicklungspraktik aus dem agilen Umfeld, die bewusst das Schreiben von Tests vor das Implementieren der eigentlichen Funktionalität stellt. Der Grundgedanke: Wer zuerst den Test formuliert, weiß genau, was der Code später leisten soll. Sobald dieser Test fehlschlägt (Red-Phase), implementiert man den minimal notwendigen Code (Green-Phase), um den Test zum Bestehen zu bringen. Anschließend verbessert und bereinigt man den Code (Refactor-Phase). Dieser Zyklus wird immer wieder durchlaufen. Als Ergebnis entstehen saubere, verständliche und gut getestete Module, die sich sicherer anfühlen und weniger Fehler aufweisen.

TDD ist eine Technik aus dem Umfeld der agilen Softwareentwicklung, bei der man den Test vor der eigentlichen Codeimplementierung schreibt. Das klingt zunächst widersprüchlich – wie soll man etwas testen, das noch gar nicht existiert? Genau darin liegt aber die Stärke von TDD: Der Test dient als Anforderungsdefinition, als Spezifikation der Funktionalität. Der anfängliche Test beschreibt, wie die Funktion sich verhalten soll. Dieser Test schlägt logischerweise erst einmal fehl, weil die Funktion noch nicht implementiert ist. Das ist die Red-Phase.

Als nächstes schreibt man den minimalen Code, um den Test zum Bestehen zu bringen. Diese Phase nennt man Green-Phase. Sobald der Test grün ist (also bestanden wurde), startet die Refactor-Phase, in der Entwicklerinnen und Entwickler den Code verbessern, vereinfachen und optimieren. Dabei stellen die Tests sicher, dass die Funktionsweise unverändert bleibt.

Dieser Zyklus – Red-Green-Refactor – wird immer wieder durchlaufen. Das Ergebnis: Der Code ist jederzeit von Tests abgedeckt, die Anforderungen sind klar und nachvollziehbar und das Entwicklungsteam gewinnt Vertrauen in die Qualität der Software. Gerade im Embedded-Umfeld, in dem das Debuggen auf echter Hardware oft aufwendig oder spät möglich ist, ist dieses Vertrauen von unschätzbarem Wert.

Testgetriebene Entwicklung bietet folgende Vorteile:

  • Frühe Fehlererkennung: Bugs werden entdeckt, bevor sie sich im Code verbreiten.
  • Strukturierte Arbeitsweise: Die Anforderungen (Tests) stehen klar vor Augen, kein "Wegprogrammieren" an den eigentlichen Zielen vorbei.
  • Bessere Wartbarkeit: Die Tests dienen als lebendige Dokumentation. Andere Developer (oder man selbst nach einigen Monaten) verstehen leichter, wie Funktionen gedacht sind.
  • Höhere Codequalität: Da man nur so viel Code schreibt, wie für das Bestehen der Tests nötig ist, entsteht weniger unnötiger und fehleranfälliger Ballast.

Anders als Desktop- oder Webanwendungen ist Embedded-Software oft an spezielle Hardware gebunden. Ein Sensor, ein Aktor oder ein bestimmtes Peripherie-Register bestimmt, ob eine Funktion korrekt arbeitet. Das erschwert das Testen, da sich die Abfrage eines GPPIO-Pins nicht so einfach als Code auf dem PC ausführen lässt.

Hier kommt eine wichtige TDD-Praxis ins Spiel: Abstraktion und Mocking. Statt direkt auf Hardwareregister zuzugreifen, definiert man Schnittstellen (Interfaces). Für die Produktion greift diese Schnittstelle auf die echte Hardware zu, und in der Testumgebung ersetzt ein Mock oder Stub die Hardware. Da Mocks Umgebung simulieren, kann derselbe Code unter Tests auf dem PC laufen. Dadurch lassen sich viele Fehler früh und unabhängig von dem Zielgerät finden.

Embedded-Systeme haben oft wenig RAM und Flash-Speicher. Ein umfangreiches Testing-Framework kann nicht direkt auf dem Zielsystem laufen, sondern braucht einen PC als Host. Der in den Tests laufende Code ist so weit abstrahiert, dass er sowohl auf dem PC als auch (theoretisch) auf dem Target lauffähig ist. Damit lassen sich große Teile der Logik und Algorithmen früh und komfortabel auch ohne Embedded-Debugger prüfen.

Echtzeitanforderungen und Timing sind weitere Aspekte: Viele Embedded-Funktionen hängen von zeitlichen Abläufen ab. Auch hier hilft TDD: Man kann Timer und Zeitfunktionen mocken, um deterministische Tests durchzuführen.

In diesem Artikel kommen zwei verschiedene Testing-Frameworks zum Einsatz: Google Test und CppUTest. Beide ermöglichen testgetriebene Entwicklung von Embedded-Software, insbesondere durch das Ausführen der Tests auf dem Host. Dennoch stehen hinter den Frameworks unterschiedliche Zielsetzungen und Designphilosophien.

Google Test (GTest) ist ein C++-basiertes Unit-Test-Framework, das ursprünglich für Desktop- und Serveranwendungen konzipiert war. Es bietet eine äußerst mächtige, aber auch komplexe API. Die Assertions sind zahlreich und fein granular (z. B. EXPECT_EQ, EXPECT_NEAR, ASSERT_THAT mit Matchern) und es gibt Mechanismen für das Testen von Ausnahmen (Death Tests), parametrisierte Tests, Test-Files, Test-Fixtures und vieles mehr.

Ursprünglich richtet sich GTest vor allem an C++-Entwickler. Da für Embedded-Projekte oft C zum Einsatz kommt, ist man gezwungen, C-Quellcode über C++-Test-Treiber einzubinden. Das ist kein grundsätzliches Problem, aber ein Mehraufwand, da man C-Header in extern "C"-Blöcken inkludieren muss. Zudem setzt GTest meist eine C++-Standardbibliothek und einen moderneren Compiler voraus. Da beides bei Embedded-Targets oft nicht verfügbar ist, läuft GTest in den meisten Fällen nur auf dem Host.

Die Integration von GTest in ein Projekt kann auf drei Wegen erfolgen:

  1. Paketmanager oder vordefinierte Pakete: Viele Build-Systeme wie CMake bieten FetchContent- oder ExternalProject-Mechanismen an, um GTest aus dem GitHub-Repository direkt ins Projekt zu ziehen.
  2. Systemweite Installation: Unter Linux-Distributionen kann man GTest-Pakete oft aus den Repositorys installieren. Unter Windows stehen Vcpkg oder Conan zur Verfügung.
  3. Vendoring: Das GTest-Repository lässt sich als Submodul ins eigene Repository einbinden.

Teams müssen sicherstellen, dass ihr Compiler und ihre Build-Umgebung auf C++ ausgelegt sind. Die GTest-Library ist umfangreich und setzt in der Regel die Standardbibliothek voraus. Auf dem Host ist das kein Problem, auf einem reinen Embedded-Target aber durchaus. Die Tests sollten daher hostbasiert laufen.

Idealerweise arbeitet man mit einem CMake-Aufbau, in dem man Testverzeichnisse separiert und in CMakeLists die GTest-Bibliothek linkt. Folgender Code zeigt ein typisches Vorgehen:

FetchContent_Declare( googletest URL https://github.com/google/googletest/archive/refs/tags/release-1.12.1.zip ) FetchContent_MakeAvailable(googletest) add_executable(tests test_led.cpp test_sha.cpp) target_link_libraries(tests gtest_main)

Wer das Mocking-Framework GMock, das Teil von GTest ist, nutzen möchte, um Hardwareschnittstellen zu simulieren, muss die zu testenden Funktionen in eine für GMock geeignete Form bringen – oft Klassen und virtuelle Methoden. Für reine C-Funktionen kann das mehr Aufwand bedeuten.

GTest kann Testergebnisse im JUnit-Format ausgeben, was die Integration in CI-Systeme wie Jenkins oder GitLab CI vereinfacht.

Fixtures sind praktisch, aber man sollte sie sparsam nutzen und nicht übermäßig komplex gestalten. Einfache SetUp/TearDown-Methoden und möglichst isolierte Tests helfen, den Überblick zu behalten.

Parametrisierte Tests sind großartig, um Algorithmen gegen viele Eingabedaten zu prüfen, etwa kryptografische Funktionen mit unterschiedlichen Testvektoren.

Bei ressourcenschwachen Targets, die Tests direkt auf dem Device ausführen sollen, ist GTest selten die erste Wahl. Es ist umfangreich und benötigt C++-Runtime-Funktionen.

Vorsicht vor Over-Engineering: Mit GTest besteht die Gefahr, äußerst komplexe Testhierarchien aufzubauen. Das kann kontraproduktiv für Embedded-Module sein, die einfach und schnell testbar sein sollen.

CppUTest ist ein schlankes Testing-Framework für C und C++, das besonders in der Embedded-Entwicklung beliebt ist. Es kommt mit minimalen Abhängigkeiten, benötigt keine umfangreiche Standardbibliothek und ist einfacher auf Embedded-ähnliche Toolchains zu portieren. Die Assertions sind einfacher (z. B. CHECK_TRUE, LONGS_EQUAL, STRCMP_EQUAL), aber flexibel genug für die meisten Anwendungsfälle. CppUTest ist bewusst minimalistisch gehalten.

Ein weiterer Vorteil ist die enge Verzahnung mit CppUMock, einem einfach zu nutzenden Mocking-Framework für C-Funktionen. Das erweist sich gerade in der Embedded-Entwicklung als nützlich, in der das Mocken von Hardwarezugriffen elementar ist. Mit Aufrufen wie mock().expectOneCall("ReadAdc").andReturnValue(123); kann man Funktionen simulieren, ohne komplexe Wrapper schreiben zu müssen.

Die Integration von CppUTest erfolgt üblicherweise durch Herunterladen oder Klonen des CppUTest-Repositorys. Als Best Practice gilt, CppUTest als Submodul oder statische Bibliothek in ein Projekt einzufügen.

CppUTest arbeitet mit CMake zusammen. Teams können den Quellcode von CppUTest in ihr Projekt ziehen und es anschließend mit add_subdirectory(cpputest) integrieren. Folgender Code führt zudem das Linken in die Test-Targets durch:

add_subdirectory(cpputest) add_executable(tests test_led.cpp test_sha.cpp) target_link_libraries(tests CppUTest CppUTestExt)

Wer Tests auf einer speziellen Toolchain ausführen möchte, beispielsweise für ein Embedded-Linux-Target oder sogar direkt auf dem Mikrocontroller, kann versuchen, CppUTest mit einer Cross-Toolchain zu bauen. Das gelingt wegen der sparsamen Abhängigkeiten oft einfacher als mit GTest.

CppUTest stellt weniger spezielle Assertions bereit, sodass für hochgradig komplexe Vergleiche gegebenenfalls eigene Hilfsfunktionen erforderlich sind.

Beim Reporting ist CppUTest simpler als GTest. JUnit-Reports sind möglich, erfordern jedoch eventuell eigene Skripte. Wer komplexe Reporting-Funktionalitäten benötigt, muss potenziell in Handarbeit investieren.

CppUMock ist hervorragend geeignet, um C-Funktionen zu mocken. Das Mocking verläuft über eine zentrale Instanz (mock()). Beim Implementieren der Testfunktionen gilt es zu beachten, dass die Funktionssignaturen exakt übereinstimmen. Ein Stolperstein sind manchmal Variadic Functions oder Inline-Funktionen, die sich schwieriger mocken lassen.

Keep it simple: Genau wie das Framework sollten die Tests einfach und nachvollziehbar bleiben.

Das Mocking sollte beginnen, sobald hardwareabhängige Funktionen im Projekt existieren. Wichtig ist, die Schnittstellen klar zu definieren.

CppUMock erlaubt es, erwartete Aufrufe und Parameter zu definieren. Das sollten Teams konsequent nutzen, um sicherzustellen, dass ihr Code die erwarteten Funktionen mit den richtigen Parametern aufruft.

Bei Fehlschlägen zeigt CppUTest recht einfache Fehlermeldungen. Um Probleme schnell lokalisieren zu können, müssen Testnamen und Ausgaben eindeutig sein. Keine Scheu vor eigenen Assertions: Wer komplexe Datenstrukturen wie große Byte-Arrays oder kryptografische Ausgaben testen muss, sollte eigene Hilfsfunktionen oder Assertions schreiben, damit die Vergleiche aussagekräftig sind.

Gesamten Artikel lesen