GoogleTest Demo

Projekt-Steckbrief

  • Schwierigkeitsgrad: Mittel 3/5
  • Kosten: 0€
  • Zeitaufwand: ~2h

Motivation

Es ist schön, bei der Entwicklung komplexerer Projekte ein leistungsfähiges Unit-Test-Framework zur Hand zu haben, das viel Zeit bei der Fehlersuche spart (vor allem das On-Target-Debugging in der Embedded-Welt kann sehr zeitaufwändig sein), die Codequalität verbessert und möglicherweise den Einblick in APIs und Architektur für andere verbessert.

Die Welt der “embedded controller”

Traditionell waren Mikrocontroller auf der Speicherseite begrenzt. In der Vergangenheit hatten sie weder viel RAM noch PROGMEM, also war mein meist in schlankem C geschrieben und die Konfiguration fand in Listen von #define statt, die nicht viel Rechenzeit kosten, weil der Präprozessor die Arbeit machen würde und nicht der µC zur Laufzeit. Meine Unit-Tests waren immer an die Hardware gebunden, auf der sie liefen - nehmen Sie Unity als bekanntes Beispiel - aber jetzt wollte ich etwas Neues ausprobieren: Code objektorientiert schreiben und die Geschäftslogik unabhängig von der Hardware testen lassen. Ich wählte das GoogleTest-Framework und wollte eine nahtlose Integration in meinen Workflow mit VScode und dessen Erweiterung PlatformIO erreichen.

Erste Schritte

Die benötigte Software kann man selbst (auf einem Windows1 Rechner) installieren, um mit GoogleTest starten zu können.

MinGW herunterladen und installieren

Siehe diesen Thread im Forum von PlatformIO, wenn es Fragen zu den folgenden Schritten gibt.

  • Unix-Benutzer: bis hier die Schritte überspringen, denn wahrscheinlich werden nur die libpthreadgc-Bibliotheken zusätzlich benötigt. Prüfen Sie das Vorhandensein aller Bibliotheken (aber lassen Sie das Präfix mingw32- weg) und installieren ggf. fehlende nach. Stellen Sie sicher, dass die PATH-Variable korrekt gesetzt ist.
  • Windows-Benutzer:
    1. Laden Sie MinGW herunter, z.B. von Sourceforge
    2. Installieren Sie durch Ausführen von mingw-get-setup.exe. Wenn Sie grafische Benutzeroberflächen mögen, lassen Sie das entsprechende Kästchen angekreuzt. Ich empfehle nicht, das Installationsverzeichnis zu ändern. Wenn Sie das doch tun, müssen Sich sich den neuen Pfad merken. MinGW install

MinGW konfigurieren

  1. Damit GoogleTest läuft, müssen zusätzliche Pakete installiert werden:
    • mingw32-gcc-g++
    • mingw32-libmingwex
    • mingw32-libmingwex-dll
    • mingw32-libmingwex-dev
    • mingw32-libpthreadgc-dll
    • mingw32-libpthreadgc-dev
  2. Um diese zu installieren,
    • Wenn Sie die GUI verwenden: Suchen Sie die Bibliotheken unter “MinGW Standard Libraries” und fügen Sie sie nacheinander hinzu/aktivieren/installieren Sie sie.
    • Benutzer des Kommandozeilen-Interpreters: Geben Sie mingw-get install ein und fügen Sie dann den Paketnamen hinzu. Wenn Sie eine Fehlermeldung erhalten, die besagt, dass einige der Pakete bereits existieren, umso besser.
  3. Jetzt müssen Sie den Ordner “bin” von MinGW zu Ihren Systempfadvariablen hinzufügen, damit PlatformIO sie später finden kann. Wenn Sie ihn nicht von Hand modifiziert haben, sollte er C:\MinGW\bin lauten.
    • GUI-Fans: folgen Sie dieser Anleitung
    • CLI: Geben Sie set PATH=%PATH%;C:\MinGW\bin ein und überprüfen Sie, ob es mit echo %PATH:;=&echo.% funktioniert.

Glückwunsch! Sie haben MinGW installiert, die Umgebungsvariablen so gesetzt, dass es von Drittanwendungen leichter gefunden wird, und es für die Zusammenarbeit mit GoogleTest konfiguriert.

PlatformIO konfigurieren

  1. Nachdem Sie die Variable PATH gesetzt haben, müssen Sie VScode neu starten.
  2. Damit PlatformIO korrekt mit dem gerade installierten Compiler verbunden wird, müssen Sie die native Umgebung verwenden, in der sich Ihr Compiler befindet. Öffnen Sie ein PlatformIO-Terminal in einem Ihrer Projekte und geben Sie pio platform install native ein. Image: how to install PIO native
  3. Zwei Möglichkeiten:
    • A) Befolgen Sie diese Anleitung und verwenden Sie mein Beispielprojekt wie unten beschrieben. Sie können den nächsten Abschnitt getrost überspringen, da dieses Projekt vorkonfiguriert ist und Sie es nach Belieben verändern können, da Sie von Anfang an über grundlegende Hardware-Abstraktionsschnittstellen verfügen.
    • B) Nehmen Sie Ihr eigenes Projekt und ändern Sie dessen Platformio.ini so, dass GoogleTest ausgeführt werden kann.
  4. Dies sollten Sie zu Ihrer Platformio.ini hinzufügen:
    [env:desktop]
    platform = native
    build_flags = 
     -std=gnu++11 # use installed GNU C++11 compiler.
     -pthread # found in gtest documentation
    lib_deps = 
     googletest # Will automatically load latest googletest lib
    lib_ignore = 
     src # most main.cpp's directly access the hardware. 
         # Can be removed if your project is different.
         #
         # PLUS: Any files that contain bare-metal hardware commands 
         # (e.g. digitalWrite), g++ won't have the headers or specialized compiler knowledge!
    lib_compat_mode = off # Must-have for external stuff like gtest!
    

    Erklärung: Für die Umgebung Desktop (= Computer ohne Microcontroller) wird der native Compiler verwendet, d.h. GCC/G++, der oben in der Anleitung installiert und für den die PATH-Variable festgelegt wurde. GoogleTest verwendet den C++11-Standard und Threading, der zu den Build-Flags hinzugefügt werden muss. Der Build von der googletest Bibliothek ab. Es werden alle Dateien/Ordner ignoriert, die Quell- oder Headerdateien mit hardwarebezogenen Befehlen oder Headern enthalten. Der Bibliothekskompatibilitätsmodus muss ausgeschaltet sein, damit der Library Dependency Finder googletest einbeziehen kann.

  5. (Optional) Falls Sie lieber GUI-Schaltflächen verwenden als eine Zeile in die Konsole zu tippen um die Tests zu starten, erstellen Sie eine benutzerdefinierte Konfiguration.

Zeit für ein paar Tests

Für einen schnelleren Einstieg kann mein Beispiel-Repository geklont werden. Es enthält alle notwendigen Elemente, um zu prüfen, ob gTest und gMock korrekt funktionieren. So kann man direkt nachvollziehen, ob die Installation erfolgreich war.

  1. Entpacken Sie die Zip-Datei bzw. verwenden Sie SSH, um das Repository zu klonen.
  2. Öffnen Sie die Startseite der PlatformIO-Erweiterung, klicken Sie auf “Open Project”, und wählen Sie den Ordner aus.
  3. Um die Unittests auszuführen, öffnen Sie ein PlatformIO-Terminal und geben Sie pio test -e desktop -f desktop ein. (-e = Umgebung, -f = Filter). Die Parameter sind erforderlich, weil die Tests nur auf der “Desktop”-Umgebung lauffähig sind, nicht aber auf dem Microcontroller.
  4. Nach einiger Zeit sollte die unten dargestellte Meldung angezeigt werden.

successful build and test

Super! Unit-Tests innerhalb von PlatformIO wurden gebaut und ausgeführt!

Zu beachten ist, dass der Ordner PlatformIO_gTestgMock/.pio/build/desktop nun eine ausführbare Datei namens program.exe enthält. Wird sie über die Kommandozeile ausgeführt, sollte das selbe Ergebnis wie in der obigen Abbildung herauskommen. Dies kann nützlich sein, wenn Tests nicht nur fehlschlagen, sondern das ganze Programm bei der Ausführung abstürzt. Zum Debuggen der Tests ist das auch hilfreich, da die CLI von PlatformIO dazu neigt, sich aufzuhängen. Man hat hier auch leichten Zugang zum Stracktrace - das sprengt den Rahmen dieser Übung aber bei Weitem.

Ab diesem Punkt

  • können Sie weiterlesen, um den Inhalt des Repositorys und die Funktionsweise von GoogleTest besser zu verstehen
  • Sie könnten das Schreiben von Tests üben, indem Sie weitere Fälle hinzufügen, z. B. um zu überprüfen, ob der Zustand “LOW” korrekt durchlaufen wird.
  • Sie könnten auch die Interfaces dieser Grundbausteine verbessern, z. B. durch Hinzufügen der Funktion analogRead() zur Hardware-Abstraktionsschicht
  • Sie könnten Ihr eigenes Projekt darauf aufbauen, die Hardware-Abhängigkeiten sauber ausschalten und es so einfach auf verschiedene Hardware-Geräte und Hersteller portieren
  • Oder Sie beschäftigen sich weiter mit diesen anderen genialen Dingen, die Sie in die Tat umsetzen wollten, bevor Sie von diesem Tutorial abgelenkt wurden.

Repository-Inhalte erklärt

Die folgenden Abschnitte behandeln die meisten Dateien im Repository und erklären, was sie im Detail tun. Die implementierte Geschäftslogik ist minimal und sollte als Beispielcode betrachtet werden. Ein reales Projekt, das alle auf dieser Seite dargestellten Muster verwendet, ist auf meiner Tonuino-Seite zu finden.

Schnittstelle der Hardware-Abstraktionsschicht

Schauen wir uns das Interface und seine Struktur genauer an.

// hal_if.h
namespace hardwareAbstraction {

// Hardware Abstraction Layer Interface class
class Hal_IF
{
public:
    virtual ~Hal_IF() = default;

    virtual bool digitalRead(uint8_t pinId) const = 0;
    virtual void digitalWrite(uint8_t pinId, bool value) const = 0;
};

} // hardwareAbstraction

Die Klasse Hal_IF hat zwei Methoden, die beide virtuell sind. Das bedeutet, dass eine Client-Klasse, die diese Schnittstelle verwendet, die Methode aufrufen kann und somit “automatisch” an die konkrete Implementierung der Methode weitergeleitet wird. Das = 0; macht diese Methoden rein virtuell, so dass das Überschreiben dieser Methoden durch eine abgeleitete Klasse nicht optional sondern vorgeschrieben ist. Das ist genau das, was wir hier wollen, denn wenn diese Methode nicht überschrieben würde, hätte sie kein Verhalten. Zu beachten ist, dass der Destruktor einer Schnittstellenklasse fast immer virtuell sein sollte.

LoopInOut

Die zu testende Logik. Sie greift nicht direkt auf die Ein-/Ausgabe-Hardware des µC zu, sondern auf die Variable m_hal, die eine Implementierung der Schnittstelle Hal_IF ist. Sie wird bei der Objekterzeugung per Referenz (gekennzeichnet durch das & bei der Typdeklaration) an die Klasse übergeben2.

// loopInToOut.h

class LoopInToOut
{
public:
    LoopInToOut(hardwareAbstraction::Hal_IF& hal): m_hal(hal){};
    ~LoopInToOut() = default;

    bool loopThrough(uint8_t inPin, uint8_t outPin);

private:
    hardwareAbstraction::Hal_IF& m_hal;
};

Dies ermöglicht die eigentliche Magie von gMock: Da der Client-Code mit einer Schnittstelle arbeitet, kann ich die “echte” Implementierung (des Hardware-Zugriffs) zur µC-Laufzeit übergeben, aber für den Test kann ich eine “Modell”-Implementierung übergeben, über die ich zum Zeitpunkt der Ausführung des Tests die Kontrolle habe.

// loopInToOut.cpp

bool LoopInToOut::loopThrough(uint8_t inPin, uint8_t outPin)
{
    bool input = m_hal.digitalRead(inPin);

    m_hal.digitalWrite(outPin, input);
    return input;
}

loopThrough() ist wiederum sehr einfach. Sie liest den Zustand des Eingangspins und ordnet ihn dem Ausgangspin zu. Sie gibt den Zustand des Eingangspins an den Aufrufer zurück.

Modellierte Hardwareabstraktion (mock hal)

GoogleTest verwendet Makros, um Verhalten zu definieren. Die Mock-Klasse leitet sich ebenfalls von der Schnittstelle ab und stellt lediglich die Deklarationen der Schnittstellenmethoden bereit, die sie überschreibt. Es ist noch kein Verhalten festgelegt.

// hal_mock.h

class Hal_mock : public hardwareAbstraction::Hal_IF
{
public:
    MOCK_METHOD(bool, digitalRead, (uint8_t pinId), (const, override));
    MOCK_METHOD(void, digitalWrite, (uint8_t pinId, bool value), (const, override));
};

Die Testvorrichtung (test fixture)

Die Testvorrichtung enthält gemeinsame Daten und Verhaltensweisen für alle Testfälle innerhalb einer Testsuite. Name der Testsuite = Klassenname. Genau dieser Name muss in den Tests, die zu dieser Suite gehören sollen, wiederverwendet werden.

// unittest_loopInToOut.cpp
class loopInToOut_Test : public ::testing::Test
{
protected:
    virtual void SetUp()
    {
        m_loopInToOut = new LoopInToOut(hal_mock);
    }

    virtual void TearDown()
    {
        delete m_loopInToOut;
    }

    NiceMock<Hal_mock> hal_mock{};
    LoopInToOut* m_loopInToOut{nullptr};
};

Jeder Test verfügt also über einige vorbereitete Daten, die er verwenden kann: Die zu testende Klasse wird als Zeiger mit einer modellierten (Mock) Implementierung ihrer Abhängigkeit eingerichtet.

Der eigentliche Test

Der Test wird durch das Makro TEST_F definiert, was soviel bedeutet wie “Test mit Fixture”, das eine Testsuite und Testnamen erwartet. Das Makro ON_CALL ist ein Befehl an gMock, der der modellierten Klasse vorgibt, wie sie sich zu verhalten hat, wenn digitalRead(_) mit einer beliebigen Eingabe (Unterstrich = beliebig) aufgerufen wird: die Methode soll standardmäßig true zurückgeben.

// unittest_loopInToOut.cpp

TEST_F(loopInToOut_Test, loop_inputHigh_writesCorrectOutputHigh)
{
    ON_CALL(hal_mock, digitalRead(_)).WillByDefault(Return(true));

    EXPECT_CALL(hal_mock, digitalWrite(2, true));

    m_loopInToOut->loopThrough(1, 2);
}

Dann wird eine “Erwartung” an die nachgebildete Klasse formuliert, die besagt, dass digitalWrite mit Eingabewerten von (2, true) aufgerufen wird, die ebenfalls auf Korrektheit geprüft werden.

Die letzte Anweisung ruft die echte Implementierung auf, so dass die Erwartungen überprüft werden können.

Implementierung des Hardwareverhaltens

Da main.cpp auf die Hardware zugreift, muss es dafür eine Art Schnittstellenimplementierung geben.

// hal.h
class Hal : public Hal_IF
{
public:
    Hal() = default;
    ~Hal() = default;

    bool digitalRead(uint8_t pinId) const override;
    void digitalWrite(uint8_t pinId, bool value) const override;
};

Hier leitet Hal sich von Hal_IF ab und überschreibt alle seine Methoden.

// hal.cpp
#include "Arduino.h"

#include "hal.h"

namespace hardwareAbstraction{

bool Hal::digitalRead(uint8_t pinId) const
{
    return digitalRead(pinId);
}

void Hal::digitalWrite(uint8_t pinId, bool value) const
{
    digitalWrite(pinId, value);
}

Man erkennt, dass nur diese Quelldatei tatsächlich die Arduino.h für den Zugriff auf die Hardware einbindet. Die Abhängigkeit von den Hardware-Funktionen ist also sehr begrenzt und in Schnittstellen-Implementierungen gekapselt, die durch die Programmstruktur ausgewählt werden können. Während die main.cpp für das Zielgerät also ein Objekt der obigen Implementierung erzeugt, wird der Test stattdessen ein Mock übergeben.

Gtest Schlüsselkonzepte

Googles offizielle Webseite über Googletest kann sehr helfen, nicht nur für den Einstieg3. Das gmock Cookbook ist ein hervorragender Leitfaden, den ich im Laufe der Monate benutzt habe, als meine Tests immer komplexer und schwieriger zu schreiben wurden, und das gmock Cheat Sheet hilft, wenn man den Überblick verloren hat, wie ein Test für dieses oder jenes spezielle Problem zu schreiben ist.

Dependency Injection

Dependency Injection ist die grundlegende Mechanik, verstanden werden muss, damit man gMock erfolgreich anwenden kann. Ich versuche es mal mit einem Bild zu verdeutlichen.

Dependency Injection using interfaces

Kurzgesagt ist die Schnittstelle lediglich ein Platzhalter für die Implementierung der Abhängigkeit der Client-Klassen.

Zur Laufzeit, z.B. wenn der Host das Client-Objekt erzeugt, wird er die Implementierung der Abhängigkeit an den Client übergeben (d.h. injizieren), der dann jede seiner Methoden aufrufen kann, da ihre Signaturen zur Kompilierzeit aufgrund der vom Client verwendeten Schnittstelle bekannt sind.

  • Das Mock ist nun lediglich eine Implementierung der Schnittstelle, die für die Tests spezialisiert ist.
  • Die eigentliche Implementierung wird in allen anderen Kontexten verwendet, sie zeigt das “echte” Verhalten dieser Klasse.

Hardware-Abstraktionsschicht

Dies ist vielleicht einer der kniffligen Punkte bei der Arbeit mit eingebetteter Software. Irgendwann geht es um die Interaktion mit der Außenwelt, sei es mit GPIO-Pins (General Purpose Input Output), den Zugriff auf eines der unzähligen Hardware-Bussysteme wie I2C, UART, SPI, CAN zur Steuerung externer Hardware oder einfach die Nutzung von Systeminterna wie dem EEPROM - da fangen die Probleme an.

Gtest läuft auf dem GCC/G++ Compiler, der einfach nichts von dem weiß, was der spezialisierte Compiler des jeweiligen Chips weiß. Er würde nicht einmal die Header-Dateien verstehen. Nun gibt es zwei Möglichkeiten, dieses Problem zu umgehen:

  • “Fake Header” für jede µC-spezifische Implementierung schreiben, die von GCC/G++ verwendet werden können
  • Eine Schnittstelle für jede µC-spezifische Implementierung unter Verwendung einer (Hardware-)Abstraktionsschicht programmieren, so dass Ihre Produktionsumgebung die Chip-Hardware wie gewohnt verwendet, die Testumgebung aber stattdessen Mocks benutzt.

Der zweite Ansatz hat eine Reihe von Vorteilen:

  1. Er ist sauber. Er zwingt einen dazu, keine Abstraktionsschichten zu zusammenzufassen die getrennt gehören.
  2. Er ist einfach. Man kann die Geschäftslogik testen, ohne den µC zu programmieren! Es müssen nur die Eingänge gemockt und die Ausgänge abgefangen werden.
  3. Er ist portabel. Wenn man die Implementierung Abstraktionsschicht austauscht, kann man sie auf einer anderen Controller-Familie oder sogar auf Chips eines anderen Herstellers auszuführen.

Nachteile

Hmm, wo Licht ist, muss es doch irgendwo auch Schatten geben. Meiner Meinung nach sind die Nachteile, wenn man alles ohne angeschlossene Hardware testen will:

nicht viel Speicher übrig...

  1. Speicher. Sowohl ROM als auch RAM werden schneller voll, wenn eine große Anzahl von Klassen und vtables existieren.
  2. Besitz. Die Injektion von Abhängigkeiten trennt den Besitz von der Nutzung eines Objekts, und man muss sich entscheiden, wie beides organisiert werden soll.
  3. Bibliotheken von Drittanbietern können nicht einfach verwendet werden sondern benötigen Anpassung. Ein Interface-Header, eine Adapter-Implementierung und ein Mock für jede Bibliothek, die “chip-internals” verwendet, sind zu erstellen.
  4. Komplexität. Da Objekte über eine Schnittstelle injiziert werden, muss jetzt Call-by-Reference oder Übergabe per Zeiger durchgeführt werden 😨. Ersteres ist zusätzlich eingeschränkt, da es nur für Konstruktorinjektion funktioniert, nicht für andere Arten (Methodeninjektion, Setterinjektion usw.).

Einige weitere Tipps

  • Alles “hinter” der Abstraktionsschicht sollte so einfach wie möglich gehalten werden: Diese Dinge können nur auf dem Zielgerät getestet und ausgeführt werden. Beispiel: Jede Methode führt nur einen Befehl aus (wie in meinem Beispielcode).
  • Verwendung einer Loader-Klasse oder eines Dependency Injection Container (Nicolas Croad) erleichtert die Arbeit, denn er enthält alle zu injizierenden Objekte und kann sie auf Anfrage übergeben.
  • Die oben genannten Nachteile sollten im Blick behalten werden. Bei kleineren Projekten lohnt sich dieser Aufwand wahrscheinlich nicht.
  • Manchmal macht es sogar Sinn, mehrere Ebenen Schnittstellen zu haben. Dieses Muster kann nicht nur für die direkte Interaktion mit der Hardware zu verwendet werden, sondern auch zwischen Softwaremodulen, die dann in einer höheren Granularität getestet werden können. Dies kann sogar schneller sein als ein großen Haufen Klassen zu haben, die nur über Mocks in der Hardware-Abstraktionsschicht kontrolliert werden können.

Schwieriges Thema!

Sie haben sich jetzt eine Pause verdient. Das waren viele Informationen, die Sie verarbeiten mussten. Nehmen Sie sich Zeit. Kontaktieren Sie mich gern auf der discussions Seite zu diesem Thema, wenn Sie das Gefühl haben, dass etwas fehlt, nicht korrekt dargestellt ist, verbessert werden sollte oder einfach genau das ist, was Sie jetzt brauchten.

  1. Die Installation unter Linux ist einfacher, da gcc und g++ bereits vorinstalliert sind. Selbst wenn sie noch nicht die richtigen Pakete zur Unterstützung von GoogleTest enthalten, sind ihr Fehlermeldungen viel aussagekräftiger als die unter Windows, z.B. wenn Bibliotheken fehlen. 🙃 

  2. Dies wird auch Constructor Injection genannt, eine Unterart der Dependency Injection, die ein Mittel zur Erreichung der Inversion Of Control ist. Ich habe einige Links zu weiteren Informationen vorbereitet. Dies ist ein umfangreiches Thema, über das ganze Bücher geschrieben worden sind. 

  3. Übrigens: die googletest-Seite ist auch statisch wie meine und verwendet ebenfalls Jekyll 👋 …gefällt sie Ihnen? Ich würde gern Ihre Meinung dazu auf meiner Diskussionsseite erfahren.