Embedded systems-engineers zien graag dat hun code bij de eerste poging probleemloos op de hardware draait. In de praktijk blijkt dit echter vaak lastig vanwege bugs en onvoorziene fouten. Wat als er een methode bestond om deze problemen al vóór uitvoering op te sporen? In dit artikel over testgestuurde ontwikkeling verkennen we een techniek die helpt om robuustere code te schrijven met minder fouten en onbedoelde fouten.
 

Aangezien firmwareontwikkeling een tak is binnen de software-engineering, heeft het ook de Testgestuurde Ontwikkeling (TDD – Test-Driven Development) overgenomen, een aanpak waarbij tests vóór de feitelijke code worden geschreven. Sommige firmwareontwikkelaars volgen echter een Test-Later Development (TLD)-benadering, waarbij — met name unit-tests — pas worden uitgevoerd nadat de code als functioneel wordt beschouwd. Hoewel deze werkwijze efficiënt en logisch lijkt, leidt dit vaak tot gebrekkige testdekking. Hierdoor wordt de basis van de code gevoeliger voor integratiefouten wanneer er nieuwe functies worden toegevoegd.
 
Een groot voordeel van TDD is dat het bij strikte toepassing zorgt voor een volledige testdekking. Ontwikkelaars hanteren een test-first mentaliteit: eerst worden tests geschreven, pas daarna de bijbehorende code. Aanvankelijk faalt de test (Red), omdat er nog geen implementatie bestaat (zie Figuur 1). Vervolgens wordt de code geschreven om de test te laten slagen (Green). Daarna wordt de code geoptimaliseerd en opgeschoond, terwijl de test succesvol blijft (Refactor). Deze cyclus — Red, Green, Refactor — vormt de kern van TDD.

Illustration of TDD iteration steps.
Figuur 1: Een illustratie van de iteratiestappen in testgestuurde ontwikkeling.


TDD keert het traditionele firmware-ontwikkelproces om, wat in het begin ongewoon en uitdagend aanvoelt. Een veelgehoorde zorg is hoe unit-tests kunnen worden toegepast op firmware die draait op een microcontroller, gezien de sterke afhankelijkheid van leveranciers-SDK’s en toolchains. Dit artikel bespreekt deze uitdagingen en laat zien hoe TDD effectief kan worden geïntegreerd in embedded ontwikkeling.
 
Of u nu eerder hebt gehoord over TDD via een boek, een collega of een presentatie — of als het concept volledig nieuw voor u is — dit artikel is bedoeld voor u.

Inschrijven
Schrijf u in voor tag alert e-mails over Embedded & AI!

Unit tests begrijpen in embedded systemen

TDD kan niet worden ingevoerd zonder een goed begrip van unit-tests. Dit artikel is niet bedoeld als handleiding voor het schrijven van unit-tests, maar wel om enkele kernbegrippen toe te lichten.
 
De term test double wordt veel gebruikt binnen unit-testing. Het verwijst naar objecten die echte systeemcomponenten vervangen, zodat de System Under Test (SUT) geïsoleerd kan worden van afhankelijkheden. In embedded systemen is het gebruik van test doubles essentieel, aangezien afhankelijkheden van SDK’s en externe bibliotheken vaak beperkter zijn dan in algemene softwareontwikkeling. Test doubles zijn vooral van belang bij off-target testing, waarbij de hostcompiler (meestal op een pc) wordt gebruikt om binaire bestanden te genereren, zonder betrokkenheid van de SDK of de echte MCU-periferie.
 
Er bestaan verschillende soorten test doubles, waarvan de meest gebruikte Fakes, Stubs en Mocks zijn:

 

  • Fakes bieden een vereenvoudigde implementatie van een afhankelijkheid, wat testen gemakkelijker maakt. Een voorbeeld is het vervangen van een NOR-Flash key/value-opslag door een in-memory key/value-store op de host tijdens off-target testen.
  • Stubs geven vooraf gedefinieerde, hardgecodeerde antwoorden met minimale logica. Denk aan een functie die altijd een vaste ADC-waarde retourneert.
  • Mocks verifiëren interacties en controleren of functies met de verwachte argumenten zijn aangeroepen. In tegenstelling tot stubs fungeren mocks als observatoren. Bijvoorbeeld: bij het testen van een I²C-sensor driver kan een mock nagaan of het correcte registeradres wordt verzonden, terwijl een stub passief blijft.

Hoewel de verschillen tussen test doubles dieper gaan, vormt deze uitleg een goed vertrekpunt. In C/C++ kunnen afhankelijkheden worden vervangen met diverse test double-technieken, zoals: vervanging op basis van interfaces, overerving, compositie, link-time-vervanging, macro-gebaseerde preprocessing en gebruik van vtables. Zie [1] voor meer informatie.
 
Unit-tests vereisen een testframework, bestaande uit een bibliotheek en een testrunner. De bibliotheek biedt assertions en ondersteuning voor test doubles zoals mocks, terwijl de testrunner de tests uitvoert. Niet alle frameworks ondersteunen standaard mocks. Het framework beheert ook testfixtures — meestal aangeduid als setup en teardown — die respectievelijk vóór en na elke test worden uitgevoerd om een gecontroleerde testomgeving te waarborgen.

Inschrijven
Schrijf u in voor tag alert e-mails over embedded programming!

TDD in de praktijk

Testgestuurde ontwikkeling volgt een zich herhalende cyclus:

 

1. Schrijf eerst een test, nog vóór de implementatie van de code. Hiermee wordt al een functie gedefinieerd zonder implementatie. De volledige testset — inclusief eerdere, reeds geslaagde tests — moet uitgevoerd worden. De nieuwe test zal in eerste instantie falen.

2. Implementeer vervolgens de minimale code om de test te laten slagen. Deze eerste versie is mogelijk nog niet geoptimaliseerd, maar het uitvoeren van de volledige testset helpt regressies te voorkomen.

3. Herschrijf en optimaliseer de code voor productiekwaliteit. Door de tests opnieuw uit te voeren, kan worden geverifieerd dat er geen nieuwe fouten zijn geïntroduceerd. Deze aanpak is in de praktijk vaak efficiënter dan later tijdrovend debuggen

 

We gebruiken een demoproject om de TDD-cyclus concreet toe te lichten. U kunt elk gewenst unit-testframework gebruiken, zoals Unity of GoogleTest, maar in dit voorbeeld kiezen we voor Ceedling. Ceedling is gebruiksvriendelijk en bevat standaard mockingfunctionaliteit via CMock.
 
In dit demoproject bouwen we een regelbare spanningsdeler op basis van een digitale potentiometer, zoals de AD5160 [2], die via SPI wordt aangestuurd (zie Figuur 2). Als eerste stap splitsen we het systeem op in twee componenten: één voor het berekenen van de benodigde weerstand voor de gewenste uitgangsspanning, en één voor het versturen van deze waarde naar de chip via SPI. Om de nadruk op TDD te houden, nemen we enkele vereenvoudigingen in acht.

Firmware f2
Figuur 2: Softwaregestuurde spanningsdeler.

Na het installeren van Ceedling, maken we het project aan met het volgende commando:

 

ceedling new sw_voltage_divider

 

Vervolgens maken we een module aan met het commando:

 

ceedling module:create[voltageDiv]

 

Deze module zal de applicatiecode bevatten die verantwoordelijk is voor het berekenen van de benodigde weerstandswaarde. Het aanmaken van de module resulteert in de volgende mappenstructuur.

 

├── project.yml

├── src

│   ├── voltageDiv.c

│   └── voltageDiv.h

└── test

     └── test_voltageDiv.c

 

graphics team pls make sure that the special characters remain in the layout

Weerstandsberekening

De eerste stap is het implementeren van de code voor het berekenen van de vereiste weerstandswaarde. Dit wordt een eenvoudige functie die twee parameters ontvangt — de ingangsspanning en de gewenste uitgangsspanning — en de berekende weerstand retourneert. We beginnen met het schrijven van een eerste unit-test om de uitvoer van deze functie te verifiëren. Aangezien deze test in eerste instantie zal falen, vormt dit het beginpunt van onze TDD-cyclus.
 

// in src/voltageDiv.h

int32_t get_Resistance(uint32_t Vin, uint32_t Vout );

 

// in src/voltageDiv.c

int32_t get_Resistance(uint32_t Vin, uint32_t Vout )

{

  return -1;

}

 

// in test/test_voltageDiv.c

void test_whenValidVoutAndVinProvided_

        thenReturnCorrectResistance(void)

{

  TEST_ASSERT_EQUAL_INT32(get_Resistance(5000,2500),10000);

     // Assuming R1 must be 10 k, when Vin = 5 V and Vout = 2.5 V

}

 

De functie TEST_ASSERT... controleert of de waarde 10000 in int32 formaat, wordt teruggegeven wanneer de functie get_Resistance wordt aangeroepen met de opgegeven parameters.

De initiële implementatie zal de weerstand berekenen op basis van de spanningsdelingsregel. Dit is voldoende om de eerste test te laten slagen.

 

// in src/voltageDiv.c

int32_t get_Resistance(uint32_t Vin, uint32_t Vout )

{

  return 10000*Vout / (Vin - Vout);

}

Vervolgens zullen we de code herstructureren door R1 als macro in het headerbestand te definiëren en een controle toe te voegen om te garanderen dat Vin en Vout niet gelijk zijn, om deling door nul te voorkomen. Daarnaast introduceren we guard conditions om te garanderen dat noch Vin noch Vout nul is en dat Vin altijd groter is dan Vout. Door de unit-tests opnieuw uit te voeren, kunnen we verifiëren dat deze herstructurering geen nieuwe fouten heeft geïntroduceerd.

 

// in src/voltageDiv.c

int32_t get_Resistance(uint32_t Vin, uint32_t Vout )

{

  if(Vin == 0 || Vout == 0) return -1;

 

  if(Vin == Vout) return -1;

  if(Vout > Vin) return -1;

 

  uint32_t R2 = R1*Vout / (Vin - Vout);

 

  return R2;

}

 

De potentiometer-driver

Nu bouwen we de driver voor de digitale potentiometer. We maken een functie die de gewenste weerstandswaarde instelt door deze via SPI naar de chip te verzenden. De bijbehorende unit-test zal dit gedrag verifiëren. Conform de TDD-aanpak zal deze test bij de eerste uitvoering uiteraard falen. Laten we een module aanmaken met behulp van: 

 

// in src/AD5160.h

uint8_t pot_set(uint32_t res_value);

 

// in src/AD5160.c

uint8_t pot_set(uint32_t res_value)

{

  return 0;

}

 

// in test/test_AD5160.c

void test_AD5160_SetResistanceViaSpi(void)

{

  TEST_ASSERT_EQUAL_INT8(pot_set(5000),1);

}

 

Tijdens normale werking zal de functie pot_set de waarde 1 retourneren als bevestiging dat de digitale potentiometer succesvol is ingesteld; dit wordt gecontroleerd door de functie TEST_ASSERT... Zoals verwacht zal deze initiële test falen.

 

De basisimplementatie van de functie pot_set houdt in dat het juiste aantal stappen voor de digitale potentiometer wordt bepaald om een weerstandswaarde te benaderen die zo dicht mogelijk bij de gewenste waarde ligt. Omdat dit normaal gesproken een SPI-overdracht vereist met de daadwerkelijke hardware, gebruiken we een mock om deze overdracht tijdens het testen te vervangen. In Ceedling gebeurt dit door de functie spi_transfer_ExpectAndReturn aan te roepen vóór de assertie die pot_set test. Het enige wat we hiervoor hoeven te doen, is de declaratie van spi_transfer opnemen in het bestand SPI.h.

 

// in test/test_AD5160.c

void test_AD5160_SetResistanceValue(void)

{

  spi_transfer_ExpectAndReturn(128,1);

     // SPI Mock used inside pot_set

  TEST_ASSERT_EQUAL_INT8(pot_set(5000),1);

}

 

// in src/AD5160.c

uint8_t pot_set(uint32_t res_value)

{

  uint8_t step = 10000/256;

  uint8_t D = res_value / step;

  return spi_transfer(D);

}

 

Tot slot kunnen we de code herstructureren door macrodefinities toe te voegen en een controle te implementeren die verzekert dat de vereiste weerstandswaarde de maximale waarde van de potentiometer niet overschrijdt

 

uint8_t pot_set(uint32_t res_value)

{

  if(res_value > R2_MAX) return 0;

 

  uint8_t D = res_value / POT_RESOLUTION ;

 

  return spi_transfer(D);

}


Voor de volledige broncode van het demoproject, bezoek de GitHub repository.

Inschrijven
Schrijf u in voor tag alert e-mails over Programmeren!

Evaluatie van de efficiëntie van TDD

Zoals bij elke aanpak van een ontwikkelaar is testgestuurde ontwikkeling (TDD) niet in alle situaties de optimale oplossing. Toch kunnen de onderliggende principes, zelfs wanneer TDD niet volledig wordt toegepast, een grote invloed hebben op de manier waarop we software ontwikkelen. TDD biedt aanzienlijke voordelen, zoals

 

  • TDD bevordert modulariteit en isolatie van afhankelijkheden, wat essentieel is voor effectieve unit-tests. Dit resulteert in goed gestructureerde en draagbare code. Zo ontmoedigt TDD het vermengen van applicatielogica met directe hardware-interacties — zelfs wanneer dit uit efficiëntie-overwegingen verleidelijk lijkt.
  • De herhalende werkwijze van TDD helpt ontwikkelaars zich te concentreren op één onderdeel tegelijk, met nadruk op extern gedrag en interfaceontwerp om doeltreffende unit-tests te creëren.
  • Door bij elke stap unit-tests uit te voeren, kunnen fouten snel worden gelokaliseerd en is directe terugkoppeling mogelijk.
  • TDD maakt het mogelijk om met de ontwikkeling te starten vóór de doelhardware beschikbaar is, door hardwarecomponenten te simuleren met mocks. Dit maakt vroegtijdig testen en valideren mogelijk.
  • TDD maakt onafhankelijke ontwikkeling mogelijk, ook wanneer collega’s hun deel van het systeem nog niet hebben afgerond. Bijvoorbeeld: als een driver nog in ontwikkeling is, kan zijn gedrag worden gesimuleerd met een mock.
  • De TDD-cyclus ondersteunt een gestage voortgang van de ontwikkeling. Eén of meerdere unit-tests per feature bevestigen dat de functionaliteit succesvol is geïmplementeerd.

 

Een van de weinige wetenschappelijke studies naar TDD in embedded ontwikkeling, “Test-Driven Development and Embedded Systems: An Exploratory Investigation," onderzocht de invloed van TDD op softwarekwaliteit en ontwikkelproductiviteit. In dit onderzoek implementeerden negen masterstudenten proefprojecten met en zonder TDD. De resultaten lieten een verbetering zien in externe kwaliteit, maar geen significante verandering in productiviteit. Hoewel dit onderzoek geen definitief oordeel biedt, zijn de bevindingen zeker relevant.

Het is niet nodig om de bekende uitdagingen van TDD uitgebreid te belichten — zoals de extra code voor unit-tests of de inspanning om afhankelijkheden te vervangen door test doubles zoals mocks, die soms complex kunnen zijn. De keuze voor TDD moet altijd een afweging zijn tussen de extra investering en de verwachte meerwaarde.
 
Tot slot is het belangrijk om te benadrukken dat TDD een ontwikkelmethode is, geen implementatiestrategie — het leert ontwikkelaars niet hoe ze hun code moeten structureren of ontwerpen. TDD sluit aan bij Agile-methodieken, met name Extreme Programming (XP), dat frequente releases in korte iteraties voorstaat en een test-first-aanpak promoot. TDD is echter niet exclusief verbonden aan XP. Daarnaast dienen unit-tests die tijdens TDD zijn ontwikkeld, altijd te worden aangevuld met systeem- en integratietesten.
 

On-Target versus Off-Target Testing: welke kiest u?

Off-target en on-target testing verwijzen naar de plaats waar de tests worden uitgevoerd (zie Figuur 3). Bij off-target testing draaien de tests op de hostmachine die wordt gebruikt voor firmwareontwikkeling, terwijl bij on-target testing de tests rechtstreeks op de uiteindelijke doelhardware worden uitgevoerd. Hierbij worden de unit-tests uitgevoerd op het embedded systeem, terwijl de testresultaten via UART-logs of een vergelijkbaar protocol worden teruggestuurd naar de host.
 
Sommigen beweren dat off-target TDD ongeschikt is om hardwarefunctionaliteit te testen, en hoewel dat deels klopt, benadrukt dit juist de noodzaak van on-target of dual-target testing in bepaalde situaties. Off-target TDD kan echter van cruciaal belang zijn in gevallen waarin hardwarefouten moeilijk te simuleren of te reproduceren zijn. Bijvoorbeeld: bij het ontwikkelen van een flashgeheugendriver is het lastig om foutgevallen, zoals SPI-busfouten of flashfouten, betrouwbaar op te wekken — hardware werkt onder normale omstandigheden immers meestal correct.

TDD On-Target, Off-Target, and Dual-target strategies.
Figuur 3: TDD-strategieën: on-target, off-target en dual-target.

Hoewel on-target testing realistischer en wenselijker lijkt, zijn er situaties waarin off-target testing praktischer is:

 

  • De doelhardware is nog in ontwikkeling en niet gereed voor gebruik.
  • Beperkt geheugen op de target verhindert het uitvoeren van alle unit-tests.
  • Er is geen debugpoort of geschikte output-interface voor het weergeven van testresultaten.
  • Beperkte beschikbaarheid van hardware maakt testen voor het hele team moeilijk.
  • Bepaalde fouten (zoals busfouten) kunnen niet betrouwbaar op echte hardware worden opgewekt.
  • De testcyclus duurt te lang vanwege flashen van binaire bestanden en het ophalen van testresultaten.

 

In dergelijke gevallen maakt off-target TDD het mogelijk om de ontwikkel-voortgang te behouden. Periodieke of gedeeltelijke on-target tests kunnen dan aanvullend worden uitgevoerd — dit wordt ook wel dual-target testing genoemd.
 
Sommige ontwikkelaars denken dat unit-testsystemen zoals Unity enkel op de host kunnen draaien, maar dat is onjuist. Unity kan worden aangepast om rechtstreeks op de target te draaien, waarbij testresultaten via interfaces zoals UART worden geprint. On-target testing is echter moeilijker te automatiseren.
 
Aan de andere kant brengt off-target testing ook risico’s met zich mee, zoals verschillen tussen de host- en target compiler-toolchains. Zo is een int op de meeste ARM Cortex-M-platformen typisch 4 bytes, maar op x86-64 kan dit 4 of 8 bytes zijn. Een goede manier om het beste van beide werelden te combineren, is TDD toepassen met gebruik van de toolchain van de target in combinatie met emulators zoals QEMU voor snelle en naadloze testuitvoering.


TDD door de ogen van de firmware-ontwikkelaarsgemeenschap

Het verzamelen van feedback uit de ontwikkelaarsgemeenschap levert waardevolle inzichten op. Online platforms zoals Reddit bieden een venster op de praktijkervaringen van professionals uit de industrie — waaronder medewerkers van bedrijven die hun gebruik van TDD doorgaans niet publiek maken. Omdat gebruikers vaak anoniem posten, worden echte ervaringen gedeeld. Hieronder volgt een selectie van enkele noemenswaardige meningen, zonder persoonlijke interpretatie.

 

Een ontwikkelaar is overtuigd van de meerwaarde van TDD, ook al voelt het in het begin onnatuurlijk aan en is het niet de snelste manier om software te bouwen. Een ander benadrukt dat unittesten prima zonder hardware kunnen worden uitgevoerd, mits gebruik wordt gemaakt van degelijke mocking- en fakingtechnieken.

 

Tegelijkertijd is er weerstand. Een ontwikkelaar stelt dat TDD lastig is bij projecten met veel onbekende factoren, die pas tijdens de ontwikkeling duidelijk worden. Hierdoor wordt er volgens hem veel tijd verspild aan het verwijderen en herschrijven van tests. Zijn gangbare aanpak: de code eerst prototypen tot er een stabiele toestand is bereikt, gevalideerd met succesvolle high-level functionele of integratietests. Pas daarna volgt een agressieve herstructurerings-fase waarbij unittests worden toegevoegd aan onderdelen die minder vaak veranderen.


Conclusie

Sommigen bepleiten TDD zonder stil te staan bij de voor- en nadelen, terwijl anderen het principe volledig afwijzen. Toch kan het aanleren en uitproberen van TDD de ontwikkelpraktijk aanzienlijk verbeteren, zelfs wanneer het niet strikt wordt toegepast. TDD is bijzonder nuttig bij grote projecten met meerdere teamleden en complexe functionaliteit, maar biedt ook duidelijke voordelen voor kleinere teams en projecten.
 
Ontwikkelaars die TDD afwijzen, ondervinden vaak moeite met firmware-architectuur of het schrijven van doeltreffende testcases. In werkelijkheid ontstaan veel misvattingen over TDD uit een gebrek aan ervaring met het correct schrijven van unit-tests. Aanpakken zoals “test the interface, not the implementation” en Behavior-Driven Development (BDD) helpen om herhaling en overbodige tests te vermijden. Daarnaast biedt mutation testing inzicht in de kwaliteit van de tests door opzettelijk kleine wijzigingen in de code aan te brengen en te controleren of deze door de tests worden gedetecteerd.

Tot slot is de standaard literatuurtip het fundamentale boek Test-Driven Development for Embedded C, which, at the time of writing this article, is the only book specifically focused on applying TDD in embedded systems. Additionally, I recommend reading the book chapter “Test-Driven Development as a Reliable Embedded Software Engineering Practice” from Embedded and Real-Time System Development: A Software Engineering Perspective, waarin TDD in embedded toepassingen diepgaand wordt behandeld en waardevolle uitbreidingen worden voorgesteld. Een uitgebreide lijst van geraadpleegde bronnen bij dit artikel is opgenomen in het README-bestand van de bijbehorende GitHub-repository, omwille van ruimtebesparing.


Noot van de redactie: Dit artikel (250092-01) verschijnt in Elektor September/October 2025.


Vragen of opmerkingen?

 

Heeft u vragen of opmerkingen over dit artikel? Neem contact op met de auteur via clemens.valens@elektor.com of met de redactie van Elektor via redactie@elektor.com.

Inschrijven
Schrijf u in voor tag alert e-mails over Firmware!