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
[... paljon tekstiä ...]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.02 s - in fi.bytecraft.mutations.AppTest
[... paljon tekstiä ...]

------------------------------------------------------

Et ole ihan varma, että testaako kyseiset testit kaikkea koodia. Laitat koodipohjaan simppelin testikattavuuslaskurin, ja katsot miltä näyttää:

------------------------------------------------------


  org.jacoco
  jacoco-maven-plugin
  0.8.7

------------------------------------------------------

$ mvn jacoco:prepare-agent install jacoco:report
[... paljon tekstiä ...]
$ 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:

------------------------------------------------------


  org.pitest
  pitest-maven
  LATEST
  
    
      DEFAULTS
    
  

------------------------------------------------------

$ mvn test-compile org.pitest:pitest-maven:mutationCoverage

------------------------------------------------------

Terminaaliin tulee iso liuta tekstiä, joka näyttää suunnilleen seuraavalta:

------------------------------------------------------

[... paljon tekstiä ...]
> 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 
[... paljon lisää tekstiä ...]

------------------------------------------------------

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 pitestin 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 TestClamp()
{
    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ä:

Haemme uusia kollegoita ja osakkaita! Tutustu palkkaan ja arvoihimme

Uusimmat pohdinnat

Näytä kaikki
Uudet kirjoitukset ja tapahtumat suoraan sähköpostiisi

Thank you! Your submission has been received!

Oops! Something went wrong while submitting the form