Unit tests schrijven voor embedded C-code is een vak apart. Anders dan bij reguliere softwareontwikkeling heb je te maken met hardware-afhankelijkheden, beperkte resources en real-time gedrag dat moeilijk te simuleren is. Toch is geautomatiseerd testen in embedded omgevingen absoluut mogelijk en steeds meer de norm bij serieuze embedded software development. In dit artikel leggen we stap voor stap uit hoe je dat aanpakt.
Wat zijn unit tests en waarom zijn ze belangrijk in embedded C?
Unit tests zijn geautomatiseerde tests die één specifieke functie of module in isolatie controleren op correct gedrag. In embedded C-ontwikkeling zijn ze belangrijk omdat ze bugs vroeg in het ontwikkelproces opsporen, zonder dat je elke keer hardware nodig hebt. Dat bespaart tijd, vermindert risico en maakt je codebase betrouwbaarder.
In de praktijk betekent dit dat je een functie aanroept met bekende invoer en vervolgens controleert of de uitvoer overeenkomt met wat je verwacht. Dit klinkt eenvoudig, maar in embedded omgevingen raakt software al snel verweven met hardware-registers, interrupts en timers. Juist daarom is het zo waardevol om die afhankelijkheden bewust te scheiden en testbaar te maken.
Voor een embedded software engineer die werkt aan machinebesturing of robotica zijn unit tests ook een communicatiemiddel: ze leggen vast wat een stuk code geacht wordt te doen. Dat maakt onderhoud en samenwerking een stuk eenvoudiger.
Waarom is unit testing in embedded C lastiger dan in andere talen?
Unit testing in embedded C is lastiger dan in hogere programmeertalen omdat de code sterk afhankelijk is van hardware, de toolchain beperkt is en er weinig abstractielagen zijn. Functies spreken direct met registers, timers of externe peripherals, wat testen buiten de doelomgeving complex maakt.
Concrete uitdagingen zijn onder andere:
- Hardware-afhankelijkheden: Functies lezen of schrijven direct naar geheugenadressen of registers die op een pc niet bestaan.
- Geen standaard OS: Veel embedded systemen draaien bare-metal, zonder besturingssysteem of standaardbibliotheek.
- Beperkte geheugen- en rekenkracht: Testframeworks moeten lichtgewicht zijn en passen binnen de constraints van het doelplatform.
- Real-time gedrag: Timing-afhankelijk gedrag is moeilijk deterministisch te testen.
- Globale state: Veel embedded code werkt met globale variabelen die de testresultaten kunnen beïnvloeden.
Dit zijn geen onoverkomelijke obstakels, maar ze vragen wel een doordachte aanpak. De sleutel zit in het slim scheiden van logica en hardware-interactie.
Welke testframeworks zijn geschikt voor embedded C-code?
De meest gebruikte testframeworks voor embedded C zijn Unity, CppUTest en CMock. Unity is populair vanwege zijn eenvoud en lage footprint. CppUTest biedt meer mogelijkheden voor C++-projecten. CMock is een tool die automatisch mock-objecten genereert op basis van header-bestanden, ideaal in combinatie met Unity.
Een korte vergelijking:
- Unity: Lichtgewicht, puur C, uitstekend voor bare-metal projecten. Weinig dependencies, makkelijk te integreren in een bestaande build.
- CppUTest: Geschikt voor C en C++, biedt memory leak detection en is populair in de automotive en hightech industrie.
- CMock: Genereert automatisch mocks voor C-interfaces, werkt goed samen met Unity en Ceedling.
- Ceedling: Een build- en testomgeving die Unity, CMock en Rake combineert tot een geïntegreerde workflow.
De keuze hangt af van je projectgrootte, toolchain en of je ook C++ gebruikt. Voor de meeste embedded C-projecten is de combinatie Unity plus CMock een solide startpunt.
Hoe isoleer je hardware-afhankelijkheden in unit tests?
Hardware-afhankelijkheden isoleer je in unit tests door gebruik te maken van abstractielagen, mocks en stubs. In plaats van direct registers aan te spreken, roep je een interfacefunctie aan die je in tests kunt vervangen door een nepimplementatie. Dit patroon heet Hardware Abstraction Layer (HAL) of dependency injection.
De aanpak in de praktijk:
- Definieer een interface: Maak een header-bestand met functies zoals gpio_set() of uart_send(). De echte implementatie spreekt de hardware aan; de test-implementatie doet dat niet.
- Gebruik stubs voor eenvoudige gevallen: Een stub is een lege of vereenvoudigde implementatie die een vooraf bepaalde waarde teruggeeft. Handig als je alleen de logica wilt testen, niet de hardware-interactie.
- Gebruik mocks voor complexere interacties: Een mock controleert ook of een functie is aangeroepen, hoe vaak en met welke parameters. CMock genereert deze automatisch.
- Vermijd globale state: Geef hardware-state mee als parameter of gebruik een struct die je in tests eenvoudig kunt initialiseren.
Dit vraagt discipline in de architectuur van je code, maar het levert een codebase op die niet alleen testbaar is, maar ook beter onderhoudbaar en herbruikbaar.
Hoe schrijf je een unit test stap voor stap in embedded C?
Een unit test in embedded C schrijf je door een testfunctie te definiëren die een specifieke functie aanroept met bekende invoer en het resultaat vergelijkt met de verwachte uitvoer via een assertion. Met Unity ziet dat er als volgt uit:
- Installeer en configureer Unity in je projectstructuur. Voeg de Unity-bronbestanden toe aan je build.
- Schrijf de te testen functie in een apart C-bestand, los van hardware-aanroepen.
- Maak een testbestand aan met een setUp() en tearDown() functie voor initialisatie en opruimen.
- Schrijf een testfunctie die begint met void test_ en gebruik assertions zoals TEST_ASSERT_EQUAL(verwacht, resultaat).
- Roep de testfunctie aan vanuit main() met RUN_TEST(test_mijn_functie).
- Compileer en voer uit op de host (je pc), niet op de target, tenzij je specifiek target-gedrag test.
- Analyseer de output en pas de code of de test aan op basis van de resultaten.
Door tests op de host te draaien houd je de feedbackloop kort. Je hoeft niet elke keer te flashen en te deployen om te zien of een functie correct werkt.
Welke veelgemaakte fouten moet je vermijden bij embedded unit tests?
De meest voorkomende fouten bij embedded unit tests zijn het testen van te veel tegelijk, het niet isoleren van hardware, het overslaan van edge cases en het schrijven van tests die de implementatie kopiëren in plaats van het gedrag te verifiëren.
Specifieke valkuilen om op te letten:
- Te grote units testen: Test één functie per test, niet een hele module. Kleine, gerichte tests zijn makkelijker te onderhouden en te debuggen.
- Vergeten te resetten: Globale variabelen of statische state die niet wordt gereset tussen tests, kan valse positieven of negatieven veroorzaken.
- Alleen de happy path testen: Test ook grenswaarden, lege invoer, overflow en foutcondities. Juist daar zitten de bugs in embedded systemen.
- Tests die afhankelijk zijn van volgorde: Elke test moet onafhankelijk werken. Als test B alleen slaagt als test A eerst is uitgevoerd, is er iets mis met de isolatie.
- Geen tests schrijven voor legacy code: Juist bij bestaande codebases zonder tests loont het om eerst karakterisatietests te schrijven voordat je refactort.
Goede unit tests zijn geen sluitpost, maar een integraal onderdeel van het ontwikkelproces. Dat geldt zeker voor embedded software developers die werken aan systemen waar betrouwbaarheid geen optie is.
Hoe PROMEXX omgaat met embedded software kwaliteit
Bij PROMEXX werken we dagelijks aan technische software voor machines, robots en hightech systemen. Kwaliteit en vakmanschap staan centraal, en dat begint bij de manier waarop we code schrijven en testen. We passen methodieken toe zoals Test Driven Development, Object Oriented Programming en agile werken in projecten waarbij software direct samenkomt met hardware en mechanica.
Wat dat in de praktijk betekent voor engineers die bij ons werken:
- Je werkt aan inhoudelijk uitdagende projecten bij grote hightechbedrijven en kleinere maakbedrijven.
- Je krijgt de ruimte om best practices zoals unit testing serieus toe te passen, niet als bijzaak maar als onderdeel van het vak.
- We investeren in trainingen, kennissessies en coaching zodat je je technisch blijft ontwikkelen.
- Je hebt een vaste thuisbasis bij een kleine, persoonlijke organisatie met kantoren in Eindhoven en Rotterdam, ook als je embedded bij een klant werkt.
Ben je een ervaren embedded software developer die wil werken aan uitdagende technische projecten in een omgeving waar kwaliteit en vakmanschap echt tellen? Bekijk dan onze openstaande vacatures voor C software engineers of neem een kijkje op onze pagina voor developers om te zien wat PROMEXX jou te bieden heeft. Wil je eerst weten hoe het is om bij ons te werken? Lees dan wat onze medewerkers zeggen over hun ervaringen.
Veelgestelde vragen
Kan ik unit tests draaien op de target hardware in plaats van op mijn pc?
Ja, dat kan — maar het is meestal niet de meest efficiënte aanpak. Tests op de host (je pc) draaien is sneller, goedkoper en makkelijker te automatiseren in een CI/CD-pipeline. Tests op de target zijn zinvol als je specifiek platform-afhankelijk gedrag wilt valideren, zoals timing, interrupts of geheugengedrag dat op de host niet te simuleren is. Een goede strategie combineert beide: logica test je op de host, hardware-integratie valideer je op de target.
Hoe begin ik met unit testing als mijn bestaande codebase geen enkele test heeft?
Begin niet met alles tegelijk herschrijven — dat is een recept voor frustratie. Schrijf eerst karakterisatietests voor de meest kritieke of foutgevoelige functies, zodat je een vangnet hebt voordat je iets aanpast. Introduceer daarna stap voor stap een abstractielaag (HAL) op plekken waar je nieuwe code schrijft of bestaande code refactort. Zo bouw je testdekking organisch op zonder het project stil te leggen.
Hoe integreer ik unit tests in mijn build- en CI-pipeline voor embedded projecten?
Tools zoals Ceedling, CMake of Makefile maken het mogelijk om je tests automatisch te bouwen en uit te voeren als onderdeel van je buildproces. Koppel dit aan een CI-tool zoals GitHub Actions, GitLab CI of Jenkins, zodat tests automatisch draaien bij elke commit of pull request. Omdat de tests op de host worden uitgevoerd, heb je geen speciale embedded hardware nodig in je CI-omgeving — een standaard Linux- of Windows-runner is voldoende.
Wat is het verschil tussen een stub en een mock, en wanneer gebruik ik welke?
Een stub is een vereenvoudigde vervanging van een functie die een vaste waarde teruggeeft, zonder te controleren hoe of hoe vaak hij wordt aangeroepen. Een mock doet dat wél: hij verifieert dat een functie is aangeroepen met de juiste parameters en het juiste aantal keren. Gebruik een stub als je alleen de logica van de te testen functie wilt isoleren. Gebruik een mock als het gedrag van de aanroep zelf onderdeel is van wat je wilt testen, bijvoorbeeld of een foutmelding daadwerkelijk via uart_send() wordt verstuurd.
Hoeveel testdekking (code coverage) moet ik nastreven voor embedded C-code?
Een vuistregel van 80% codecoverage is in veel projecten een realistisch en zinvol doel, maar het getal zelf is minder belangrijk dan wát je test. Zorg dat kritieke functies, foutafhandeling en grenswaarden altijd gedekt zijn — dat zijn de plekken waar bugs in embedded systemen de meeste schade aanrichten. In veiligheidskritische domeinen zoals automotive (ISO 26262) of medische apparatuur gelden strengere eisen, zoals MC/DC-coverage, die je toolchain en teststrategie bepalen.
Werkt Test Driven Development (TDD) ook in embedded C-projecten?
Ja, TDD is zeker toepasbaar in embedded C, maar vraagt wel aanpassing aan de context. De klassieke red-green-refactor-cyclus werkt het beste voor de logische laag van je software, los van hardware. Voor de hardware-nabije code schrijf je eerst de interface en mock je de implementatie weg. TDD dwingt je bovendien om code testbaar te ontwerpen vóór je hem schrijft, wat leidt tot betere architectuurkeuzes zoals duidelijke interfaces en minder globale state.
Hoe ga ik om met het testen van interrupt-handlers en timing-afhankelijke code?
Interrupt-handlers en timing-afhankelijke code zijn notoir lastig te unit testen, omdat ze sterk afhankelijk zijn van hardware-events. De beste aanpak is om de logica uit de interrupt-handler te halen en onder te brengen in een gewone functie die je wél kunt testen. De handler zelf wordt dan zo klein mogelijk — idealiter alleen een vlag zetten of een functie aanroepen. Timing-gedrag valideer je bij voorkeur via integratietests op de target, eventueel ondersteund door een logic analyzer of oscilloscoop.