Test Driven Development (TDD) ist eine Entwicklungsmethode, die durch den Test-First-Ansatz von Anfang an eine hohe Testabdeckung des entstehenden Codes sicherstellen soll. Vor allem aber wird der Entwickler gezwungen, sich vor der Implementierung Gedanken zu machen, welches Verhalten sein Modul haben soll, und dieses Verhalten in Form von Tests festzuhalten und damit zu formalisieren.
Die Methode wird bereits in vielen Desktop-, Web- und Serverapplikationen erfolgreich eingesetzt.
Herausforderungen in Embedded Software
Es gibt eine Reihe von Erklärungen, die üblicherweise angeführt werden, um zu begründen, warum TDD für Embedded Software nicht funktionieren kann. Die häufigsten Gründe sind:
- Embedded Software ist stark verknüpft mit der Hardware.
- Hardware ist nicht von Anfang an verfügbar.
- Elektronische Komponenten oder Leiterplatten haben Fehler.
- Schaltpläne und Hardware stimmen nicht überein.
- Annahmen aus Spezifikationen, Datenblättern usw. treffen in der Hardware nicht zu.
- Legacy Code
Nicht selten hat man es zusätzlich mit existierendem Code zu tun. Dies ist zwar nicht alleine ein Problem von Embedded Software, soll hier aber dennoch erwähnt werden.
Für all diese Herausforderungen gibt es aber Lösungen. Wie diese aussehen, wird im Folgenden in einer Reihe von Strategien und Techniken gezeigt.
Weg von der Hardware
In Anbetracht der Punkte 1. bis 5. ist der erste Schritt schnell klar. Wir wollen uns so weit wie möglich von der Hardware und dem Zielplattform-Compiler abkoppeln. Denn die Logik der Software sollte unabhängig von der Hardware, dem Compiler und der Laufzeitumgebung korrekt funktionieren. Um mögliche Probleme besser isolieren zu können, konzentrieren wir uns daher erst einmal auf die Entwicklung auf dem Hostsystem mit einer stabilen Umgebung. Eine solche bietet auch die Möglichkeit, die häufig luxuriösen Tools für statische Codeanalyse, Code Coverage, Profiling, Speicher-Instrumentierung (z. B. Valgrind) usw. zu nutzen.
Abkoppeln der Abhängigkeiten
Um diese Abkopplung von der Hardware zu erreichen, gibt es eine Reihe von Techniken, die für TDD schon eingesetzt werden und die auch für Embedded Software gut wieder verwendet werden können. Der klassische Ansatz bei objektorientierter Software besteht darin, ein Objekt, von welchem das getestete Objekt abhängt, durch ein geeignetes Stub-, Spy- oder Mock-Objekt auszutauschen. Damit dies überhaupt erst möglich wird, muss die Software aber schon entsprechend entwickelt worden sein, zum Beispiel unter Verwendung des Dependency-Inversion-Prinzips [7].
Michael Feathers erwähnt in seinem Buch [2] aber ein Konzept, wo er dieses Prinzip verallgemeinert, sodass es auch für nicht objektorientierte Software funktioniert. Er nennt das Konzept «Seam», zu Deutsch in etwa «Nahtstelle». Die Grundidee besteht darin, Stellen im Code zu finden, wo das Verhalten geändert werden kann, ohne den Code an dieser Stelle selbst ändern zu müssen.
Für C-Code zum Beispiel sind die folgenden Techniken sehr interessant, da dort die Möglichkeiten aus OOP fehlen. Zum einen kann die Testapplikation statt der produktiven Bibliothek eine Testbibliothek einbinden. Mit dieser können dann z. B. alle Funktionsaufrufe geloggt und gegen eine erwartete Sequenz verglichen werden oder es können passende Testwerte zurückgegeben werden.
Weiterhin ist es auch möglich, eine Funktion durch einen Function-Pointer zu ersetzen. Im produktiven Code wird dieser Pointer standardmässig auf die produktive Funktion gesetzt, im Testcode wird der Pointer aber auf eine Testfunktion umgesetzt.
Eine letzte Möglichkeit besteht darin, mit C/C++-Makros Testcode in die Applikation zu injizieren. Mit der GCC-Option –D kann beispielsweise ein Funktionsaufruf auf eine Testfunktion umgebogen werden oder es kann mit -include ein spezieller Testheader eingefügt werden, um die Testapplikation zu bauen. Es ist allerdings zu beachten, dass in diesem Fall das Prüfobjekt selbst geändert wird, was unerwartete Effekte haben kann.
Dual Targeting
Trotz der Abkopplung von der Hardware wird der Zielcompiler immer wieder hinzugezogen, um den Kontakt mit dem Zielsystem nicht zu verlieren. Dieser Ansatz verursacht aber auch eine Reihe von weiteren Herausforderungen, die die bisherige Liste wie folgt erweitern.
7. Unterschiedliche Fehler in Host- und Ziel-Compilern.
8. Unterschiedliche Unterstützung für Sprachkonstrukte der Compiler.
9. Fehler in den Plattform spezifischen Runtime Libraries.
10. Inkompatible Unterschiede zwischen Header Files der Target- und Host- Umgebungen.
James Grenning nennt den Ansatz, Host und Ziel gleichzeitig zu adressieren, in seinem Buch [1] «Dual Targeting». Er zeigt dort auch auf, wie diese neuen Herausforderungen angegangen werden können und schlägt dazu eine Erweiterung des TDD-Zyklus vor.
Anpassung des TDD-Zyklus
Die Zielplattform darf während der Entwicklung nicht vergessen werden. Wird dies zu lange vernachlässigt, kann es dazu führen, dass die Punkte 7. bis 10. zu grossen Problemen werden und später viel Zeit zur Fehleranalyse und Problembehebung aufgewendet werden muss.
Um dies zu verhindern und möglichst schnell auf potenzielle Probleme aufmerksam gemacht zu werden, wird der klassische TDD-Zyklus erweitert. Die bestehenden Schritte «Test Schreiben – Implementieren – Refactoring» werden um zusätzliche Testschlaufen ergänzt. In Abbildung 2 sind diese Schlaufen von links nach rechts mit abnehmender Häufigkeit der Ausführung dargestellt.
Erweiterter TDD-Zyklus
In der ersten Schlaufe wird der Quellcode immer mal wieder auch für die Zielplattform kompiliert. Dies kann zum Beispiel passieren, sobald neue Header Files oder neue Sprachkonstrukte benutzt werden oder wenn eine neue Bibliotheksfunktion aufgerufen wird. Optimalerweise ist ein entsprechender Schritt in der Continuous-Integration-Umgebung automatisiert. In dieser Schlaufe ist es noch nicht wichtig, das Resultat auch tatsächlich auf der Zielplattform auszuführen. Es reicht zu überprüfen, ob die verwendeten Header Files und Sprachkonstrukte vom Zielcompiler unterstützt werden. Diese erste Schlaufe hilft die Probleme 8. bis 10. frühzeitig zu erkennen.
Mit der zweiten und dritten Schlaufe werden die Unit-Tests auf der eigentlichen Zielhardware laufen gelassen. Je nach Verfügbarkeit von Speicher kann dies entweder auf einem Evalboard oder sogar schon auf der tatsächlichen Target-Hardware passieren. Mit diesem Schritt sind die Probleme von Punkt 1. und 3. bis 5. abgedeckt.
In der letzten Schlaufe geht es auf die eigentliche Hardware, um die Herausforderungen 1. bis 5. auf Integrationsstufe zu adressieren. Dazu werden Akzeptanztests auf dem Gesamtsystem unter Benutzung der Zielhardware gefahren. Optimalerweise laufen diese Tests ebenfalls automatisiert. Dies ist in der Realität aber häufig schwierig, da externe Instrumentierung oder gar manuelle Interaktionen und Kontrollen nötig sind.
So tun als ob
Eine weitere Option, um sich von der Hardware abzukoppeln und besonders das Problem von Punkt 2. anzugehen, besteht darin, einen Simulator für die Hardware zu entwickeln oder einen bestehenden Simulator einzusetzen. Ein solcher Simulator kann auch helfen, die letzte Schlaufe des erweiterten TDD-Zyklus bis zu einem gewissen Grad zu automatisieren.
Wichtig ist dabei, den Simulator nicht von Anfang an als vollständigen Ersatz der Hardware zu planen. Je kompletter und detaillierter der Simulator werden soll, desto aufwendiger ist die Umsetzung. Am Anfang eines Projekts hilft es aber oft schon, wenn die Simulation einige einfache, vordefinierte Antworten erzeugen kann. Der Detaillierungsgrad kann mit der Entstehung der Applikation stetig erhöht werden.
Schritte für einen Übergang
Wer mit TDD anfangen will, hat nicht oft die Gelegenheit, dies auf grüner Wiese zu tun, und muss sich folglich mit bestehendem Code beschäftigen. Dabei ist bestehender Code häufig schlecht oder gar nicht automatisch getestet und auch nicht für gute Testbarkeit geschrieben worden. Dies erfordert eine vorsichtige Herangehensweise, um nicht existierende Software mit fehlender Testabdeckung durch grobe Umbauten zu destabilisieren.
Üblicherweise ist ein Projekt gemäss Abbildung 3 anfangs in der Situation 1. oder 2. Das heisst, entweder wird das gesamte System nur mithilfe von manuellen Tests überprüft oder es existieren ein paar grobe Tests für einen Teil der Software. Das Ziel bei der Einführung von TDD ist es, die Situation 5. zu erreichen. Der gesamte Code ist dann mit TDD entstanden und ausreichend mit Tests abgedeckt. Im besten Fall gibt es auch automatisierte Akzeptanztests über das gesamte System in Form einer simulierten Umgebung (wie in Situation 6.) oder durch automatisierte Hardware (wie in Situation 7.)
Da in bestehendem Code nicht von heute auf morgen alle bestehenden Module mit Tests versehen werden können, ist eine Strategie nötig, um sich Schritt für Schritt dem Ziel zu nähern. Eine Möglichkeit besteht darin, Defect Driven Development zu betreiben. Für jeden neuen Defekt wird erst ein neuer Test geschrieben, der genau diesen Defekt reproduziert. Nachdem der Defekt behoben ist, läuft der Test erfolgreich durch, und man wird in Zukunft rechtzeitig gewarnt, wenn der Defekt wieder auftreten sollte. Dieser Ansatz ist in Situation 3. dargestellt.
Eine andere Möglichkeit besteht darin, wie in Situation 4. dargestellt, mindestens den neu entstehenden Code mit TDD zu entwickeln. Feathers schlägt dafür ein Verfahren namens Sprout-Method oder Sprout-Class vor. Man sorgt dafür, dass die neue Funktionalität möglichst unabhängig vom existierenden Code entsteht. Statt eine bestehende Funktion zu erweitern, schreibt man z. B. eine neue Funktion, die vollständig getestet werden kann und die dann aus dem alten Code aufgerufen wird. Oder man lagert neu entstehenden Code in einer eigenen neuen Klasse aus.
Verschiedene Ausbaustufen für Embedded Testing
Die Vorteile
Die Einführung von TDD für Embedded Software erfordert initialen Zusatzaufwand. Es darf nicht unterschätzt werden, dass TDD nicht einfach einen Umstieg bedeutet von «Tests nach dem Codieren schreiben» zu «Tests vor dem Codieren schreiben». TDD ist eine andere Art der Softwareentwicklung und erfordert einige Übung, bevor sie wirklich erfolgreich funktioniert. Einen Einstieg bieten [3] und [6], die wichtigsten Best Practises sind in [5] aufgelistet.
Aber wenn die Methodik erfolgreich umgesetzt wurde, ergeben sich vielfältige Vorteile. Diese Arbeitsweise wirkt sich aus auf das Softwaredesign, das Vertrauen in die Codebasis, die Entwicklungs- und die Debugging-Zeiten.
- Es sind weniger häufig mühsame Debugging-Sitzungen nötig, da logische Probleme früh und noch im Hostsystem während der Entwicklung durch entsprechende Tests gefunden werden.
- Existierende Tests geben später mehr Sicherheit beim Refactoring von bestehendem Code, da sie schnell informieren, wenn bestehendes Verhalten gebrochen wird.
- Das erwartete Verhalten einer Komponente ist durch die Tests dokumentiert und durch die Ausführbarkeit auch automatisch überprüft.
- Es muss weniger häufig die gesamte Software für das Target kompiliert und heruntergeladen werden, um Fehler zu analysieren und zu lösen, da mit einer soliden TDD-Umgebung viele dieser Probleme auch ohne echte Hardware analysiert und gelöst werden können.
- Die Software kann einfacher auf neue Hardware migriert werden, da sie bereits gut von der verwendeten Hardware abstrahiert ist.
Zusammenfassung
Wir haben gesehen, dass sich bei der Einführung von TDD in Embedded Software einige spezielle Herausforderungen ergeben. Durch den Einsatz von Dual Targeting, geeigneter Seams und einem erweiterten TDD-Zyklus ist es aber möglich, auch hier mit Test Driven Development zu arbeiten.
Die Vorteile wurden in verschiedenen Studien [8] [9] [10] untersucht und nachgewiesen. Auch in einem bestehenden Projekt ist es sinnvoll, sich über TDD Gedanken zu machen und eine schrittweise Einführung zu planen. Wichtig ist dabei eine seriöse Auseinandersetzung mit dieser etwas anderen Entwicklungsmethodik.
Literatur- und Quellenverzeichnis
[1] James W. Grenning, Test-Driven Development for Embedded C, The Pragmatic Programmers 2011.
[2] M. Feathers, Working Effectively with Legacy Code, Prentice Hall 2004.
[3] K. Beck, Test-Driven Development by Example, Addison-Wesley – Vaseem 2003.
[4] M. Estermann, QDD in Brownfield Projekten, bbv Booklets 2013; https://bbv.ch/de/unternehmen/publikationen.html.
[5] U. Enzler, Clean TDD Cheat Sheet, planetgeek.ch 2013; http://www.planetgeek.ch/wp-content/uploads/2013/06/Clean-Code-V2.2.pdf.
[6] http://en.wikipedia.org/wiki/Test-driven_development
[7] http://de.wikipedia.org/wiki/Dependency-Inversion-Prinzip.
[8] D. Janzen, H. Saiedian, Does Test-Driven Development Really Improve Software Design Quality? http://digitalcommons.calpoly.edu/cgi/viewcontent.cgi?article=1027&context=csse_fac
[9] C. Sims, Empirical Studies Show Test Driven Development Improves Quality, 2009; http://www.infoq.com/news/2009/03/TDD-Improves-Quality
[10] E. M. Maximilien, L. Williams, Assessing Test-Driven Development at IBM; http://collaboration.csc.ncsu.edu/laurie/Papers/MAXIMILIEN_WILLIAMS.PPD