Wie test de tests?

Jeroen Rachid Techniek

De Latijnse uitdrukking “Quis custodiet ipsos custodes?” (Wie bewaakt de bewaker) is ook van toepassing op tests. Hoe stel je vast dat je tests het goede testen? Welke maat kun je gebruiken voor de kwaliteit van de tests? Een veelgebruikte maat is coverage: het aantal regels code die geraakt worden in de uitvoering van een test. Veel teams gebruiken een doelpercentage (80% code coverage) als maat voor het bepalen van de kwaliteit van de tests. Maar je kunt relatief eenvoudig een hoge code coverage bereiken zonder dat je daadwerkelijk goede tests schrijft. Gelukkig zijn er ook andere manieren om de kwaliteit van je tests vast te stellen.

Wat is mutation testing?

Mutation testing is een manier om de kwaliteit van je unit tests te toetsen. De naam mutation testing wekt voor mij de suggestie dat dit een extra vorm van testing is die je moet doen naast integration testing, end-to-end testing, unit-testing en regressie-testing. Mutation testing doet echter geen uitspraak over de kwaliteit of functionaliteit van je code, maar over de kwaliteit van je tests. Voor mutation testing hoef je ook helemaal niets extra’s te maken of te schrijven: het is een analyse van je huidige testsuite. Normaalgesproken wordt dit alleen toegepast op unit tests, omdat dit de snelste vorm van tests zijn, en omdat een mutation test vaak wel tientallen keren trager is dan de originele testsuite. Technisch gezien kan je mutation testing toepassen op elke testvorm, zelfs end-to-end tests. Het wordt echter niet aangeraden.

Bij mutation testing wordt de code-under-test aangepast. Het mutation test framework maakt een mutant van je code, door bijvoorbeeld een conditie om te gooien (een > te veranderen in een < of een >=), een integer te verhogen of te verlagen, een statement weg te halen of een heel conditieblok uit te schakelen. De verwachting is dat er dan minimaal één van je unit tests faalt omdat er assertions zijn die hierop checken. In dat geval wordt de mutant gezien als ‘killed’. Als er geen unit test faalt dan ‘overleeft’ de mutant. Het resultaat is een mutation score: het percentage killed mutants.

Voor diverse talen zijn tegenwoordig goede mutation testing frameworks beschikbaar: Stryker Mutator is een veelgebruikte framework met varianten voor Javascript/Typescript, C# en Scala. Voor PHP is er Infection en voor Java is er PiTest.

Hieronder een voorbeeld in php dat gemaakt is m.b.v Infection.
Dit is de file under test:

<?php 

/* infectuous.php */ 

namespace MutationTesting; 

class Infectuous { 

  public static function doSomething(array $param): int { 
    if (isset($param['test'])) { 
      return 2; 
    }
    return 1; 
  } 

  public static function doSomethingElse(array $param): array { 
    if (isset($param['test'])) { 
      return ['test' => $param['test']];
    }
    return [];
  }
}

Met de volgende tests bereik je 100% code coverage op deze code:

<?php
/* InfectuousTest.php */

declare(strict_types=1);

use MutationTesting\Infectuous;
use PHPUnit\Framework\TestCase;

class InfectuousTest extends TestCase {
    public function testItShouldTestSomething(): void {
        $actual = Infectuous::doSomething([]);
        $this->assertNotEmpty($actual);
    }

    public function testItShouldTestSomethingMore(): void {
        $actual = Infectuous::doSomething(['test' => 1]);
        $this->assertNotEmpty($actual);
    }

    public function testItShouldTestSomethingElse(): void {
        $actual = Infectuous::doSomethingElse([]);
        $this->assertEmpty($actual);
    }

    public function testItShouldTestSomethingElseMore(): void {
        $actual = Infectuous::doSomethingElse(['test' => 1]);
        $this->assertNotEmpty($actual);
    }
}

Dit zegt het coverage report:
100% coverage op de tests

Echter het rapport van de mutation test laat iets anders zien:
mutanten ontsnapt tijdens de mutation test

Door even naar de tests te kijken zie je ook snel waarom dat zo is: er wordt nergens naar de inhoud van de return waardes gekeken. Er wordt enkel gekeken of ze leeg of niet leeg zijn. De mutanten die ontsnapt zijn, zijn al die mutanten die de return waarde manipuleren.

Het mutation testing framework kan een rapport maken waarin je de mutanten die de test overleefd hebben, kunt zien.

In dit voorbeeld zie je de mutant waarbij de waarde van de integer geflipt werd (van 2 naar -2).

mutanten ontsnapt tijdens de mutation test

Twee andere mutanten op dezelfde regel die overleefden, zijn de mutant die de waarde met 1 verhoogd en de mutant die de waarde met 1 verlaagd.
De kwaliteit van deze tests laten duidelijk te wensen over, terwijl wel 100% code coverage gehaald wordt.

Hoe werken mutation testing frameworks?

Mutation testing frameworks beginnen doorgaans met het maken van een een AST (Abstract Syntax Tree) van de code-under-test. Vervolgens wordt de AST geanalyseerd om te identificeren waar er mutanten kunnen worden geplaatst, en worden deze mutanten geïsoleerd geplaatst in (kopieën van) de AST. Bij gecompileerde talen zal er dan nog een compilatiestap volgen. Tot slot wordt de testsuite uitgevoerd voor elke mutant.

Sommige frameworks bevatten optimalisaties om bovenstaande wat vlotter te maken.

Stryker maakt gebruik van Mutation Switching: Alle mutanten worden conditioneel in dezelfde AST gezet en gecompileerd. Dit scheelt veel tijd, omdat er maar eens hoeft te worden gecompileerd. Ook kan dezelfde ‘assembly’ worden hergebruikt om de diverse mutants te testen, omdat ze worden aangezet op basis van environment variables. Het nadeel van deze aanpak is dat Stryker geen Enum definities en constante waardes kan muteren – dat is namelijk niet mogelijk op een conditionele manier.

middels mutation switching worden mutants conditioneel geplaatst in de broncode

In de bovenstaande code voorbeeld zie je de code die Stryker genereert om mutanten te maken. De Add methode is het origineel, en in Add_Mutated zie je wat het framework ervan heeft gemaakt. De StrykerMutantControl.IsActive methode gebruikt het framework om te bepalen welke mutant op dat moment actief is.

Mutation testing is helaas geen wondermiddel. Gegenereerde mutanten kunnen leiden tot veel false positives: mutanten die overleven, maar die niet relevant zijn voor je tests en ook geen uitspraak doen over de kwaliteit. In deze gevallen kan je je mutation test framework configureren om voortaan geen mutanten te plaatsen in bepaalde namespaces, classes of methodes. Let hierbij wel op dat je niet per ongeluk teveel uitzet!

Een ander probleem is dat een mutation test run erg lang kan duren. Bij elke mutant die gegenereerd wordt moet je je volledige testsuite runnen om te kijken of hij gekilled wordt.

Doorgaans is het zo dat mutation test frameworks de test runners instrumenteren dat ze ‘opgeven’ bij de eerst falende test, om tijd te besparen. Stryker kan zelfs de unit tests analyseren om mutanten te koppelen aan unit tests, en hoeft zodoende niet de hele test suite uit te voeren, maar alleen de relevante tests.

Stryker.NET bevat geavanceerde features voor de zeer grote projecten waarbij mutation testing een dag of zelfs langer kan duren. Met de ‘since’ flag kun je een git commit specificeren. Er zullen dan alleen mutanten worden gemaakt voor code die is veranderd sinds deze commit. Dat maakt de boel een stuk sneller, echter zorgt dit voor een incompleet rapport. Wil je alsnog een compleet rapport? Dan kan je tegelijk middels de ‘baseline’ flag aangeven waar de mutation report van de vorige run zich bevindt op je filesystem, en zal deze worden gemerged in het nieuwe rapport. Draai je Stryker in een pipeline? Dan kun je de ‘treshold’ configureren om de job te laten falen bij een te lage score.

Als er mutanten overleven dan betekent dat niet automatisch dat er een fout in je code zit, maar dat er een geval is dat niet door een test afgedekt wordt. In tegenstelling tot bij andere soorten tests (zoals integratietesten of unit testen) is het daarom minder van belang om bij elke code wijziging de mutation test uit te voeren. Sommigen kiezen er voor om dit als nightly job uit te voeren op de main branch.

Alhoewel de techniek al even bestaat, wordt er nog steeds onderzoek gedaan naar mutation testing. Doel van dat onderzoek is het verbeteren van de inzetbaarheid. Daarbij wordt vooral gekeken naar manieren om de mutation test sneller uit te kunnen voeren. Onze oud-medewerker Rasjaad heeft aan de UvA onderzoek gedaan naar mogelijkheden (onder andere met Machine Learning) om mutanten te clusteren, zodat minder mutanten getest hoeven worden, terwijl de betrouwbaarheid van de mutation test gelijk blijft. Zie Mutation Testing: clustering mutants.

Wil je zelf eens experimenteren met Mutation testing, dan zijn er handige online playgrounds beschikbaar:

Collega Rachid heeft tijdens zijn afstuderen de Stryker.NET playground ontwikkeld. Voor Infection is er de Infection Playground. In beide omgevingen kun je direct vanuit de browser experimenteren met mutation testing.

Is het vandaag bruikbaar?

Zeker. Microsoft gebruikt Stryker.NET op een aantal grote projecten. Met relatief weinig inspanning kan elke developer op zijn eigen machine een mutation test draaien. Je hoeft geen extra tests te maken, of dit als vereiste check aan je CI/CD pipeline toe te voegen (hoewel daar erg goede ondersteuning voor is).

Afhankelijk van hoeveel tests en hoeveel code je hebt, kan het wel enige tijd (minuten tot uren) duren voordat je mutation test is afgerond. De meeste tijd gaat waarschijnlijk zitten in het configureren van je mutation test framework, en het interpreteren van de resultaten om te bepalen voor welke delen van de code extra of betere tests noodzakelijk zijn.

Meer weten over testen?

Een afspraak maken bij ons op kantoor of wil je even iemand spreken? Stuur ons een mail of bel met Jolanda.