Testautomatisering

Het is normaal voor test suites om te groeien, maar verwijderen van testen is zeldzaam. Hierdoor worden sommige testen elke keer uitgevoerd zonder waarde toe te voegen. Het enige dat deze testen doen, is tijd verspillen en onderhoud kosten. Met dit in gedachten, ben ik op zoek gegaan naar een automatische manier om overbodige testen te detecteren met de kracht van mutatie testen.

Wat is een overbodige test?

Een overbodige test voegt geen waarde toe aan onze test suite en daarmee ook niet aan onze applicatie. Ze kosten echter wel tijd om uit te voeren en te onderhouden.

Ik zie een test als overbodig als een van de volgende uitspraken waar is:

  1. De test kan niet falen
  2. De test heeft een duplicaat, een test die dezelfde logica test

Beide uitspraken zijn moeilijk automatisch te detecteren, maar ik ga het proberen in dit experiment. Mijn hypothese voor dit experiment is: Onfaalbare en duplicate tests voegen nooit waarde toe aan een test suite en, in het verlengde daarvan, aan de kwaliteit van de applicatie onder test.

De kracht van mutatie testen

Mutatie testen is een groot onderwerp op zichzelf, maar dit is de basis. Mutatie testen is een test aanpak waar we in de broncode automatisch bugs (mutanten genoemd) genereren en kijken of er een unit test faalt door de mutant. Als een mutant geen testen laat falen willen we misschien een test toevoegen voor die situatie. De mutatie test runner genereert meerdere mutanten in een test run en voert meerdere testen uit voor elke mutant. Hierdoor is mutatie testen zeer CPU-intensief, zelfs met alle uitstekende optimalisaties van de tool makers.

Mutatie testen resulteert in een dataset waarin voor elke test staat welke mutanten het dekt. Normaal gebruik je deze dataset om de mutatie dekking te berekenen, maar we kunnen het ook gebruiken voor andere doeleinden. Het meest relevant voor dit experiment is dat de data ons het volgende kan vertellen:

  • Voor elke test, hoe vaak is het gefaald?
  • De mutatie test runner geneert veel bugs tijdens een test run. Onder deze omstandigheden zal elke test die kan falen dat ook doen.
  • Er zijn uitzonderingen: Mutatie test tools kunnen de meeste bug types genereren, maar niet alles.
  • Voor elke test, welke mutanten dekt het?
  • Hiermee kunnen we de overlap tussen testen detecteren. Twee testen die exact dezelfde mutanten dekken, dekken dezelfde logica en zijn dus duplicaat.

Barrière voor succes

De grootste barrière voor succes is dat mutation testen langzaam is. Praktisch gezien is het te langzaam voor alles behalve unit tests. Toekomstige hardware verbeteringen en tool optimalisaties kunnen dit probleem verzachten, maar testduur zal altijd een probleem blijven vanwege de aard van mutation testen.

Het tweede probleem is dat mutation testen langzaam is. Ja, nog een keer, dat is hoe langzaam het is. Er zijn verhalen van grote codebases met volwassen test suites waar het draaien van mutation testen dagen duurt. Tegelijkertijd halen grote codebases met volwassen test suites het grootste voordeel uit het detecteren van overbodige testen. Dat maakt deze aanpak moeilijk om te verkopen.

Laten we het proberen

Genoeg theorie. Ik heb een proof-of-concept geschreven voor het detecteren van onfaalbare en duplicate testen. Om het concept te testen, heb ik de codebase van het open source project Express gepakt en mutation testen toegevoegd met Stryker.

Express is een populair HTTP server framework met een 12-jaar oude JavaScript codebase en een uitstekende unit test suite van 1145 tests (op het moment van schrijven). Ik heb Express gekozen omdat ik weet dat het een uitgebreide unit test suite heeft waardoor we een grotere kans op succes hebben. Stryker is een mutatie test runner. Het is een project voor mutatie testen in JavaScript, C# en Scala. Ik heb de JavaScript versie van Stryker gebruikt.

Vanwege de manier waarop Stryker werkt, kan de mutatie dekking tussen test runs lichtelijk afwijken. Om de data zo zuiver mogelijk te maken heb ik elke test 10 keer uitgevoerd en heb ik meetonzekerheid opgenomen in de onderstaande resultaten. Meer data zou beter zijn, maar elk van de 10 runs duurt tussen de 10 en 20 minuten. Het telt snel op.

Test 1: Baseline

Voer alle testen uit zonder aanpassingen.

1

De mutatie dekking van 88% is een zeer hoge score. Het is zeldzaam voor een applicatie om in de buurt van 80% te komen, omdat het zeer hoge test standaarden vereist. We zien hier dat sommige testen nooit falen en dat er veel duplicaten zijn.

Test 2: Skip onfaalbare testen

13 testen hebben nooit gefaald in de eerste test. Ik heb deze testen uitgezet.

2

Het uitzetten van deze 13 testen heeft de mutatie dekking niet significant veranderd. Het verschil valt binnen de meetonzekerheid.

Deze data ondersteunt de hypothese. We hebben echter meer data nodig voordat we definitief kunnen stellen dat het verwijderen van onfaalbare testen geen impact heeft op de kwaliteit van de test suite. Het merendeel van de uitgezette testen kijken of de publieke API van Express bestaat. Stryker kan dit type bug niet genereren. Een bug in de publieke API zal afgevangen worden in andere test types die de publieke API wel actief gebruiken. De rest van de uitgezette testen dekken de functionaliteit van een dependency. Geen van de uitgezette testen lijken onterecht gemarkeerd te zijn als onfaalbaar.

Test 3: Skip een aantal duplicate testen

In de vorige testen hadden we veel duplicate testen. Ik heb 10 duplicate testen uitgezet. Voor deze proof-of-concept heb ik uitsluitend simpele duplicaten gekozen. Dit zijn situaties waar twee testen precies dezelfde mutanten afdekken. Een van die twee testen heb ik uitgezet. Complexe duplicaten zijn te detecteren, maar het vereist meer implementatie effort dus ik laat het voor nu links liggen.

3

Het uitzetten van deze tien testen heeft de mutatie dekking niet significant veranderd. Het verschil valt binnen de meetonzekerheid. Deze data ondersteunt de hypothese. We hebben echter meer data nodig voordat we definitief kunnen stellen dat het verwijderen van duplicate testen geen impact heeft op de kwaliteit van de test suite.

Data samenvatting

De verzamelde data ondersteund de hypothese. De mutatie dekking is nauwelijks veranderd na het uitzetten van 23 testen. Maar de dataset is klein. We kunnen geen harde conclusies trekken voordat we meer data hebben verzameld.

results

Conclusie

De data die we tijdens het experiment hebben gegenereerd, vertelt ons dat we een tool kunnen maken die ons helpt met het verkleinen van onze low-level test suites zonder de kwaliteit significant aan te tasten. We kunnen dit doen met onze definitie van een overbodige test en de data gegenereerd door mutatie testen.

De bruikbaarheid van zo’n tool in de praktijk is discutabel. De codebases die er de meeste baat bij zouden hebben, zijn tegelijkertijd ook degene die de tool het moeilijkst kunnen gebruiken. De CPU-intensieve aard van mutation testen betekent  dat zo’n tool uren of zelfs dagen nodig heeft. Tijdens dit experiment heb ik me meerdere keren afgevraagd of het het waard is om zo’n tool te bouwen en ik ben er nog steeds niet uit.

Zelfs als deze aanpak te CPU-intensief is voor dagelijks gebruik, is het misschien  nuttig om af en toe te gebruiken. Het kan het waard zijn om deze tool één keer per jaar te draaien. Een test suite lente schoonmaak zeg maar. Denk jij dat het het waard is om deze tool te bouwen? Waar zou jij het voor gebruiken?

 

Reproduceer het zelf

{
“testRunner”: “mocha”,
“coverageAnalysis”: “perTest”,
“reporters”: [“html”, “progress”, “json”],
“ignoreStatic”: true
}

  1. Draai StrykerJS met npx stryker run
  2. Draai het onderstaande script met NodeJS

import { readFile } from “node:fs/promises”;
import { calculateMetrics } from “mutation-testing-metrics”; // A dependency of Stryker

const inputPath = “./reports/mutation/mutation.json”;
const input = JSON.parse(await readFile(inputPath, “utf-8”));

const metrics = calculateMetrics(input.files).metrics;
console.log(“Total mutants: ” + metrics.totalValid);
console.log(“Mutation coverage: ” + metrics.mutationScore.toFixed(3) + “%”);

// —————–

const allMutants = Object.values(input.files)
.map((file) => file.mutants)
.flat();

const allTests = Object.entries(input.testFiles)
.map(([filePath, file]) =>
file.tests.map((t) => {
return { …t, filePath };
})
)
.flat()
.map((test) => {
return {
…test,
killedMutants: allMutants
.filter((mutant) => mutant.coveredBy.includes(test.id))
.sort((a, b) => {
if (a.id > b.id) return -1;
if (a.id < b.id) return 1;
return 0;
}),
};
});
console.log(“Total tests: ” + allTests.length);

// —————–

const neverFailed = allTests.filter((t) => t.killedMutants.length === 0);

console.log(“Tests that can’t fail (0 mutants killed): ” + neverFailed.length);
// console.log(neverFailed);

// —————–

const testsWithDuplicates = allTests
.filter((t) => t.killedMutants.length > 0)
.map((thisTest) => {
return {
…thisTest,
duplicates: allTests.filter((otherTest) => {
if (otherTest.id === thisTest.id) {
return false;
}
if (otherTest.killedMutants.length !== thisTest.killedMutants.length) {
return false;
}

// Crude and inefficient way to compare which mutants are covered by each test
const thisKilledMutants = JSON.stringify(
thisTest.killedMutants.map((m) => m.id)
);
const otherKilledMutants = JSON.stringify(
otherTest.killedMutants.map((m) => m.id)
);
const match = thisKilledMutants === otherKilledMutants;

return match;
}),
};
})
.filter((test) => test.duplicates.length > 0);

console.log(
“Tests with at least 1 duplicate (same mutants covered): ” +
testsWithDuplicates.length
);
// console.log(testsWithDuplicates);

Wil je ons nieuwste Paarsz magazine per post ontvangen? Laat dan je gegevens achter.

Ontwerp zonder titel (19)

Werken bij Bartosz?

Vincent Verhelst

Geïnteresseerd in Bartosz? Dan ga ik graag met jou in gesprek. We kunnen elkaar ontmoeten met een kop koffie bij ons op kantoor. Of tijdens ontbijt, lunch, borrel of diner op een plek die jou het beste uitkomt. Jij mag het zeggen.

Mijn Paarsz