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

Seite 2: Entscheidungshilfe: GTest oder CppUTest

Inhaltsverzeichnis
close notice

This article is also available in English. It was translated with technical assistance and editorially reviewed before publication.

FĂĽr groĂźe Projekte mit umfangreichem Testbedarf und C++-Struktur ist GTest oft die erste Wahl. Sind flexible Assertions, komplexe Fixtures, Parametrisierung und vielleicht komplexe Mocks von objektorientierten Interfaces erforderlich, ist es ideal. CppUTest bietet sich dagegen an, wenn Projekte C-lastig sind, die Tests frĂĽh im Embedded-Kontext laufen sollen und einfaches Mocking von C-Funktionen gewĂĽnscht ist.

In einer komfortablen PC-Umgebung, in der C++11 oder höher und die C++-Standardbibliothek kein Problem darstellen, ist GTest leicht zu integrieren. Für Tests auf ressourcenarmen Systemen oder mit Cross-Compilern und wenigen Abhängigkeiten ist CppUTest besser geeignet.

Da GTest weit verbreitet ist, existiert eine große Community, und es gibt zahllose Beispiele und Hilfen online. CppUTest ist ebenfalls verbreitet, aber etwas spezifischer. Dafür ist das Framework einfacher zu verstehen. Die Lernkurve ist flacher, weil weniger "Magie" passiert. Das kann ein Vorteil für Embedded-Entwickler sein, die sich neu mit Test Driven Development beschäftigen. Dafür ist die Dokumentation von CppUTest leider ausbaufähig.

TDD ist langfristig angelegt. Tests dienen als Dokumentation und Sicherheitsnetz. Ein schlankes Framework wie CppUTest ist langfristig wartungsfreundlich und lässt sich meist einfach auf neue Compiler oder Targets anpassen. Dass Google GTest stetig weiterentwickelt, bietet einerseits Vorteile, kann aber zu Inkompatibilitäten führen.

Zusammengefasst können sowohl Google Test als auch CppUTest dabei helfen, in Embedded-C-Projekten testgetriebene Entwicklung umzusetzen. Die Entscheidung für ein Framework hängt von den konkreten Rahmenbedingungen ab. Für ressourcenarme Embedded-Umgebungen, C-lastige Codebasen und einfache Integration ohne viel Overhead ist CppUTest die bessere Wahl. Für Projekte, die von Anfang an in C++ entwickelt werden, auf dem Host laufen, eine große Testinfrastruktur benötigen und von umfangreichen Features und Tooling profitieren möchten, ist Google Test vorteilhaft.

Best Practices beim Einsatz beider Frameworks umfassen das konsequente Kapseln von Hardwarezugriffen, den Einsatz von Mocks, schlanke, gut strukturierte Tests und eine saubere Build-Integration via CMake oder andere Build-Systeme. Stolpersteine gibt es in beiden Welten, doch mit einer klaren Strategie, regelmäßigen Refactoring-Schritten und einer sorgfältigen Testorganisation lassen sich diese meistern

Als Beispiel fĂĽr TDD im Embedded-Umfeld dient ein einfaches Modul, das LEDs auf einem Embedded-Board steuert. Dabei gibt es folgende Aufgabenstellung:

Es gibt drei LEDs (rot, grĂĽn, blau), die ĂĽber ein Register an einem bestimmten Speicherort ein- und ausgeschaltet werden.

Eine API stellt drei Funktionen bereit: void Led_Init(void) initialisiert das LED-Subsystem. void Led_SetRed(bool on) schaltet die rote LED ein oder aus. bool Led_IsRedOn(void) fragt den Status der roten LED ab. Die letzten beiden Funktionen existieren zusätzlich für die grüne und die blaue LED.

In der Praxis erfolgt die Umsetzung direkt mit einem Hardwareregister. Für den Test kommt jedoch ein Mock zum Einsatz: Eine globale Variable repräsentiert das LED-Register. Auf dem Host lassen sich auf die Weise Bit-Manipulationen simulieren.

TDD dient dazu, Schritt für Schritt die LED-Funktionen zu implementieren. Dabei entstehen zunächst die Tests (Red), dann wird der minimale Code implementiert (Green) und anschließend verbessert (Refactor).

Diese Schritte erfolgen fĂĽr jede neue Funktionsweise.

Das Beispiel erhält folgende Verzeichnisstruktur:

project/
├─ src/
│ ├─ led.c
│ └─ led.h
└─ test/
├─ google_test/
│ ├─ CMakeLists.txt
│ └─ test_led.cpp
├─ cpputest/
│ ├─ CMakeLists.txt
│ └─ test_led.cpp
└─ mock/
└─ mock_register.h
  • src/ enthält die Produktionsquellen.
  • In test/ liegen die Testdateien. Es gibt getrennte Unterordner fĂĽr Google Test und CppUTest, um beide Ansätze demonstrieren zu können.
  • h dient als Mock fĂĽr das Hardware-Register.

Angenommen, die Registeradresse ist 0x40001000. Auf der realen Plattform erfolgt die Abfrage über einen volatile-Zugriff, der anzeigt, dass der Wert von der Hardware geändert werden kann:

#define LED_REGISTER (*(volatile uint32_t*)0x40001000)

Im Host-basierten Test simuliert eine in mock_register.h definierte globale Variable den Zugriff auf die Hardware:

#ifndef MOCK_REGISTER_H
#define MOCK_REGISTER_H

#include <stdint.h>

extern uint32_t MOCK_LED_REGISTER;

#endif

In der Testumgebung kommt MOCK_LED_REGISTER als Ersatz fĂĽr LED_REGISTER zum Einsatz. In der endgĂĽltigen Anwendung entscheidet ein #ifdef TEST-Block, ob der Code auf LED_REGISTER oder MOCK_LED_REGISTER zugreift.

Die Implementierung der Header-Datei led.h sieht folgendermaĂźen aus:

#ifndef LED_H
#define LED_H

#include <stdbool.h>
#include <stdint.h>

void Led_Init(void);
void Led_SetRed(bool on);
bool Led_IsRedOn(void);

void Led_SetGreen(bool on);
bool Led_IsGreenOn(void);

void Led_SetBlue(bool on);
bool Led_IsBlueOn(void);

#endif // LED_H

Das Grundgerüst von led.c enthält zunächst keine Implementierungen der Funktionen:

#include "led.h"

#ifdef TEST
#include "mock_register.h"
#define LED_REGISTER MOCK_LED_REGISTER
#else
#define LED_REGISTER (*(volatile uint32_t*)0x40001000)
#endif

#define RED_LED_BIT   (1U << 0)
#define GREEN_LED_BIT (1U << 1)
#define BLUE_LED_BIT  (1U << 2)

void Led_Init(void) {
    // Noch keine Implementierung
}

void Led_SetRed(bool on) {
    // Noch leer
}

bool Led_IsRedOn(void) {
    return false; // Platzhalter
}

void Led_SetGreen(bool on) {
    // Noch leer
}

bool Led_IsGreenOn(void) {
    return false; // Platzhalter
}

void Led_SetBlue(bool on) {
    // Noch leer
}

bool Led_IsBlueOn(void) {
    return false; // Platzhalter
}

Die Datei test/google_test/test_led.cpp erhält zunächst einen Test, der fehlschlägt. Der folgende Code zeigt beispielhaft den ersten Testfall für die rote LED:

#include <gtest/gtest.h>
#include "led.h"
#include "mock_register.h"

uint32_t MOCK_LED_REGISTER;

class LedTest : public ::testing::Test {
protected:
    void SetUp() override {
        MOCK_LED_REGISTER = 0x00000000;
        Led_Init();
    }
};

TEST_F(LedTest, RedLedOffAfterInit) {
    // Nach der Initialisierung soll die rote LED aus sein
    EXPECT_FALSE(Led_IsRedOn());
}

TEST_F(LedTest, TurnRedLedOn) {
    Led_SetRed(true);
    EXPECT_TRUE(Led_IsRedOn());
    // ĂśberprĂĽfen, ob das entsprechende Bit gesetzt ist
    EXPECT_EQ((uint32_t)(RED_LED_BIT), MOCK_LED_REGISTER & RED_LED_BIT);
}

TEST_F(LedTest, TurnRedLedOff) {
    Led_SetRed(true);
    Led_SetRed(false);
    EXPECT_FALSE(Led_IsRedOn());
    EXPECT_EQ((uint32_t)0, MOCK_LED_REGISTER & RED_LED_BIT);
}

Das Projekt befindet sich somit in der Red-Phase: Die Tests existieren, schlagen aber fehl, da Led_Init() und die anderen Funktionen noch nicht korrekt implementiert sind.

Folgende Anpassung in led.c sorgt dafĂĽr, dass der Code den Test besteht:

#include "led.h"

#ifdef TEST
#include "mock_register.h"
#define LED_REGISTER MOCK_LED_REGISTER
#else
#define LED_REGISTER (*(volatile uint32_t*)0x40001000)
#endif

#define RED_LED_BIT   (1U << 0)
#define GREEN_LED_BIT (1U << 1)
#define BLUE_LED_BIT  (1U << 2)

void Led_Init(void) {
    LED_REGISTER = 0;
}

void Led_SetRed(bool on) {
    if (on) {
        LED_REGISTER |= RED_LED_BIT;
    } else {
        LED_REGISTER &= ~RED_LED_BIT;
    }
}

bool Led_IsRedOn(void) {
    return (LED_REGISTER & RED_LED_BIT) != 0;
}

void Led_SetGreen(bool on) {
    if (on) {
        LED_REGISTER |= GREEN_LED_BIT;
    } else {
        LED_REGISTER &= ~GREEN_LED_BIT;
    }
}

bool Led_IsGreenOn(void) {
    return (LED_REGISTER & GREEN_LED_BIT) != 0;
}

void Led_SetBlue(bool on) {
    if (on) {
        LED_REGISTER |= BLUE_LED_BIT;
    } else {
        LED_REGISTER &= ~BLUE_LED_BIT;
    }
}

bool Led_IsBlueOn(void) {
    return (LED_REGISTER & BLUE_LED_BIT) != 0;
}

Nun sollten alle Tests grĂĽn werden, und das Projekt befindet sich in der Green-Phase.

Im dritten Schritt geht es daran, den Code zu säubern, allgemeine Funktionen auszulagern und die Codebasis zu optimieren. Solange alle Tests grün bleiben, ist gewährleistet, dass die Änderungen die Funktionsweise nicht beeinträchtigen.

Ein möglicher Ansatz beim Refactoring wäre, den Code für das Setzen/Löschen von Bits in eine Hilfsfunktion zu kapseln. Auf die Details verzichtet dieser Artikel, da der Fokus auf dem Testen und nicht auf dem Optimieren des Codes liegt.

Nach dem ersten Schritt mit TDD folgen in der Praxis weitere Tests unter anderem für die anderen LEDs, für Fehlerfälle, um sicherzustellen, dass Init() den Zustand korrekt zurücksetzt und für weitere Funktionen.

Nach jedem neuen Test (Red) erweitert man den Code (Green) und refaktoriert anschlieĂźend (Refactor).

Um CppUTest zu demonstrieren, kommen dieselben Testfälle in test/cpputest/test_led.cpp zum Einsatz:

#include "CppUTest/TestHarness.h"
#include "led.h"
#include "mock_register.h"

uint32_t MOCK_LED_REGISTER;

TEST_GROUP(LedTest)
{
    void setup() {
        MOCK_LED_REGISTER = 0;
        Led_Init();
    }

    void teardown() {
    }
};

TEST(LedTest, RedLedOffAfterInit) {
    CHECK_FALSE(Led_IsRedOn());
}

TEST(LedTest, TurnRedLedOn) {
    Led_SetRed(true);
    CHECK_TRUE(Led_IsRedOn());
    UNSIGNED_LONGS_EQUAL(RED_LED_BIT, MOCK_LED_REGISTER & RED_LED_BIT);
}

TEST(LedTest, TurnRedLedOff) {
    Led_SetRed(true);
    Led_SetRed(false);
    CHECK_FALSE(Led_IsRedOn());
    UNSIGNED_LONGS_EQUAL(0, MOCK_LED_REGISTER & RED_LED_BIT);
}

Die Assertions in CppUTest sind etwas anders, unter anderem mit CHECK_TRUE, CHECK_FALSE und UNSIGNED_LONGS_EQUAL, aber das Prinzip ist dasselbe. Auch hier wĂĽrden die Tests beim erstmaligen AusfĂĽhren vor dem Implementieren fehlschlagen (Red) und nach dem Schreiben von minimalem Code schlieĂźlich erfolgreich sein (Green). Am Ende folgt das Refaktorieren.

Vergleich Google Test vs. CppUTest

Beide Frameworks liefern im Test nahezu identische Ergebnisse, aber im Detail gibt es Unterschiede:

  • Einrichtung und Abhängigkeiten: GTest bringt einen größeren Footprint mit sich, erfordert mindestens C++14 und STL-Funktionen. In Embedded-Umgebungen ist das nicht immer verfĂĽgbar oder erwĂĽnscht. CppUTest ist schlanker und lässt sich einfacher ohne groĂźe Standardbibliotheken auf Embedded-Plattformen integrieren. Auch die Assertions sind einfacher gehalten.
  • Test-AusfĂĽhrung auf dem Host: Beide Frameworks sind ursprĂĽnglich fĂĽr Host-Umgebungen konzipiert. GTest ist dabei sehr komfortabel und besitzt ein reichhaltiges Set an Assertions und Integrationen mit verschiedenen Entwicklungsumgebungen. CppUTest ist fĂĽr die Embedded-Entwicklung oft besser geeignet, da es speziell mit Blick auf MCU- und Cross-Compiling-Umgebungen entwickelt wurde. Es lässt sich einfacher strippen und auf ein Minimum reduzieren.
  • Mocking und Stubs: GTest lässt sich hervorragend mit Google Mock kombinieren und ermöglicht aufwendige Mock-Objekte fĂĽr komplexe Interfaces. FĂĽr reine C-Module ist das jedoch manchmal etwas umständlich, da die objektorientierte Mocking-Architektur von Google Mock eher an C++-Interfaces anknĂĽpft. CppUTest bringt mit CppUMock ein einfaches, aber effektives Mock-Framework mit, das sich optimal fĂĽr C-Funktionen eignet. So lassen sich Hardware-Zugriffe oder Betriebssystem-Calls leicht simulieren.
  • Auswertung und Reporting: GTest erzeugt standardisierte JUnit-kompatible XML-Reports. Das vereinfacht die Integration in CI/CD-Pipelines. CppUTest erfordert mehr Handarbeit fĂĽr die Integration in Skripte und CI-Pipelines. Die Reporting-Funktionen sind simpler gehalten.
  • Lerndauer und Community: GTest ist weit verbreitet, und es gibt zahllose Tutorials, Beispiele sowie Hilfen im Netz. CppUTest hat eine etwas kleinere, aber aktive Community. Die Lernkurve ist steil, aber kurz, da die API einfacher ist.