Mutaatiotestaus
This blog post is available in English at https://jgke.fi/blog/posts/mutation-testing/
Olet asiakkaalla, ja pöydälle tulee vaatimus funktiosta, jolla saadaan validoitua saako jonkin ikäinen ihminen juoda alkoholia baarissa. Vastaushan riippuu luonnollisesti iän lisäksi maasta. Kun kovistelit asiakasta vähän tarkemmin, sait tietää että koodin pitäisi osata vastata oikein Suomen, Yhdysvaltojen ja Saksan osalta. Ikärajat ovat vastaavasti 18, 21 ja 16 kussakin maassa.
Laitat projektin pystyyn ja alat hahmottelemaan ratkaisua Javalla.
$ mvn archetype:generate -DgroupId=fi.bytecraft.mutations -DartifactId=mutations -DarchetypeArtifactId=maven-archetype-quickstart -DarchetypeVersion=1.4 -DinteractiveMode=false
package fi.bytecraft.mutations;
public class App
{
public static void main(String[] args) {}
enum CountryCode {
FI, /* Finland */
US, /* United States */
DE /* Germany */
}
public static boolean canDrinkAlcohol(int age, CountryCode country) {
if (country == CountryCode.FI && age <= 17) {
return false;
} else if (country == CountryCode.US && age < 20) {
return false;
} else if (country == CountryCode.DE && age <= 15) {
return false;
}
return true;
}
}
Jokaiseen koodipohjaan kuuluu luonnollisesti testit. Koska asiakas on ilmoittanut deadlineksi ’eilen’, harjoitat hieman summittaista testausta ja kirjoitat muutaman testin ajattelematta tarkemmin:
package fi.bytecraft.mutations;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;
import org.junit.Test;
public class AppTest
{
@Test
public void TestAlcoholLegalAges()
{
assertFalse(App.canDrinkAlcohol(17, App.CountryCode.FI));
assertTrue(App.canDrinkAlcohol(18, App.CountryCode.FI));
assertFalse(App.canDrinkAlcohol(18, App.CountryCode.US));
assertTrue(App.canDrinkAlcohol(21, App.CountryCode.US));
assertFalse(App.canDrinkAlcohol(15, App.CountryCode.DE));
assertTrue(App.canDrinkAlcohol(16, App.CountryCode.DE));
}
}
Koodi ainakin tuntuu toimivan:
$ mvn test
[... a lot of text ...]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.02 s - in fi.bytecraft.mutations.AppTest
[... a lot of text ...]
Et ole ihan varma, että testaako kyseiset testit kaikkea koodia. Laitat koodipohjaan simppelin testikattavuuslaskurin, ja katsot miltä näyttää:
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
</plugin>
$ mvn jacoco:prepare-agent install jacoco:report
[... a lot of text ...]
$ xdg-open xdg-open target/site/jacoco/index.html
Vilkaiset raporttia, jossa kaikkien rivien kohdalla lukee vihreää eli testit olivat koskeneet jokaiseen riviin. Tästä huojentuneena painat deploy-nappulaa…
…ja parin viikon päästä asiakas soittaa vihaisen puhelun, että jollekin alaikäiselle oli myyty alkoholia 1. Mikä meni pieleen?
Mutaatiotestaus
Mutaatiotestaus tarkoittaa sellaisten testaustyökalujen käyttämistä, jotka muokkaavat automaattisesti koodia ennen testien ajoa. Näiden koodimuutosten tarkoitus on rikkoa koodi, minkä pitäisi näkyä sitten testien hajoamisesta. Työkalut tämän jälkeen ilmoittavat tavallisen testikattavuuden sijaan mutaatiotestikattavuuden.
Mutaatiot tarkoittavat tässä tapauksessa koodin automaattista muokkaamista (esimerkiksi <
-vertailuoperaattorin vaihtaminen >
-vertailuoperaattoriksi), jonka jälkeen testien tulisi ilmoittaa virheistä.
Otetaan esimerkiksi max(a,b)
-funktio, jonka toteutus on max(a,b) = a > b ? ab
. Jos pseudokoodissa olevan <
-operaattorin muuttaa >
-operaattoriksi, niin koodi ei tee enää samaa kuin aiemmin, jonka testien tulisi huomata.
Javalle on saatavilla pitest
-niminen kirjasto, jolla olemassa olevan testipatterin voi ajaa mutaatiotesteinä. Otetaan pitest
käyttöön:
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>LATEST</version>
<configuration>
<mutators>
<mutator>DEFAULTS</mutator>
</mutators>
</configuration>
</plugin>
$ mvn test-compile org.pitest:pitest-maven:mutationCoverage
Terminaaliin tulee iso liuta tekstiä, joka näyttää suunnilleen seuraavalta:
[... a lot of text ...]
> org.pitest.mutationtest.engine.gregor.mutators.RemoveConditionalMutator_EQUAL_ELSE
>> Generated 3 Killed 3 (100%)
> KILLED 3 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0
> NO_COVERAGE 0
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.RemoveConditionalMutator_ORDER_IF
>> Generated 3 Killed 3 (100%)
> KILLED 3 SURVIVED 0 TIMED_OUT 0 NON_VIABLE 0
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0
> NO_COVERAGE 0
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.rv.CRCR3Mutator
>> Generated 7 Killed 6 (86%)
> KILLED 6 SURVIVED 1 TIMED_OUT 0 NON_VIABLE 0
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0
> NO_COVERAGE 0
--------------------------------------------------------------------------------
> org.pitest.mutationtest.engine.gregor.mutators.ConditionalsBoundaryMutator
>> Generated 3 Killed 2 (67%)
> KILLED 2 SURVIVED 1 TIMED_OUT 0 NON_VIABLE 0
> MEMORY_ERROR 0 NOT_STARTED 0 STARTED 0 RUN_ERROR 0
> NO_COVERAGE 0
[... a lot of text ...]
Raportissa näkyvät SURVIVED 1
-rivit tarkoittavat, että jotain koodiriviä muutettiin, ja testit menivät läpi – eli toisin sanottuna testit eivät oikeasti testaakaan kaikkea.
Graafisesta näkymästä näkee paremmin, mistä on kyse:
$ xdg-open target/pit-reports/*/index.html
Kuvan perusteella huomataan, että koodi ei olekaan erityisen hyvin testattua, ja muutetaan testejä vastaamaan enemmän todellisuuden tarpeita:
@Test
public void TestAlcoholLegalAges()
{
assertFalse(App.canDrinkAlcohol(16, App.CountryCode.FI));
assertFalse(App.canDrinkAlcohol(17, App.CountryCode.FI));
assertTrue(App.canDrinkAlcohol(18, App.CountryCode.FI));
assertTrue(App.canDrinkAlcohol(19, App.CountryCode.FI));
assertFalse(App.canDrinkAlcohol(19, App.CountryCode.US));
assertFalse(App.canDrinkAlcohol(20, App.CountryCode.US));
assertTrue(App.canDrinkAlcohol(21, App.CountryCode.US));
assertTrue(App.canDrinkAlcohol(22, App.CountryCode.US));
assertFalse(App.canDrinkAlcohol(14, App.CountryCode.DE));
assertFalse(App.canDrinkAlcohol(15, App.CountryCode.DE));
assertTrue(App.canDrinkAlcohol(16, App.CountryCode.DE));
assertTrue(App.canDrinkAlcohol(17, App.CountryCode.DE));
}
…ajetaan testit:
$ mvn test
[...]
[INFO] Running fi.bytecraft.mutations.AppTest
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.019 s <<< FAILURE! - in fi.bytecraft.mutations.AppTest
[ERROR] TestAlcoholLegalAges(fi.bytecraft.mutations.AppTest) Time elapsed: 0.004 s <<< FAILURE!
java.lang.AssertionError
at fi.bytecraft.mutations.AppTest.TestAlcoholLegalAges(AppTest.java:19)
[...]
…jonka jälkeen huomataan bugi alkuperäisessä koodissa:
- } else if (country == CountryCode.US && age < 20) {
+ } else if (country == CountryCode.US && age <= 20) {
Korjauksien jälkeen koodi toimii. Kun testit ajetaan uudelleen pitest
in läpi, raportti näyttää nyt vihreämmältä!
Tässä esimerkissä saatiin mutaatiotestauksella löydettyä bugi, mitä ei alkuperäisestä koodista löytynyt tavanomaisella testikattavuuden mittauksella.
Ei pelkkää hyvää
Mutaatiotestaus, kuten kaikki muutkin työkalut, sisältää omat haittapuolensa. Selkeimmät ovat testien ajamisen hitaus sekä ajoittaiset false positive -virheet. Kuten muutkin testit, mutaatiotestaus ei myöskään takaa, että koodi toimisi.
Mutaatiotestaus myös rajoittuu testaamaan vain mutaatioita, joita kirjasto osaa tehdä koodiin. Nämä sisältävät esimerkiksi operaattorien muuttamista toisiinsa, sekä vakioiden arvojen muuttamista. Teoriassa esimerkiksi pitest
mahdollistaa omien mutaatioiden lisäämisen, mutta tämä ei ole helppoa.
Testikattavuudesta
Joissakin projekteissa vaaditaan, että testikattavuus tulee pitää jonkin hatusta vedetyn rajan yläpuolella. Tämä yleensä aiheuttaa vain sen, että testeillä ylläpidetään korkeaa testikattavuutta sen sijaan, että keskityttäisiin testaamaan kunkin projektin kannalta oleelliset asiat.
Sama huomio pätee mutaatiotestaukseen. Vaikka mutaatiotestausframeworkeista saa ulos CI-ystävällisiä lukuja, se ei tarkoita sitä, että näitä tulisi käyttää sellaisenaan. Ennen kuin laitat CI:n kaatumaan jos mutaatiotestausluku on alle 100%, ajattele että onko se oikeasti tarpeellista projektin kannalta, vai olisiko hyödyllisempää käyttää testien hinkkaamiseen käytetty aika esimerkiksi dokumentaation parantamiseen.
False positive -virheet
Silloin tällöin tulee koodia vastaan, josta mutaatiotestaus antaa false positive -ilmoituksia. Yksinkertainen esimerkki on clamp-funktio:
public static int clamp(int x, int max) {
if(max <= x) return max;
return x;
}
Tähän kun kirjoittaa kourallisen testejä:
@Test
public void TestMax()
{
assertTrue(App.clamp(0, 2) == 0);
assertTrue(App.clamp(1, 2) == 1);
assertTrue(App.clamp(2, 2) == 2);
assertTrue(App.clamp(3, 2) == 2);
assertTrue(App.clamp(0, -1) == -1);
assertTrue(App.clamp(1, -1) == -1);
assertTrue(App.clamp(2, -1) == -1);
}
Vaikka testit ovatkin kattavat ja testaavat kaikki tapaukset, pitest
ei ole tyytyväinen:
Tämä johtuu koodin luonteesta: jos x == max
, ei ole väliä kumman polun koodissa ottaa. Tästä johtuen testeillä ei voi huomata <=
-operaattorin muuntamista <
-operaattoriksi. Näissä tilanteissa ei ole muuta vaihtoehtoa kuin merkata funktio mutaatiotestauksen ulkopuoliseksi, tai rajoittaa funktioon käytetyistä mutaatio-operaatioista pois ne operaatiot, joita ei voi testata.
Yhteenveto
Mutaatiotestaus on yksi testaustyökalu muiden joukossa. Jos kielellesi löytyy mutaatiotestauskirjasto, suosittelen sen ajamista. Älä kuitenkaan luule, että vihreät raportit tarkoittavat toimivaa koodia.
Linkkejä:
- Java: PIT mutation testing
- JavaScript, C#, Scala: Stryker Mutator
- Muita resursseja: Awesome Mutation testing
luonnollisesti mainitsematta maata tai ikää ↩︎