Test-Driven-Development Erst testen, dann codieren

Softwareentwickler können oft erst sehr spät im Prozess ihre Anwendungen auf der Zielhardware ausführen. Dem möchte man mit der Entwicklungsmethodik Test-Driven-Development entgegensteuern. Dabei wird der Entwicklungsprozess von »Teste am Ende« zu »Teste als Erstes« umgestellt.

Hersteller in sicherheitskritischen Bereichen wie der Medizin- und Automobiltechnik sowie in der Luftfahrt kommen wegen der Zertifizierung nicht darum herum, ihre eingebettete Software nach den jeweils geforderten Standards (z.B. DO-178, ISO 26262, IEC 61508, EN 50128, IEC 62304) zu testen. Daher verwenden diese Firmen oft das »Test Driven Development« (TDD) als Methodik und setzen dazu automatisierte dynamische Analyse-Testwerkzeuge ein, beispielsweise »VectorCAST« von Vector Software. Solche Testtools eignen sich auch für den allgemeinen Markt der Embedded Systeme. Wo liegen die Herausforderungen und Chancen eines Test-Driven-Development-Prozesses?

Bei der traditionellen Entwicklung von Embedded-Software (Bild 1) kommen Werkzeuge für die Testautomatisierung in einer traditionellen Entwicklungsumgebung zum Einsatz, in der einzelne C++-Klassen getestet werden, sobald sie fertig entwickelt sind. Durch diesen reinen Modultest (Unit Test) kann der Entwickler sicherstellen, dass die Low-Level-Anforderungen, die einem Modul zugeschrieben werden, korrekt umgesetzt sind, während gleichzeitig eine Code-Coverage-Analyse die Vollständigkeit der Tests überprüft.

Da der Code an dieser Stelle bereits fertig geschrieben ist, gibt es eine Reihe von Problemen, die bei einem solchen traditionellen Entwicklungsansatz auftreten können: 

  • Mögliche Fehler im Code werden erst Tage oder sogar Wochen identifiziert, nachdem er geschrieben wurde. 
  • Es besteht das Risiko, dass die Entwickler Modultests basierend auf dem entwickelten Code erstellen, anstatt diese direkt von den Anforderungen abzuleiten. 
  • Der Code ist »überentwickelt«, denn es entsteht mehr Code, als erforderlich ist, um die Anforderungen an das Projekt zu erfüllen.

Aufgrund dessen kann es passieren, dass potenzielle Fehler im Code erst spät im Projektzyklus identifiziert werden. Dies bedeutet aber, dass die Kosten für die Fehlerbehebung höher ausfallen, je länger der Zeitraum zwischen der Einführung des Fehlers und dem letztendlichen Erkennen und Beheben des Fehlers ist (Bild 2). Zudem muss sich der Entwickler zum Beheben des Fehlers wieder ganz neu in
seinen Code hineindenken.

Eine gängige Lösung für dieses Problem ist es, manuelle Code-Reviews und statische Analysen in den Entwicklungsprozess mit einzubringen. Je nach Können des Gutachters und Komplexität der zu überprüfenden Algorithmen oder des Systemverhaltens können solche Reviews allerdings sehr kostspielig sein. Eine statische Analyse verifiziert lediglich, dass der Code weder unklar noch doppeldeutig ist. Sie überprüft nicht, ob die Anforderungen korrekt umgesetzt sind.

Folgendes gilt es auch noch zu beachten: Der Umfang der meisten Fehler ist ziemlich begrenzt. Studien zeigen, dass 80% der Fehler in 20% der Klassen oder Routinen eines Projektes vorkommen. Am häufigsten – genauer gesagt zu 95% – gehen die Fehler zulasten des Programmierers. 2% aller Fehler gehen auf Softwaresysteme (z.B. Compiler und OS) zurück, weitere 2% auf andere Software und 1% auf die Hardware. Daraus können wir schließen, dass wir die Mehrzahl der Fehler im System früh ausfindig machen können, wenn wir nur die Software der Programmierer früher testen könnten.

Was ist »Test Driven Development«?

Mithilfe des Test Driven Development (TDD) sollen sich zahlreiche dieser Probleme lösen lassen. Einer der größten Vorteile besteht darin, dass diese Methodik die Entwickler dazu zwingt, eher über das Testen basierend auf den Anforderungen sowie der Programmierschnittstellen nachzudenken als über die Details der Implementierung. Bei TDD wird der Zyklus aus Testentwicklung, Codierung und der Testdurchführung sehr oft in sehr kurzen Zykluszeiten wiederholt – im Extremfall nach jeder Zeile neu geschriebenen Codes. Dies fördert ein einfaches Design und verschafft viel früher einen Überblick. Das Konzept des TDD ist nicht sehr kompliziert und besteht aus vier Abschnitten (Bild 3): 

  • Der Entwickler erstellt eine Programmierschnittstelle (Application Programming Interface, API) für ein Modul. Die API formuliert die benötigten Eingabe- und Ausgabewerte (Input/Output). 
  • Der Entwickler testet anhand eines beispielhaften Falles, ob durch den hinzugefügten Code eine gewünschte Verbesserung eingetroffen ist. Verifiziert ist das Modul erst dann, wenn die Funktion ordnungsgemäß umgesetzt ist und der Test nicht mehr fehlschlägt. 
  • Der Entwickler implementiert die neue Logik für das Modul. Das Resultat ist das Bestehen des Tests. 
  • Der Entwickler modifiziert den neuen Code auf die Projektstandards. 

Ursprünglich entstammt dieses Konzept der sogenannten »Test First«-Programmierung, einem zentralen Bestandteil des »Extreme Programming«, das bereits im Jahr 1992 entstand. In den letzten Jahren hat sich jedoch das Test Driven Development zu einer eigenständigen vollwertigen Thematik entwickelt.

Für TDD wurden diese drei vereinfachten Regeln erstellt: 

  • Entwickler sollten keinen Code schreiben, bevor sie einen fehlschlagenden Modultest geschrieben haben. 
  • Sie sollten nur so viele Modultests schreiben, wie zum Scheitern unbedingt notwendig ist. Wobei auch ein Nichtkompilieren zum Scheitern zählt. 
  • Entwickler sollten nicht mehr Produktionscode schreiben, als zum Bestehen des noch scheiternden Tests notwendig ist. 

Zu beachten ist, dass in Bild 3 »Code Coverage 100%« als Gating-Condition zwischen »Tests Pass« und »Write (More) Application Code« eingefügt ist. 100-prozentige Codeabdeckung gehört zwar nicht zu den 
traditionellen Anforderungen der TDD, kann aber eine große Hilfe dabei sein, die dritte Regel dieser Entwicklungsmethodik umzusetzen.

Anforderungen an die Testwerkzeuge

Der Hauptunterschied zwischen TDD und dem Wasserfall-Testen bei C++ ist, dass beim TDD das Testen beginnt, sobald der Entwickler die erste Zeile des Codes geschrieben hat. Daraufhin wird parallel mit der Code-Entwicklung weiter getestet. Zudem ist es wichtig, Werkzeuge zu nutzen, die dieses Entwicklungsparadigma unterstützen. Zum Beispiel sind beim TDD die Inputs der Testwerkzeuge Header-Dateien (.h), anstelle von Quelldateien (.cpp). Einige Merkmale eines TDD-fähigen Testautomatisierungswerkzeugs erleichtern die Anpassungsfähigkeit an diese Entwicklungsprozesse: Unterstützung von inkrementeller Entwicklung, Rückverfolgbarkeit zwischen Anforderungen und Testfällen sowie Traceability zwischen Testfällen und Quellcode. Was bedeutet das konkret?

Mit diesen Tools sollten sich Testfälle anhand der Header-Datei für die zu testende Klasse oder Funktion erstellen lassen. Für jede Methode, die noch nicht codiert wurde, sollten die Werkzeuge automatisch Platzhalter erstellen. Letztendlich muss sich Code schrittweise (inkrementell) und parallel zu der Entwicklung der Testumgebung hinzufügen lassen. Als Ergebnis erhält der Entwickler die Möglichkeit, parallel zur Anwendungsentwicklung die Software zu testen.

Da die Testfälle zu bilden sind, bevor der zugrunde liegende Code geschrieben ist, basieren die Tests natürlich auf den Anforderungen und nicht auf den Implementierungsdetails. Daher ist es sinnvoll, die einzelnen Tests den zu prüfenden Anforderungen zuzuordnen. Das Testautomatisierungstool sollte die Möglichkeit bieten, jeden Testfall mit dem zu testenden Quellcode zu verknüpfen (Bild 4).

Ein Schlüsselkonzept des TDD ist es, so wenig Code zu schreiben wie nötig, um einen Test zu bestehen. In der Praxis wird diese Regel eingehalten, indem die Codeabdeckung überprüft wird, sobald der Code geschrieben ist. Da jeder einzelne Testfall exakt der dazugehörigen Codezeile zugeordnet ist, ist sichergestellt, dass nur der Code ausgeführt wird, der mit dem Test assoziiert ist.

Eine weitere Möglichkeit besteht darin, stets auf hundertprozentige Codeabdeckung zu achten, während der Entwickler inkrementell Module erzeugt. Folglich sollte er nur Logik zu einem Modul hinzufügen, die sich auf bestehende Tests bezieht. Daher ist es auch wichtig, dass ein Werkzeug zur Testautomatisierung jede Code Coverage direkt mit einem einzelnen bereits durchgeführten Testfall verbinden kann (Bild 5).

Die drei TDD-Gesetze werden eingehalten, wenn sichergestellt ist, dass die Testfälle auf den Anforderungen basieren und die Testfälle wiederum auf den Quellcode zurückgehen. Ein weiterer Vorteil ist, dass dadurch jedes einzelne Modul immer vollständiger geprüft wird. Dadurch können Entwickler Logik über längere Zeit refaktorieren, ohne dabei Leistungseinbußen befürchten zu müssen.

Beispiel zum Test Driven Development

Im Folgenden wollen wir ein einfaches Beispiel betrachten. Wir möchten eine neue Anwendung erstellen, und eines der Module soll für die Nachrichtenverarbeitung zuständig sein. Wir sind nicht sicher, in welchem Umfang der gesamte Funktionsumfang notwendig sein wird, aber wir sind sicher, dass wir in der Lage sein müssen, Nachrichten zu senden und zu empfangen. Es muss gewährleistet sein, dass diese Nachrichten aus einer variablen Anzahl von 32 Bit breiten Integer-Werten bestehen. Zunächst werden wir eine einfache C++-Header-Datei schreiben und eine Anweisung definieren, um Nachrichten zu senden, und eine, um Nachrichten zu empfangen (siehe »Listing 1«).

Listing 1

// Message.h

bool Send_Message( MessageIdType Message_Id, ByteSteam &MessageData, MessageSizeType MessageSize); bool Receive_Message( MessageIdType &Message_Id, ByteSteam &MessageData, MessageSizeType &MessageSize);

Anstelle mit den Implementierungsdetails der Anweisungen fortzufahren, verlangt die TDD, dass zunächst Testfälle für die beiden Anweisungen generiert werden. Dadurch müssen wir zuerst über die Designdetails und die Randbedingungen nachdenken, und beginnen erst danach, den Code zu schreiben. Für dieses Modul sollten zum Beispiel die folgenden Testfälle erstellt werden:

  • Wenn wir den Befehl »Receive_Message« nutzen, bekommen wir die Nachrichten in der Reihenfolge, in der sie gesendet wurden, oder basiert die Priorität der Nachrichten auf der ID?
  • Was passiert, wenn wir etwas empfangen und es keine ausstehenden Nachrichten gibt?
  • Was ist die maximal mögliche Nachrichtengröße, die gesendet werden kann? Ist Integer der am besten geeignete Datentyp für die »Message_Size«?
  • Wie umfangreich sind die »Message_Id«-Werte?
  • Was passiert, wenn eine ungültige »Message_Id« empfangen wird?

Hoffentlich finden wir die Antworten auf diese Fragen in den Anforderungen. Sobald wir die Antworten haben, können wir Tests entwerfen, mit denen das Verhalten unseres Moduls überprüft wird, wenn es mit einer der Randbedingungen konfrontiert wird. Erst wenn diese Tests erstellt wurden und fehlgeschlagen sind, beginnen wir mit der Umsetzung der Software, dem Codieren. Da das Modul stufenweise aufgebaut ist, werden die Testfälle die Tests bald bestehen.

Am Ende dieses iterativen Vorgehens erhalten wir zwei Artefakte: den fertigen Quellcode und eine vollständige Reihe von Tests, in denen das Verhalten dieses Quellcodes zum Ausdruck kommt.

On-Target-Tests in einer TDD-Umgebung

Eine der Herausforderungen in vielen Projekten ist es, den Übergang von einer nativen Testumgebung auf einem Host-System zu einer Testumgebung auf einer Embedded-Hardware zu bewerkstelligen. HIL-Tests (Hardware in the Loop) sind besonders wichtig für sicherheitskritische Anwendungen. Das Testen ist hier oft eine Voraussetzung für die Zertifizierung. Selbst bei Projekten mit weniger strengen Testanforderungen ermöglicht das Testen auf der Zielplattform das Auffinden einer größeren Anzahl von Fehlern.

Das Testen auf dem Zielgerät beinhaltet viele Herausforderungen, einschließlich:

  • begrenzte Zielspeicher und Schnittstellen-Ressourcen, 
  • Minimierung der Auswirkungen auf die Ausführungszeit, 
  • automatischer Download des Test-Images, 
  • automatische Datenerfassung, 
  • eingeschränkte Zielhardware und 
  • Training von Entwicklungsteams, um Probleme bei der Ausführung auf der Zielumgebung zu lösen.

All die vorgenannten Anforderungen an eine Testautomatisierungslösung für TDD möchte Vector Software mit seinem Werkzeug »VectorCAST« erfüllen. Der Kasten »Was kann VectorCAST?« geht näher darauf ein.

Über den Autor:

N. C. Rajadurai ist Director of EMEA bei Vector Software. 

 

Was kann VectorCAST? 

Vector Software verfügt über Werkzeuge, welche die Testautomatisierung für Test Driven Development erleichtern. Das wichtigste Ziel der »VectorCAST«-Testwerkzeuge ist es, Entwicklern zu ermöglichen, sich auf den Aufbau und die Ausführung von präzisen und effektiven Softwaretests zu konzentrieren. Die VectorCAST-Tools bieten folgende Funktionen: 

vollständig integrierter TDD-Workflow,
Integration mit Requirement-Management-Tools,
intuitives Erstellen von Testfällen basierend auf Modul-API,
integrierte Code-Coverage-Analyse und
vollautomatische HIL-Testdurchführung.

Bei der Verwendung von VectorCAST für TDD wird automatisch eine Testumgebung erstellt, die auf der Spezifikation des zu testenden Codes basiert. Die fehlenden Methoden oder Funktionen werden automatisch durch die Tools »gestubbed«, um eine ausführbare Testumgebung zu gewährleisten. Zusätzlich aktualisiert VectorCAST die Testumgebung jedes Mal, wenn der Code aktualisiert wird, und erstellt ein Update für neue Logik. Dieser Prozess geschieht nahtlos, sodass der Entwickler sofort einen neuen Test durchführen kann, wenn er eine neue Zeile Code geschrieben hat.

Die Tools von Vector Software verfügen über eingebaute Schnittstellen für eine Vielzahl von Werkzeugen für das Anforderungsmanagement, zum Beispiel »Polarion«, »Doors« und »Requisite Pro«. Zusätzlich ist für Projekte, die nicht mit einem gängigen Requirements-Management-Tool arbeiten, eine vom Benutzer konfigurierbare Schnittstelle für CSV-Dateien verfügbar. Durch diese Funktion können Entwickler eine bidirektionale Verbindung zwischen Anforderungen und Tests erstellen. Daraus ergibt sich, dass die Zuordnung von Anforderungen zu Tests entweder von dem Anforderungsmanagement-Werkzeug oder von VectorCAST selbst generiert werden kann.

VectorCAST erstellt Testfälle immer basierend auf der API der Unit oder des zu testenden Moduls. Dies fügt sich perfekt in das TDD-Konzept ein, bei dem die Tests auf den Schnittstellen und nicht auf den Details der Implementierung basieren. Die grafische Oberfläche ist laut Hersteller intuitiv zu bedienen und unterstützt Schnittstellen von beliebiger Komplexität.

Die Tools bieten eine vollständige Code-Coverage-Analyse für alle Phasen der Entwicklung, angefangen beim Codieren des Moduls über die Integration bis zum Systemtest. Daten zur Codeabdeckung werden immer auf der Grundlage des Testfalles gepflegt, der die Daten verursacht hat. Während der frühen Entwicklungsphasen bleibt so gewährleistet, dass im gesamten Lebenszyklus der Anwendung keine unnötige, »zusätzliche« Logik implementiert wird. Dies stellt auch sicher, dass immer vollständig getestet werden kann.

Die VectorCAST-Tools bieten laut Hersteller einen hohen Grad an Automatisierung für HIL-Tests, unabhängig von der Komplexität, die mit dem Herunterladen und Ausführen der Tests verbunden ist. Die HIL-Komplexität wird in einem dünnen Layer zwischen der Kernfunktion des Tools und dem Zielgerät extrahiert. Dies bedeutet, dass alle Tool-Funktionen, einschließlich der TDD-Workflow-Funktionen, für die Entwickler verfügbar sind, unabhängig von den verwendeten Entwicklungswerkzeugen und der Zielhardware.