Hvorfor 100 prosent kodedekningsgrad ikke betyr 0 prosent feil

Som et mål på hvor godt vi har enhetstestet måles ofte kodedekningsgrad. Men selv om dekningsmåling kan være nyttig er det også begrep som ofte misforstås. Noen ganger virker bruken av det også mot sin hensikt.

Et julebordsesongaktuelt eksempel:

Enhetstester er spesielt godt egnet til å finne og forhindre feil i kode som uttrykker komplekse logiske sammenhenger.

Et årstidsaktuelt eksempel på slik kompleks logikk finner vi i Lov om omsetning av alkoholholdig drikk m.v. Siden dette navnet i enkelte sammenhenger kan være komplisert å uttrykke med presis diksjon omtales den gjerne bare som “Alkoholloven”. 

Formålet med loven er å: “...begrense i størst mulig utstrekning de samfunnsmessige og individuelle skader som alkoholbruk kan innebære”.

I alkoholloven brukes i hovedsak to parametre for å bestemme om en drikk er lovlig å selge, skjenke eller utlevere til en person:

A: Personens alder

B: Drikkens alkoholstyrke, uttrykt i volumprosent

Kort oppsummert kan alkohol under 22% selges til personer 18 år eller eldre, mens drikke over 22% forbeholdes oss over 20. For alkohol over 60% spiller alder ingen rolle siden det uansett er ulovlig. Tilsvarende regnes drikke under 0.7% som alkoholfri og er dermed ikke omfattet av loven.

 “Can You Drink It?”

Nå er det jo både tungvint, upraktisk og lite stilig å dra på julebord med Norges Lover under armen, bare for å holde styr på hvem som kan drikke hva. Det hadde vært mer elegant med en app, sant?

La oss introdusere “Can You Drink It?”:

can-u-drink-it.png

Appen “Can You Drink It?” lar brukeren taste inn alder og alkoholprosent og gir et klart og tydelig svar tilbake på eventuell drikking er innenfor norsk lov.

Forfriskning.java

La oss se på litt kode. Vi kan tenke oss at appen bruker følgende Java-kode til å avgjøre om en person kan konsumere en gitt alkoholenhet på lovlig vis:

public class Forfriskning {

    public boolean kanDrikke(int alder, double volumProsent) {

        if (volumProsent < 0.0007)
            return true;

        if (volumProsent > 0.6)
            return false;

        if (alder < 18)
            return false;

        if (alder >= 20)
            return true;

        if (volumProsent >= .22)
            return true;

        return false;
    }
}

Vi trenger ikke kunne mye Java for å se at denne koden sjekker alder og volumprosent opp mot grenseverdiene i loven, og returnerer true hvis drikken kan skjenkes til en person med angitt alder.

Siden alkoholbruk som kjent kan medføre både samfunnsmessige og individuelle skader er det svært viktig at Forfriskning.java ikke inneholder feil.

Som faglig engasjerte programmerere er vi opptatt av å levere kvalitet, og vi legger derfor til en par enhetstester:

@Before
public void setup() {
    f = new Forfriskning();
}

@Test
public void barnSkalIkkeDrikkeAlhohol() {
    assertThat(f.kanDrikke(10, 0.07), is(false));
    assertThat(f.kanDrikke(10, 0.4), is(false));
}

@Test
public void fossilerDrikkerPåEgetAnsvar() {
    assertThat(f.kanDrikke(50, 0.4), is(true));
}

Stolte og fornøyde er vi klare til å levere appen vår, men i siste liten snubler vi over kontraktens bilag 3, nærmere bestemt “Kundens kravspesifikasjon”, tabell 17, “Ikke-funksjonelle krav”, kravnummer 62:

“All kode skal ha 100% kodedekning på enhetstester. Leverandøren skal dokumentere dette gjennom rapport fra dekningsverktøy”.

Lettere stresset skur vi på måling av kodedekning. Forskrekkelsen er stor når vi finner ut at testene våre gir usle 58% dekningsgrad:

Her må det åpenbart noen flere tester til for å oppnå kravet. La oss legge til disse:

@Test
public void alleBurdeKunneDrikkeKefir() {
    f.kanDrikke(5, 0.001);
}

@Test
public void forMyeMøllersTran() {
    f.kanDrikke(65, 0.65);
}

@Test
public void tenåringerSkalIkkeDrikkeBrennevin() {
    f.kanDrikke(19, 0.40);
}

@Test
public void tenåringerKanDrikkeVin() {
    f.kanDrikke(18, 0.13);
}

Vi kjører testene, og får nå 100% dekningsgrad! Kravet er oppfylt, alle er glade, vi kan levere og ta helg.

Mandag morgen våkner vi imidlertid til følgende avisoverskrift:

Sander (8) ble nektet å kjøpe kefir

 Rimi-ansatt mente salg var i strid med alkoholloven.

Hva har skjedd? Det viser seg at Sander var sendt ut av mor til butikken for å kjøpe Kefir. På grunn av Sanders unge alder og faktumet at Kefir inneholder små mengder alkohol hadde den butikkansatte sjekket lovligheten i appen “Can You Drink It?”.

Sander måtte derfor slukøret gå hjem uten Kefir, mens kartongen han prøvde å kjøpe ble beslaglagt til fordel for statskassen. (jamfør Alkoholloven § 10-4)

Hvordan kunne dette skje? Vi som hadde 100% dekningsgrad? Hvis vi kikker litt nærmere på en av de siste testene vi la til, så er det kanskje ikke så rart:

@Test
public void alleBurdeKunneDrikkeKefir() {
    f.kanDrikke(5, 0.001);
}

Hva er problemet med denne testen? Den kjører som vi ser koden med riktig input for å avdekke caset vi ønsker å teste (nemlig at barn bør kunne kjøpe Kefir, selv om det inneholder små mengder alkohol.)

Feilen her er at testen ikke uttrykker noen som helst forventninger til koden som kjøres. Slike tester er faktisk ikke helt uvanlig å finne i kodebaser som er laget med et urealistisk høyt krav til kodedekning. Utviklerene vil elte og kna på koden inntil alle linjer kjøres og rapporten blir grønn.

Når dette målet er nådd har man gjerne brukt så mye tid at man ikke har tid til å gjøre det enhetstester faktisk skal gjøre, nemlig å avdekke feil og forhindre at de kan oppstå igjen.

I eksempelet vårt ville vi ganske enkelt fange opp denne feilen ved å legge til en assert, slik:

@Test
public void alleBurdeKunneDrikkeKefir() {
    assertThat(f.kanDrikke(5, 0.001), is(true));
}

Når vi nå kjører testen vil den feile:

java.lang.AssertionError: 
Expected: is 
but: was 
at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
at org.junit.Assert.assertThat(Assert.java:865)
at org.junit.Assert.assertThat(Assert.java:832)
at ForfriskningTest0.alleBurdeKunneDrikkeKefir(ForfriskningTest0.java:33)

(Den interesserte leser oppfordres til å finne feilen)

Hva kan vi lære av Sander (8) ?

Du vil kanskje innvende at eksempelet over virker noe konstruert - og det er det jo. Men jeg har sett lignende ting skje i praksis.

I jakten på høy kodedekning kaster vi bort mye tid. Samtidig taper vi kvalitet. Hva kan vi så lære av dette? La oss starte med å slå hull på en utbredt myte og si følgende med klar og tydelig stemme:

100% kodedekning er IKKE det samme som 0% feil.

Som vi har sett kan det like gjerne bety 20% feil. Eller 100% feil. Som selvstendig metrikk er derfor “kodedekning” i seg selv 100% meningsløs og dermed også verdiløs. Den forteller oss lite eller ingenting om kvaliteten på det vi leverer.

Jakten på høy dekningsgrad medfører imidlertid flere problemer enn bare tiden vi kaster bort:

Det oppfordrer til testkode med liten eller ingen verdi
Typiske eksempler er testing av gettere / settere. Disse er som oftest generert av IDE-verktøy, så testing av dem gir ingen verdi, men tar tid og tilfører støy i rapportene.

Det oppfordrer til enhetstesting etter at koden er skrevet
Når kvalitetskriteriet er en viss oppnådd dekningsprosent er det ikke noe poeng å skrive tester underveis, siden koden vil endrer seg og du dermed vil tape dekningsprosent. Da er det enklere å ta et skippertak til slutt og sørge for at all kode blir kjørt. Som konsekvens av dette mister man en av de viktigste fordelene med TDD (testdrevet utvikling), nemlig at testene brukes som input til designvalg underveis i utviklingen.

Det gir tester med komplekst oppsett
Enkelte APIer gir kode som kan være krevende å nå fra en enhetstest. Det kan føre til masse oppsettskode for mocking. Typisk ser man 20-30 linjer kode med oppsett for å nå 1-2 linjer kode. All denne oppsettskoden gjør testene vanskeligere å forstå, og dyrere å vedlikeholde.

Det tvinger oss til å tester tilfeller vi vet aldri vil oppstå
La oss si du kaller URLEncoder.encode() med encoding “UTF-8”. Denne metoden kan kaste UnsupportedEncodingException, en checked exception. Selv om vi vet at UTF-8 alltid vil være støttet må vi likevel fange UnsupportedEncodingException og dette medfører kode vi må teste hvis vi vil oppnå høy dekningsgrad.

Det oppfordrer til å skrive enhetstester hvor en integrasjonstest hadde vært bedre
Et typisk eksempel på dette er container-spesifikk oppstartskode uten spesielt mye logikk. Slik kode kan kreve at vi fyrer opp en full container for å kjøre den, og da er ikke enhetstester spesielt egnet. Men hvis rapporten fra enhetstestkjøring skal vise 100% har vi ikke annet valg enn å kjøre koden fra enhetstester.

Så hva skal vi gjøre?

Siden du har fulgt med så lang har du fått med deg at jeg har brukt ganske mange ord på å underbygge en påstand som i grunnen er ganske innlysende - nemlig at krav om 100% dekning ikke bare er meningsløst, men også kontraproduktivt. Men hva er så alternativet?

Her er noen råd:

Skal vi droppe krav om dekningsgrad?
Ja og nei. I et miljø hvor utviklere selv bruker enhetstesting som et verktøy for å drive design og programmering bør krav om en viss dekningsgrad være overflødig. I sammenhenger hvor utviklerne har et mindre modent forhold til testing og TDD kan et krav om dekning være fornuftig for å sikre at testing skjer. Men kravet må være realistisk og det bør følges opp med krav som sikrer at testene er vedlikeholdbare og at de faktisk avdekker feil.

Hva bør kravet så være?
Det kommer an på. Husk at testbudsjettet ditt ikke er utømmelig. Som alltid ellers gjelder det å bruke tiden og pengene dine der hvor innsatsen gir størst utbytte. Enkel oppsettskode eller verdi-objekter fortjener ikke samme grad av testing som en pensjonsberegning. Derfor kan det være lurt å tilpasse dekningskrav/mål til ulike deler av kodebasen.

Legg testinnsatsen der du tror sannsynlighet og konsekvens av feil er størst. Dette gjelder typisk logisk kompleks kode med mye sammenligninger, grenseverdier og logikk. Husk at det alltid vil finnes et punkt hvor kostnaden med flere tester vil overstige verdien av dem. Det er vanskelig å si hvor dette skjæringspunktet er, men det er helt sikkert nærmere 80 enn 100 prosent.

Tenk helhetlig
Kodedekning bør ses i sammenheng med andre kvalitetsaspekter. Hvor godt avdekker testene faktiske feil? Dette kan delvis avdekkes av mutasjonstester hvor et verktøy endrer programkoden din og sjekker i hvilken grad testkoden din fanger opp feilen. Verktøyene for dette på langt nær er så modne som kodedekningsverktøyene, men det kan være verdt å sjekke ut.

Husk også at kode med høy kodekompleksitet (syklomatisk kompleksitet; antall logiske veier gjennom koden) oftere inneholder feil. Det finnes gode verktøy for å rapportere dette. Produktet av kompleksitet og dekningsgrad kan gi en god indikator for hvor flere enhetstester kan gi størst utbytte. Det er også kjent at programvarefeil har en tendens til å opptre i “clustre”. Hvis du finner en feil i en metode eller klasse indikerer det altså forhøyet sannsynlighet for feil i samme enhet/modul.

Ikke bli verktøy-blind
Kodedekningsverktøy er avansert teknologi, og det er lett å la seg imponere over grafer og rapporter. Enkle prosent-tall er også lett å forholde seg til, måle, huske og diskutere. Derfor er det også lett å se seg blind på verktøyene og gi dem mer av styringen enn de strengt tatt fortjener. Husk å se metrikker fra ulike verktøy i sammenheng. En kvalitetsindikator blir ikke viktigere fordi den er lett å måle. Likevel ser vi ofte at det enkelt målbare ofte gis ufortjent høy prioritet.  

Live kodedekning

I min jobb som systemutvikler bruker jeg kodedekningsanalyse aktivt. Ikke for å nå et bestemt prosent-krav, men for å finne aspekter ved koden jeg ikke har tenkt på, eller som kan inneholde feil, samt som en pekepinn på når jeg har skrevet nok tester.

Jeg opplever dette som så nyttig at jeg lenge har savnet å kunne bruke teknikken også på produksjonskode. Jeg har derfor utviklet verktøyet Revoc. I motsetning til klassiske kodedekningsverktøy er dette laget for å kunne kjøre “live” på systemer. Dermed kan det identifisere hvilken kode som faktisk blir brukt, hvilken kode som aldri blir brukt, hvilken kode som er nylig kjørt. Du kan trykke på en knapp i brukergrensesnittet ditt, og kildekodelinjene som ble kjørt lyser opp i Revoc.

I Kantega har vi testet ut Revoc i et noen prosjekter med gode erfaringer. Det lar oss utforske kodebaser og hjelper oss å fjerne kode som ikke lenger er i bruk. Og av alle indikatorer vi kjenner for programvarefeil er den beste kanskje antall kodelinjer. En ting er sikkert: Hvor det finnes kode vil det også finnes feil.

Litt om forfatteren

Eirik-Bjørsnøs

Eirik Bjørsnøs

Som Chief Scientist i Kantega er Eirik stadig på utkikk etter smartere teknologi og smidigere metoder som kan hjelpe oss å levere verdifull programvare mer effektivt. Eirik eksperimenterer med alt fra bytekodeoptimalisering til firmakultur og presenterer mer enn gjerne det han finner i foredrag på konferanser fjern og nær.
comments powered by Disqus