Embedded software blijft meestal onveranderd nadat het product waar het op draait op de markt is gebracht. Dus hoe kun je er zeker van zijn dat je code vrij is van fouten? We duiken in de wereld van het testen van software om dat uit te zoeken!

In "The Art of Software Testing" van Glenford J. Myers begint de auteur met de vraag aan de lezer om te bedenken hoe je een oefenprogramma kunt testen. Als je nog nooit eerder een softwaretest hebt geschreven, besef je ineens hoe moeilijk testen is. Waar moet je beginnen? Hoe moet je het aanpakken? Wanneer moet je eindigen? Maar misschien is de meest cruciale vraag: wat is softwaretesten?

Myers geeft ons een uitstekende omschrijving om deze laatste vraag te beantwoorden:

“Testen is het proces van het uitvoeren van een programma met de bedoeling fouten te vinden.”

Software krijgt vaak een slechte naam. In tegenstelling tot hardware, zoals een printplaat of chipontwerp dat gedurende de levensduur van het product hetzelfde blijft, kan software veranderd worden. Dus, zo luidt de gedachte, hoef je niet zo streng te zijn bij de ontwikkeling ervan, omdat eventuele fouten met relatief gemak hersteld kunnen worden. Dit geldt voor een met internet verbonden machine in een kantoor of datacentrum, maar niet als je satellieten bouwt met een levensduur van 20 jaar.

Embedded software ontwikkeling past niet in deze "we kunnen het achteraf aanpassen" mentaliteit omdat de software onlosmakelijk verbonden is met de hardware, vooral code die nauw verbonden is met de randapparatuur. Bovendien zijn de meeste microcontrollers niet verbonden met het internet, en als dat wel zo is, zoals een IoT-sensor, is de beschikbare bandbreedte waarschijnlijk niet voldoende om het uploaden van een nieuwe firmware-image te ondersteunen.

Er is ook nog een andere uitdaging. Als je code ontwikkelt op een PC, kun je relatief snel tests uitvoeren en de resultaten op je scherm zien. Op een microcontroller heb je waarschijnlijk geen scherm of een toetsenbord. Dus hoe testen ontwikkelaars embedded software?

    Heb je ooit testen uitgevoerd?


    Testen van embedded software versus eisen

    De eerste stap naar het succesvol testen van embedded software is een goede definitie van wat de software moet doen. Er moet een document van eisen worden opgesteld waarin de verwachte functionaliteit en eventuele beperkingen worden uitgelegd. Als je bijvoorbeeld een functie hebt die temperatuurmetingen omrekent van Celsius naar Fahrenheit, dan is een eis dat de omrekening voor alle waarden nauwkeurig is. De wiskundige conversie is dan:
     
    TempF = TempC × (9/5) + 32
     
    Het ontwikkelen van code voor embedded systemen vereist echter vaak dat we werken onder de beperkingen van de schaarse hulpmiddelen van kleine microcontrollers. Dus, zonder floating-point ondersteuning kan dat betekenen dat we een functie ontwikkelen met alleen maar gehele getallen. Dit veroorzaakt onvermijdelijk hogere foutniveaus in het resultaat dan je zou verwachten van een floating-point implementatie. Dit zou dus in de eisen moeten worden aangegeven, mogelijk als de te verwachten nauwkeurigheid.

    Rekenen met gehele getallen kan ook het bereik van de om te rekenen waarden beperken. Een signed 8-bits char beperkt het invoerbereik van -128 tot +127, maar beperkt ook het resultaat tot hetzelfde bereik. Dit beperkt impliciet het invoerbereik van -88°C tot +52°C, dat wordt omgezet in -127°F tot +127°F. Hoewel dit allemaal erg beperkend klinkt, is het een realistische beperking voor een 8-bits microcontroller die zich richt op temperatuurmetingen in huis. Een vergelijking van een code met 8-bits char en float implementaties staat aan het eind van dit artikel.
     
    Temp graph - Testing Embedded Software
    Verband tussen Celsius en Fahrenheit van -128°C tot 127°C
    Het is ook essentieel om rekening te houden met tests waarvan je weet dat ze moeten mislukken. Bijvoorbeeld, een omrekening van -100°C zou geen -148°F moeten opleveren voor onze geoptimaliseerde functie-implementatie. Gebeurt dat wel, dan kan er iets mis zijn. Dergelijke dingen kunnen voorkomen wanneer char niet beperkt is tot 8 bits op sommige processorarchitecturen.
    Deze grenzen in functionaliteit en in- en uitvoerbereik helpen ons ook bij het definiëren van onze tests.

    Black versus white-box tests

    Met de specificatie in de hand zijn er twee fundamentele benaderingen voor het ontwikkelen van de tests: black-box en white-box tests. Black box gaat ervan uit dat je de code test volgens de specificatie zonder de implementatie te kennen. Een white-box test houdt rekening met de specificaties tijdens de testontwikkeling, maar met begrip van hoe de code werkt.

    In het geval van onze temperatuurconversiefunctie zouden black-box tests kunnen resulteren in een uitputtende verzameling tests. Alle mogelijke geldige ingangen zouden getest kunnen worden zoals gedefinieerd in de specificatie (-88 tot +52°C). Het resultaat, in Fahrenheit, wordt gecontroleerd volgens de gespecificeerde nauwkeurigheid (+/- 1°F). In dit voorbeeld is het aantal tests dat resulteert groot maar beheersbaar. Als we echter 16-bits waarden zouden ondersteunen, of meerdere invoerwaarden zouden hebben, wordt het aantal testgevallen dat resulteert al snel onbeheersbaar, zowel wat betreft de hoeveelheid als de uitvoeringstijd van de tekst.
     
    White vs Black Box - Testing Embedded Software
    Een black box test ontwikkelt testen die uitsluitend gebaseerd zijn op de eisen. White
    box tests gebruiken bovendien het begrip van de code implementatie.

    Black-box tests

    Om het testen hanteerbaarder te maken, kunnen enkele aannames over de te testen code worden gedaan om het aantal tests te beperken. Bijvoorbeeld, als de functie 25°C correct omzet, werkt hij waarschijnlijk ook correct voor 26 en 24. Dit staat bekend als een equivalentieklasse en wordt gebruikt om het aantal testgevallen formeel te verminderen zonder afbreuk te doen aan de kwaliteit van het testen.

    Er zijn andere strategieën om het aantal testgevallen te verminderen. Grenswaarde-analyse onderzoekt de randvoorwaarden van equivalentieklassen. In ons voorbeeldgeval kijken we naar de grenzen van de invoerwaarden zoals gedefinieerd in de specificatie (bijvoorbeeld -88 tot -86°C, en +50 tot +52°C). Als programmeur weten we ook dat er problemen kunnen ontstaan als variabelen verkeerd gedefinieerd zijn, zoals unsigned char in plaats van char. Daarom zijn tests voor invoer van -1°C, 0°C, en 1°C zinvol, evenals tests die resultaten verwachten van -1°F, 0°F, en 1°F.

    De volledige reeks black-box testbenaderingen, zoals opgesomd door Myers, zijn:
    • Gelijkwaardigheidsverdeling.
    • Grenswaarde analyse.
    • Cause-effect graphing: een formele methode voor testontwikkeling die geschikt is voor complexe systemen.
    • Fouten raden: een informele methode voor testontwikkeling, gedreven door intuïtie en ervaring.

    White-box tests

    Bij white-box tests wordt een andere aanpak gehanteerd. De testontwikkelaar kent de implementatie van de code. In ons voorbeeld bevat de functie slechts één regel broncode in C: de wiskundige vergelijking om Celsius om te zetten in Fahrenheit. Het resultaat van de tests zou dus niet veel verschillen van die van een black box benadering.

    Als de broncode echter beslissingen bevat, zoals if- en switch-statements, verandert de zaak. Met kennis van de logica van het programma kan een tester tests maken die ervoor zorgen dat alle mogelijke paden in de code worden uitgeprobeerd. Zo zullen de testgevallen sommige schijnbaar vreemde waardecombinaties doorstaan, zodat coderegels diep in de software worden bereikt. Opnieuw stellen verschillende benaderingen teams in staat om verschillende niveaus van toetsing te bereiken.

    Myers somt het volgende op:
    • Statement coverage: Zorg ervoor dat alle stellingen worden uitgevoerd.
    • Beslissingsdekking: Zorg ervoor dat alle stellingen voor beslissingen minstens één keer worden getest om waar en onwaar op te leveren.
    • Voorwaardelijke dekking: Zorg ervoor dat voorwaarden van stellingen met beslissingen worden getest om waar en onwaar op te leveren (zoals if(A && B) ).
    • Besluit/conditie dekking: Beide benaderingen zijn nodig in code met een complexere flow om meer mogelijke paden uit te oefenen.
    • Meervoudige-beslissingsdekking: Gewoonlijk bekend als aangepaste voorwaarde/beslissingsdekking (MC/DC), dekt deze grondige aanpak ook paden die de bovenstaande alternatieven kunnen verbergen. Het wordt gebruikt bij het testen van veiligheidskritische software in sommige auto-, medische, lucht- en ruimtevaarttoepassingen.
     
    ELK087 - Testing Approaches.PNG
    Voorbeelden voor equivalentieklassen en grenswaardeanalyse voor onze temperatuurconversiefunctie.
    Als je onderzoek doet naar het testen van software, kun je ook gray-box testen tegenkomen. Dit ligt tussen de twee hierboven beschreven benaderingen in, wanneer de testontwikkelaar enig inzicht heeft in de implementatie van de code, maar minder dan beschikbaar is voor white-box testen.

    Wanneer test je embedded software?

    Het is nooit te vroeg om te beginnen met testen. En hoewel het verleidelijk is om tests te schrijven voor je eigen code, zou dit de taak moeten zijn van iemand die onbekend is met de software. De bovenstaande testbenaderingen resulteren in veel tests die uren kunnen duren om uit te voeren. Je zult echter willen controleren of je testomgeving werkt voordat je een nachtelijke testrun start. Daarom is het de moeite waard om een testcase te ontwikkelen met een handvol tests die dit kunnen aantonen. Dit staat bekend als een rooktest.

    De naamgeving van tests hangt ongeveer samen met het ontwikkelingsstadium van je project. Tests voor onze temperatuurconversiefunctie worden unit tests genoemd. Deze testen een functie of module in isolatie. Deze code kan getest worden op een PC, omdat hij universeel is en niet afhankelijk van de mogelijkheden van de beoogde microcontroller. Software ter ondersteuning van het testen van systemen is onder andere Unity, dat ontworpen is voor embedded ontwikkelaars, en CppUnit for C++.

    Tests worden meestal gemaakt met behulp van een bewering, een verklaring van het verwachte juiste resultaat. Als het resultaat onjuist is, wordt de testfout genoteerd en gerapporteerd na voltooiing van alle tests. Hieronder staat een voorbeeld met Unity:
     
    // Example tests developed using Unity

    // Variable value test
    int a = 1;
    TEST_ASSERT( a == 1 ); //this one will pass
    TEST_ASSERT( a == 2 ); //this one will fail

    // Example output for failed test:
    TestMyModule.c:15:test_One:FAIL

    // Function test; function is expected to return five
    TEST_ASSERT_EQUAL_INT( 5, FunctionUnderTest() );

    (Source: Unity)

    Het testen van protocolstacks is een grotere uitdaging, omdat deze verwachten gegevens te delen met lagen erboven en eronder. Om dit te bereiken, simuleren software-implementaties, bekend als een stub, het verwachte gedrag.

    Code die rechtstreeks werkt op de randapparatuur van een microcontroller is moeilijker te testen. Eén benadering is het ontwikkelen van een hardware-in-the-loop (HIL) opstelling. Bij het testen van code die de UART initialiseert kan bijvoorbeeld een tweede microcontroller worden gebruikt om de juiste werking voor elke test te bevestigen. Als alternatief kan een logische analyzer met een programmeerinterface de uitvoer vastleggen, waarbij de baudrate en de juiste pariteit en stopbitconfiguratie worden gecontroleerd.

    Later in het ontwikkelingsproces zullen de verschillende softwaremodules worden gecombineerd. We willen bijvoorbeeld ons Fahrenheit resultaat uitvoeren met behulp van een circulaire buffer gekoppeld aan de UART interface. Dit stadium vereist integratietesten om te bepalen of de afzonderlijke softwaremodules nog correct werken wanneer ze met elkaar verbonden zijn. Voor embedded systemen vereist dit ook een HIL benadering.

    Voor een voltooid product zijn systeemtests nodig. In dit stadium is er weinig behoefte om de functionaliteit van de code te onderzoeken. In plaats daarvan richt het testteam zich op de algehele functionaliteit. Zo wordt onderzocht of een druk op een knop de juiste reactie oplevert, of het scherm de juiste berichten geeft, en of de resulterende functionaliteit aan de verwachtingen voldoet.

    Embedded software testen - niet zo eenvoudig als het lijkt

    Testen is een complexe aangelegenheid, met uitdagingen die variëren van welke tests je moet uitvoeren tot hoe je de tests moet uitvoeren. Gelukkig zijn er veel goede bronnen beschikbaar die formele testbenaderingen uitleggen. Zelfs het boek van Myers, geschreven in de jaren 1970, is nog steeds relevant. Testontwikkeling wordt ook vergemakkelijkt door een reeks open-source testraamwerken, die de drempel verlagen voor embedded ontwikkelaars die hun testaanpak willen verbeteren.

    Voorbeeldcode voor het omrekenen van Celsius naar Fahrenheit

    8-bits microcontrollers werken efficiënter met 8-bits waarden. Bovendien is het onwaarschijnlijk dat ze hardware bevatten om floating-point berekeningen te versnellen. Daarom is het zinvol om waar mogelijk processor-geoptimaliseerde functies te schrijven die snel en efficiënt zijn. Door het bereik van ondersteunde Celsius-waarden te beperken, is de volgende op char gebaseerde temperatuurconversiefunctie vijf keer sneller dan dezelfde functie die floatvariabelen gebruikt. De uitvoeringstijden zijn gebaseerd op een Arduino MEGA (ATMEGA2560) gecompileerd met de standaard Arduino IDE instellingen.
     
    // 8-bit Celsius to Fahrenheit conversion function. 
    // Required 5.5µs to execute
    // Limited to Celsius range of -88°C to +52°C
    char convertCtoF(char celsius) {
      int fahrenheit = 0;

      digitalWrite(7, HIGH);
      // F = (C * (9/5)) + 32 - convert celsius to fahrenheit
      // (9/5) = 1.8
      fahrenheit = ((18 * ((int) celsius)) + 320) / 10;
      digitalWrite(7, LOW);
      
      return (char) fahrenheit;
    }

    // Floating-point Celsius to Fahrenheit conversion function. 
    // Required 24.3µs to execute
    // Limited to range of 'float'
    float fpConvertCtoF(float celsius) {
      float fahrenheit = 0.0;

      digitalWrite(7, HIGH);
      fahrenheit = (celsius * (9.0 / 5.0)) + 32.0;
      digitalWrite(7, LOW);
      
      return fahrenheit;
    }


    Vertaling: Hans Adams