Enhetstesting

Vi tester programvare for å finne feil og demonstrere kvalitet. Aktiviteten er helt nødvendig for å kunne svare på om systemet vi utvikler oppfører seg som forventet og tilfredstiller de kravene som er satt. Vi må evaluere både om vi har utviklet programvaren rett og om vi har utviklet rett programvare. Testing er derfor en kjerneaktivitet i all systemutvikling.

Vi kan tilnærme oss testingen på ulike måter, men her skal vi fokusere på enhetstesting. En enhetstest tester en konkret funksjon, klasse eller komponent i systemet, og lages av utviklere mens de jobber med koden.

Enhetstesting er en form for white-box-testing. Vi forholder oss til systemet som en gjennomsiktig boks hvor vi har inngående kjennskap til kildekoden. Kunnskap om systemets algoritmer og datastrukturer gjør det lettere å vite hvilken input vi skal bruke for å kontrollere de ulike stiene gjennom programkoden.

White-box-testing

Figur 1: White-box-testing betyr inngående kjennskap til kildekode og struktur

Enhetstene er automatiserte – de defineres i kode eller skript og utføres av en maskin. Slik automatisering er en forutsetning for kontinuerlig integrasjon (CI) og kontinuerlig utrulling/leveranse (CD), og testene kjøres gjerne når vi bygger eller sjekker inn kode i et versjonskontrollsystem.

Prinsipper

Når vi lager enhetstester er det visse prinsipper som bør følges.

Arrange-Act-Assert

En test følger vanligvis et bestemt mønster kalt Arrange-Act-Assert (AAA):

  1. Arrange: Forbered testdata, variabler og oppsett som testen trenger.

  2. Act: Kjør testen.

  3. Assert: Verifiser resultatet.

Vi kan verifisere både tilstand og oppførsel:

Positiv og negativ testing

Positiv testing går utifra at systemet fungerer som forventet. Vi baserer oss på korrekt og valid input og tester for vellykkede tilfeller. Men vi bør også verifisere at systemet håndterer feiltilfeller i form av uønsket input og uforutsigbar oppførsel. Dette kalles negativ testing, og er viktig for å sikre et robust og stabilt system. Vi bør derfor utarbeide både positive og negative tester.

Nøyaktighet og presisjon

En test bør være både nøyaktig og presis. La oss si at vi har en liste med tall (eksempelet er hentet fra boka 97 Things Every Programmer Should Know):

3 1 4 1 5 9

Vi ønsker å sortere listen i stigende rekkefølge og implementerer en funksjon for dette. Når vi skal teste funksjonen er det naturlig å sjekke at at det som returneres er en liste med tall i stigende rekkefølge. Vi kan også sjekke at resultatet inneholder nøyaktig like mange elementer som lista vi startet med. Men er dette nok? Tenk deg at algoritmen vår har en feil som fyller hele resultatlisten med det første tallet fra den opprinnelige listen. Da ender vi opp med følgende resultat:

3 3 3 3 3 3

Listen tilfredstiller kravene i testen vår, men det er åpenbart ikke dette vi er ute etter. I vårt tilfelle er det kun ett resultat som er riktig:

1 1 3 4 5 9

Testen må være både nøyaktig og presis: vi må sjekke at listen er sortert og at den holder en permutasjon av de opprinnelige verdiene.

Dokumentasjon

En god test bidrar til å dokumentere koden den tester. Den viser hvordan koden virker ved å:

Skriv testene slik at de som prøver å forstå koden får en lettere jobb. Når vi lager enhetstester bør vi blant annet bruke meningsfulle og beskrivende navn, og vi bør samle tester som logisk hører sammen.

80/20-regelen

Testdekning er et mål på hvor mye av kildekoden som testes. Det kan være fristende å ha 100% dekning som mål. I praksis er dette svært vanskelig, fordi det betyr at vi må sjekke alle mulige kombinasjoner av input, betingelser og stier i koden vår. Vi må også ta hensyn til at testutvikling er en tidkrevende og kostbar aktivitet. 80/20-regelen (Pareto-prinsippet) forteller oss at 80% av alle feil kan knyttes til 20% av kodebasen. Feil har en tendens til å samles rundt et lite subsett av moduler og funksjoner i koden vår. Det betyr at vi ikke trenger å teste absolutt alt for å sikre tilstrekkelig kvalitet. Dessverre må vi fortsatt identifisere den delen av koden som faktisk produserer feilene.

80/20-regelen gjør ikke nødvendigvis testutviklingen lettere, men den sier noe om hvor vi bør rette blikket. Det handler ikke om hvor mange tester vi har, men om kvaliteten på testene. Fem tester som i praksis verifiserer det samme gir liten verdi, selv om det øker testdekningen. Siden målet er å finne riktig nivå på testingen mer enn å oppnå full testdekning, må vi være ekstra kritiske til hva vi faktisk velger å teste. Det kan være lurt å ta utgangspunkt i kode som kalles ofte fra andre moduler, eller som er koblet direkte mot forretningskritisk funksjonalitet. En feil her kan gi store negative konsekvenser for resten av systemet.

Forutsigbarhet

Testene våre er avhengige av et forutsigbart testmiljø. Vi må kunne garantere repeterbarhet og at testene produserer det samme resultatet hver gang de kjører (presisjon). Det forutsetter at vi har et testdatasett som ser likt ut før hver gjennomkjøring. Det er flere måter å oppnå dette på:

Det kan være fristende å kopiere reelle data fra et produksjonssystem inn i en testdatabase. Det gir oss gode testdata, men er problematisk hvis databasen inneholder personopplysninger eller annen sensitiv informasjon. I slike tilfeller er det tryggest å basere seg på fiktive data, selv om opplysningene kan maskeres.

Enhetstesting med JUnit

Vi skal nå se nærmere på grunnleggende enhetstesting med JUnit, som er et test-rammeverk for Java. Vi bruker IntelliJ IDEA som utviklingsmiljø, men prosessen vil være tilsvarende også om du velger å bruke et annet IDE.

Opprett et nytt prosjekt

Det første vi må gjøre er å opprette et nytt Maven-prosjekt i IntelliJ IDEA:

  1. Klikk på knappen "New Project" (eller velg File → New → Project...)

  2. Velg "New Project" fra venstre-menyen

  3. Name kan settes til "junit-test"

  4. Velg "Maven" som Build system

  5. Pass på at du har en gyldig JDK

  6. "Add sample code" skal ikke være valgt

  7. Klikk på "Advanced settings for å fylle ut Maven-spesifikke egenskaper:

    • GroupId: edu.ntnu.idatt2003

    • ArtifactId: junit-test

  8. Klikk på "Create".

Vi har nå opprettet et tomt Maven-prosjekt med en generisk katalogstruktur og pom.xml. Det er verdt å merke seg at vi har en egen katalog for enhetstester:

Et nytt Maven-prosjekt i IntelliJ IDEA

Figur 2: Et nytt Maven-prosjekt i IntelliJ IDEA

Konfigurer Maven

Mavens standardkonfigurasjon bruker Java 1.5 for kompilering. Siden vi bruker en nyere Java-versjon må vi eksplisitt overstyre dette i POM-fila ved å sette maven.compiler.source og maven-compiler-target. IntelliJ IDEA legger til dette automatisk, slik at Java-versjonen i POM-fila reflekterer JDKen du valgte når du opprettet prosjektet. Pass også på at UTF-8 er satt som tegnsett:

For å kjøre testene bruker Maven en plugin kalt "maven-surefire-plugin". Dette er en standard plugin, men vi trenger en nyere versjon som er kompatibel med JUnit 5.x, og må derfor definere denne eksplisitt i POMen:

Viktig: Hvis vi unnlater å konfigurere maven-surefire-plugin vil Maven kjøre test-fasen uten å plukke opp testene. Maven vil ikke feile, men rapporten etter kjøring vil vise "Test run: 0" selv om vi har enhetstester i prosjektet vårt.

Til slutt må vi legge til JUnit som avhengighet i POM-fila. Legg til følgende i "pom.xml":

Legg til en metode som kan testes

Opprett klassen "edu.ntnu.idatt2003.DateUtils" i katalogen src/main/java". Legg deretter til følgende statiske metode:

Metoden isLeapYear returnerer true dersom året er et skuddår og false hvis det ikke er det. Store Norske Leksikon forteller oss at skuddår er "år som er delelige på 4 og som ikke er delelige med 100, med unntak for år som er delelige på 400". For å være sikre på at metoden oppfyller betingelsene og fungerer som forventet lager vi noen enhetstester.

Test for år som er skuddår

Først tester vi for år som er skuddår. Med utgangspunkt i beskrivelsen over bør vi dekke følgende tilfeller:

Opprett klassen "edu.ntnu.idatt2003.DateUtilsTest" i katalogen "src/test/java". Opprett så en enhetstest som dekker det første tilfellet:

IntelliJ IDEA vil nå vise flere feil, markert med rød tekst. For å fikse dette må du gjøre følgende:

  1. Høyreklikk på "pom.xml", velg "Maven" og deretter "Reload project". Dette vil tvinge frem en nedlasting av avhengighetene i prosjektet (det er mulig IntelliJ IDEA har gjort dette automatisk).

  2. Hold muspekeren over @Test og velg "Import class".

  3. Hold muspekeren over assertTrue og velg "Import static method..."

Hvis autoimport av en eller annen grunn ikke virker kan du legge til uttrykkene manuelt:

Vi har nå laget vår første test med JUnit. Det er verdt å merke seg følgende:

Så tester vi for et år som er delelig på 400:

Vi har nå to positive tester som sjekker om et år er skuddår. Det er naturlig å gruppere disse sammen, noe vi får til med @Nested. Importer annotasjonen med import org.junit.jupiter.api.Nested; og omslutt de to testene på følgende vis:

Kjør testene

Vi kan kjøre testene på flere måter:

Kjør testene med Maven i IntelliJ IDEA

For å kjøre testene med Maven i IntelliJ IDEA må vi legge til en egen konfigurasjon:

  1. Klikk på nedtrekksmenyen øverst til høyre i IntelliJ-vinduet (Current File) og velg "Edit Configurations...".

  2. Klikk på "+" og velg Maven.

  3. I feltet "Command line" skriver du inn "clean test".

  4. Klikk på knappen "Apply", deretter "Ok".

Velg så "junit-test [clean,test]" i menyen øverst til høyre og klikk på "Play"-knappen: clean test med Maven

Kjør testene med Maven fra terminalen

For å kjøre testene fra terminalen må du gå til prosjekt-katalogen "junit-test". Deretter kjører du kommandoen mvn clean test. Dette forutsetter naturligvis at du har installert kommandolinjeverktøyet "mvn" på forhånd.

Kjør testene i IntelliJ IDEA uavhengig av Maven

Det er også mulig å kjøre testene i IntelliJ IDEA uavhengig av Maven. Da klikker du på den grønne pila til venstre for test-klassen eller test-metoden du vil kjøre.

Kjøring av tester i IntelliJ IDEA

Figur 3: Kjøring av tester i IntelliJ IDEA

Merk: For at dette skal fungere må du passe på at "Project bytecode version" og "Target bytecode version" er er kompatibel med JDK-en du har valgt for prosjektet. Hvis du får feilmelding kan du følge oppskriften her.

Test for år som ikke er skuddår

Vi må også teste for år som ikke er skuddår:

Utvid "DateUtilsTest.java" med følgende tester:

Merk at disse testene bruker assertFalse, som må importeres med import static org.junit.jupiter.api.Assertions.assertFalse;.

Negativ testing

Testene vi har skrevet så langt er positive – vi baserer oss på valid input og går utifra at systemet fungerer som forventet. Men vi bør også lage negative tester. Som et minimum bør vi sjekke for ugyldig input. En type ugyldig input er verdier som ikke er heltall, f.eks. en tekststreng eller null. I vårt tilfelle vil dette fanges opp under kompilering, så her er vi heldige. Men hva om input er et negativt tall? Spørsmålet blir nå om negative årstall i det hele tatt skal støttes. Dette tenkte vi ikke på da vi skrev metoden, som igjen illustrerer et viktig poeng. Når vi skriver tester blir vi tvunget til å ta en ekstra titt på det vi ønsker å teste, som ofte fører til mer robust kode.

Hvis vi bestemmer oss for at negative årstall ikke skal støttes må vi endre metoden vår i klassen "DateUtils":

Her bruker vi unntakshåndtering for å løse problemet. På linje 1 ser vi at metoden vår kaster unntak av typen IllegalArgumentException. Men dette skjer bare hvis year er negativt, se linje 7.

Nå kan vi legge til en test for negative årstall i "DateUtilsTest.java":

Vi kaller isLeapYear med et ugyldig år som input (linje 7). Metoden vil da kaste en IllegalArgumentException. Dette er et unntak av typen "unchecked", som betyr at vi kan velge om vi vil håndtere det eksplisitt i koden eller håpe på det beste når koden kjøres. I denne testen ønsker vi å bekrefte nettopp at unntaket kastes, og vi må derfor omslutte metode-kallet i en try/catch-blokk (linje 6-11). Hvis unntaket av en eller annen grunn ikke kastes vil testen feile med meldingen som er oppgitt på linje 8.

Merk også at vi bruker assertEquals her (linje 10), som må importeres med import static org.junit.jupiter.api.Assertions.assertEquals;.

Vi har nå fem tester som gir følgende resultat:

Test-resultat i IntelliJ IDEA

Figur 4: Test-resultat i IntelliJ IDEA

DisplayName

Det er viktig at testene har beskrivende navn. Hvis vi ønsker å ha mer kontroll over hvordan navnet formatteres og vises kan vi bruke @DisplayName. F.eks. så blir testen yearIsDivisibleByOneHundredButNotByFourHundred mye enklere å lese med DisplayName:

DisplayName støtter UTF-8, som betyr at vi kan bruke emojis (Hurra!). Og når vi kjører testen er det teksten fra DisplayName som vil vises i testrapporten.

Before og after

Vi har nå sett hvordan JUnit kan hjelpe oss med å kjøre en test (Act) og verifisere resultatet (Assert), samt gruppere tester som logisk hører sammen. Noen ganger må vi også gjøre forberedelser før vi kjører testene (Arrange). Det kan være vi må nullstille testdata eller initialisere viktige variabler. Kanskje må vi også rydde opp etter at testene har kjørt. JUnit har egne annotasjoner som hjelper oss å håndtere livssyklus. Før testene kjører kan vi bruke @BeforeAll, alternativt @BeforeEach hvis vi ønsker å gjenta en metode før hver test. På samme måte kan vi rydde opp etter oss med annotasjonene @AfterAll eller @AfterEach.

Testdekning

IntelliJ IDEA tilbyr funksjonalitet for å måle testdekning. Testene vi har skrevet gir full testdekning for klassen "DateUtils":

Testdekning i IntelliJ

Figur 5: Testdekning i IntelliJ IDEA

Kildekode

Fullstendig kildekode kan lastes ned fra https://gitlab.com/atleolso/testing-junit.