Unittests mit uCUnit
Einleitung
Modultests (Unittests) haben sich in der Softwareentwicklung zum Stand der Technik in der Softwarequalitätssicherung etabliert.
Es gibt für die verschiedensten Programmiersprachen eine Reihe von Unit-Testframeworks, die Modultests unterstützen. Für die Programmiersprache C, die häufig bei Mikrocontrollern eingesetzt wird, gibt es mittlerweile auch eine Reihe von Frameworks:
Im folgenden soll es über die Verwendung von uCUnit gehen, einem speziellen Unit-Testframework für Mikrocontroller.
uCUnit
Das Unit-Testframework uCUnit wurde im Hinblick auf den Einsatz auf Mikrocontrollern entworfen:
- Benötigt keine Standardbibliotheken
- Benötigt keine dynamische Speicherverwaltung
- Benötigt wenig Speicherplatz
- Portabel
- Anpassbar
- Einfach zu integrieren: Das ganze Framework besteht aus einer einzigen Headerdatei
Vor dem Start
Zunächst lädt man sich die aktuelle Version (zur Zeit der Erstellung dieses Artikels v1.0) von der Website herunter. Das Paket ist ein ZIP-Archiv. Dieses kann man an einen beliebigen Ort entpacken. Darin enthalten ist bereits ein kleines Demonstrationsprojekt, das als Basis für die eigene Testumgebung dienen kann.
Anpassungen
Da es für die unterschiedlichen Zielplattformen unterschiedliche Hardware gibt, muss man ein paar Anpassungen an uCUnit vornehmen.
uCUnit benutzt zur Hardwareabstraktion folgende Makros:
- UCUNIT_Init(): Initialisierung der Hardware
- UCUNIT_Shutdown(): Kontrolliertes Herunterfahren
- UCUNIT_Safestate(): Bringt das System in einen sicheren Zustand, wenn ein Test fehlschlägt
- UCUNIT_Recover(): Holt das System aus dem sicheren Zustand
- UCUNIT_WriteString(msg): Kapselt die Funktion zur Ausgabe eines Strings an den Hostcomputer
- UCUNIT_WriteInt(n): Kapselt die Funktion zur Ausgabe eines Integerwertes an den Hostcomputer. Wird für die Zusammenfassung der Testergebnisse benötigt
uCUnit benötigt für diese Makros konkrete Implementierungen. Im folgenden wird das Beispielprojekt als Basis für die Anpassungen genommen.
Im Beispielprojekt ist der hardwareabhängige Teil in System.h definiert und in System.c als Stubs implementiert:
void System_Init(void);
void System_Shutdown(void);
void System_Safestate(void);
void System_Recover(void);
void System_WriteString(char * msg);
void System_WriteInt(int n);
Über die defines in der sog. "Customzing Area" gibt man bekannt, welche Funktion für das Makro aufgerufen werden soll. Der Präprozessor ersetzt dann die Makroaufrufe mit den konkreten Funktionen.
#define UCUNIT_Init() System_Init()
#define UCUNIT_Shutdown() System_Shutdown()
#define UCUNIT_Safestate() System_Safestate()
#define UCUNIT_Recover() System_Recover()
#define UCUNIT_WriteString(msg) System_WriteString(msg)
#define UCUNIT_WriteInt(n) System_WriteInt(n)
Das mitgeliefert Beispiel lässt sich für den i386 mit dem GNU gcc kompilieren und direkt ausführen, so dass man ein Gefühl dafür bekommt, wie die Ausgabe aussieht. Im Verzeichnis arm befindet sich ein Makefile, das man mit Hilfe arm-elf-gcc für den ARM Prozessor kompilieren kann und mit Hilfe von arm-elf-run auch ausführen kann.
Prinzip
Während bei den üblichen Unit-Testframeworks sog. Assertions zum Einsatz kommen, wird in uCUnit ein etwas anderes Konzept verwendet, das von "Design-by-Contract" inspiriert wurde.
Assertions brechen das Programm ab - dies kann bei Embedded Systems je nach Anwendung und Situation kritisch sein. Zunächst muss die Hardware in einen sicheren Zustand gebracht werden oder kontrolliert heruntergefahren werden.
In uCUnit sind die zentralen Element sog. Checks, die in einer Checkliste organisiert sind. Zu Beginn einer Checkliste teilt man mit, welche Aktion im Falle eines fehlgeschlagenen Checks ausgeführt werden soll.
Aufbau eines Testfalls
Ein Testfall baut sich im Prinzip immer so auf:
Testfall Beginn
/* Checkliste für Vorbedingungen */
Checkliste Beginn ( Aktion )
Check 1
Check 2
Check 3
...
Checkliste Ende
Ausführung Code, Funktionsaufruf, etc.
/* Checkliste für Nachbedingungen */
Checkliste Beginn ( Aktion )
Check 1
Check 2
Check 3
...
Checkliste Ende
Testfall Ende
Beispiel Flugzeuglandung
Ein Beispiel verdeutlicht das Konzept:
Vor einer Flugzeuglandung wird eine Checkliste abgearbeitet:
- Ist das Fahrwerk ausgefahren und eingerastet?
- Sind die Landeklappen in der richtigen Stellung?
- Sind die Bremsen bereit?
Erst wenn die komplette Checkliste ohne Fehler abgearbeitet ist, gibt es grünes Licht zur Landung.
Wenn man sich nun vorstellt, die Landung des Flugzeugs zu automatisieren, könnte man eine Funktion PrepareLanding() schreiben. Diese soll die notwendingen Tätigkeiten ausführen, die zur Landevorbereitung nötig sind.
Die Implementierung der Funktion PrepareLanding() soll nun getestet werden. Konkret würde das in etwa so aussehen:
void Test_PrepareLanding(void)
{
UCUNIT_TestcaseBegin("PrepareLanding()");
/* Checkliste für Vorbedingungen */
UCUNIT_ChecklistBegin(UCUNIT_ACTION_WARNING); /* Fehlgeschlagene Checks werden nur ausgegeben */
UCUNIT_CheckIsEqual(GEAR_STATUS_INSIDE, gear_status); /* Ist das Fahrwerk drinnen? */
UCUNIT_CheckIsEqual(FLIGHT_ANGLE, landing_flaps); /* Sind die Landeklappen im Normalzustand? */
UCUNIT_CheckIsEqual(BREAK_STATUS_OPEN, break_status); /* Sind die Bremsen geöffnet? */
UCUNIT_ChecklistEnd()
PrepareLanding();
/* Checkliste für Nachbedingungen */
UCUNIT_ChecklistBegin(UCUNIT_ACTION_SAFESTATE);
UCUNIT_CheckIsEqual(GEAR_STATUS_OUTSIDE, gear_status); /* Ist das Fahrwerk draussen? */
UCUNIT_CheckIsEqual(LANDING_ANGLE, landing_flaps); /* Sind die Landeklappen im richtigen Landewinkel? */
UCUNIT_CheckIsEqual(BREAK_STATUS_READY, break_status); /* Sind die Bremsen bereit? */
UCUNIT_ChecklistEnd();
UCUNIT_TestcaseEnd();
}
Code Coverage
Für sicherheitskritische Systeme ist eine gewisse Testabdeckung vorgeschrieben. Hierfür bietet uCUnit sogenannte Tracepoints. Diese werden in den Testcode eingebaut.
Wenn ein Tracepoint ausgeführt wird, ändert er seinen Status. Dieser kann dann später mit Hilfe von Checks abgefragt werden. Deutlicher wird die Verwendung, wenn man sich das mitgelieferte Beispiel ansieht.
Praxis
In der Praxis hört man vielfach, dass man Embedded Systems nur schlecht testen kann, weil man direkten Hardwarezugriff benötigt. Aber es gibt viele Funktionen, die keinen Hardwarezugriff benötigen. Gerade im Bereich der digitalen Signalverarbeitung sind Algorithmen und Berechnungen zu testen.
Beispiele wären:
- Ringpuffer Funktionen
- Berechnung von Checksummen
- FFT
- PID-Regler
- Filter
- Skalierung von Sensordaten
- ...
Aber auch Treiber kann man testen, wenn man im Design des Treibers auf das Testen auslegt. So kann z. B. ein Loopback-Interface verwendet werden. Das Zeichen das auf die Schnittstelle geschrieben wird, muss auch wieder beim Einlesen ankommen.
Eine weitere Möglichkeit ist, z. B. Sensordaten aufzunehmen und diese als Array im Testfall abzuarbeiten.
Zusammenfassung
Mit Hilfe von Unit-Testframeworks ist es möglich, Modultests zur Qualitätssicherung durchzuführen. uCUnit bietet speziell angepasste Funktionen, die man im Bereich von Embedded Systems und Mikrocontrollern einsetzen kann.
Im Hinblick auf die Verwendung von Funktionen für mehrere Prozessoren, müssen diese auf jeder Plattform korrekt funktionieren - und dafür sind Tests unerlässlich wenn man keine bösen Überraschungen erleben möchte, die dann in tagelanger Sucherei im Code enden.
Downloads
Homepage und Download: http://www.ucunit.org
Siehe auch
- Check: http://check.sourceforge.net/
- CUnit: http://cunit.sourceforge.net/
- MinUnit: http://www.jera.com/techinfo/jtns/jtn002.html
- Embedded Unit: http://embunit.sourceforge.net/