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.
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.
Når vi lager enhetstester er det visse prinsipper som bør følges.
En test følger vanligvis et bestemt mønster kalt Arrange-Act-Assert (AAA):
Arrange: Forbered testdata, variabler og oppsett som testen trenger.
Act: Utfør funksjonen eller handlingen som skal testes.
Assert: Verifiser resultatet.
Vi kan verifisere både tilstand og oppførsel:
Tilstand: Sjekk at testen returnerer forventet resultat. Hvis vi tester en funksjon som sorterer en liste med tall er vi interessert i resultatet, altså den sorterte listen.
Oppførsel: Sjekk at koden utføres på riktig måte. Nå er vi interessert i algoritmen som gjennomfører selve sorteringen. Kaller den funksjonene vi forventer, og gjøres det i riktig sekvens?
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.
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.
En god test bidrar til å dokumentere koden den tester. Den viser hvordan koden virker ved å:
beskrive kontekst, utgangspunkt og betingelser som må oppfylles
illustrere hvordan programvaren brukes
definere forventet resultat og tilstand etter kjøring
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.
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 vil dette være tidkrevende og kostbart, fordi vi må sjekke alle linjer, betingelser og stier i koden vår. Dekningsgraden sier dessuten lite om kvaliteten på testene. 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 tyder på 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. Et høyt antall dårlige tester 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 til forretningskritisk funksjonalitet. Feil her kan gi store negative konsekvenser for resten av systemet.
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å:
Vi kan hardkode verdiene vi trenger i selve testen.
Vi kan lage en testdatabase og et skript som oppretter ønsket tilstand før vi kjører testen.
Vi kan bruke "In-Memory"-databaser som opprettes og slettes for hver økt.
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.
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.
Det første vi må gjøre er å opprette et nytt Maven-prosjekt i IntelliJ IDEA:
Klikk på knappen "New Project" (eller velg File → New → Project...)
Velg "Java" fra venstre-menyen
Name kan settes til "junit-test"
Velg "Maven" som Build system
Pass på at du har en gyldig JDK
"Add sample code" skal ikke være valgt
Klikk på "Advanced settings for å fylle ut Maven-spesifikke egenskaper:
GroupId: edu.ntnu.idatt2003
ArtifactId: junit-test
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:
Mavens standardkonfigurasjon bruker Java 1.8 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:
51<properties>
2 <maven.compiler.source>21</maven.compiler.source>
3 <maven.compiler.target>21</maven.compiler.target>
4 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
5</properties>
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:
91<build>
2 <plugins>
3 <plugin>
4 <groupId>org.apache.maven.plugins</groupId>
5 <artifactId>maven-surefire-plugin</artifactId>
6 <version>3.5.2</version>
7 </plugin>
8 </plugins>
9</build>
Viktig: Hvis vi unnlater å konfigurere maven-surefire-plugin
vil eldre versjoner av 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":
81<dependencies>
2 <dependency>
3 <groupId>org.junit.jupiter</groupId>
4 <artifactId>junit-jupiter</artifactId>
5 <version>5.11.4</version>
6 <scope>test</scope>
7 </dependency>
8</dependencies>
Hvis du får feilmeldinger knyttet til POMen (i IntelliJ vises disse gjerne som rød tekst), så må du tvinge IntelliJ til å laste inn endringene, f.eks. ved å høyreklikke på "pom.xml", velge "Maven" og deretter "Reload project".
Opprett klassen "edu.ntnu.idatt2003.DateUtils" i katalogen src/main/java". Legg deretter til følgende statiske metode:
41public static boolean isLeapYear(int year) {
2 return (year % 4 == 0 && year % 100 != 0) ||
3 (year % 400 == 0);
4}
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.
Først tester vi for år som er skuddår. Med utgangspunkt i beskrivelsen over bør vi dekke følgende tilfeller:
Året er delelig på 4, men ikke på 100.
Året er delelig på 400.
Opprett klassen "edu.ntnu.idatt2003.DateUtilsTest" i katalogen "src/test/java". Opprett så en enhetstest som dekker det første tilfellet:
41
2public void yearIsDivisibleByFourButNotByOneHundred() {
3 assertTrue(DateUtils.isLeapYear(2020));
4}
IntelliJ IDEA vil nå vise to feil, markert med rød tekst. For å fikse dette må du gjøre følgende:
Hold muspekeren over @Test
og velg "Import class".
Hold muspekeren over assertTrue
og velg "Import static method..."
Hvis dette ikke virker kan du legge til uttrykkene manuelt:
21import org.junit.jupiter.api.Test;
2import static org.junit.jupiter.api.Assertions.assertTrue;
Vi har nå laget vår første test med JUnit. Det er verdt å merke seg følgende:
JUnit vet at metoden vi har opprettet er en test fordi vi har annotert den med @Test
.
Verifisering av resultatet skjer gjennom assertTrue(...)
. JUnit APIet tilbyr flere slike metoder som hjelper oss å sjekke at faktisk resultat stemmer overens med forventet resultat.
Metoden har fått et langt, beskrivende navn som dokumenterer hva vi faktisk tester.
Så tester vi for et år som er delelig på 400:
41
2public void yearIsDivisibleByFourHundred() {
3 assertTrue(DateUtils.isLeapYear(2000));
4}
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:
131
2class AYearIsALeapYear {
3
4
5 public void yearIsDivisibleByFourButNotByOneHundred() {
6 assertTrue(DateUtils.isLeapYear(2020));
7 }
8
9
10 public void yearIsDivisibleByFourHundred() {
11 assertTrue(DateUtils.isLeapYear(2000));
12 }
13}
Vi kan kjøre testene på flere måter:
For å kjøre testene med Maven i IntelliJ IDEA må vi legge til en egen konfigurasjon:
Klikk på nedtrekksmenyen øverst til høyre i IntelliJ-vinduet (Current File) og velg "Edit Configurations...".
Klikk på "+" og velg Maven.
I feltet "Command line" skriver du inn "clean test".
Klikk på knappen "Apply", deretter "Ok".
Velg så "junit-test [clean,test]" i menyen øverst til høyre og klikk på "Play"-knappen:
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.
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.
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.
Vi må også teste for år som ikke er skuddår:
Året er ikke delelig på 4.
Året er delelig på 100, men ikke på 400.
Utvid "DateUtilsTest.java" med følgende tester:
x1
2class AYearIsNotALeapYear {
3
4
5 public void yearIsNotDivisibleByFour() {
6 assertFalse(DateUtils.isLeapYear(1981));
7 }
8
9
10 public void yearIsDivisibleByOneHundredButNotByFourHundred() {
11 assertFalse(DateUtils.isLeapYear(2100));
12 }
13}
Merk at disse testene bruker assertFalse
, som må importeres med import static org.junit.jupiter.api.Assertions.assertFalse;
.
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":
xxxxxxxxxx
81public static boolean isLeapYear(int year) {
2 if (year < 0) {
3 throw new IllegalArgumentException("Year must be >= 0");
4 }
5
6 return (year % 4 == 0 && year % 100 != 0) ||
7 (year % 400 == 0);
8}
Her bruker vi unntakshåndtering for å løse problemet. Metoden vår kaster unntak av typen IllegalArgumentException
, men dette skjer bare hvis year
er negativt, se linje 2.
Nå kan vi legge til en test for negative årstall i "DateUtilsTest.java":
xxxxxxxxxx
151
2class AYearIsNotSupported {
3
4
5 public void yearIsNegative() {
6 try {
7 DateUtils.isLeapYear(-1);
8 fail("Method did not throw IllegalArgumentException as expected");
9 } catch (IllegalArgumentException ex) {
10 assertEquals("Year must be larger >= 0", ex.getMessage());
11 }
12 // Or we could do this:
13 // assertThrows(IllegalArgumentException.class, () -> DateUtils.isLeapYear(-1));
14 }
15}
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 omslutter derfor 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:
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:
xxxxxxxxxx
41
2"Year is divisible by 100 but not by 400") (
3public void yearIsDivisibleByOneHundredButNotByFourHundred() {
4 ...
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.
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
.
IntelliJ IDEA tilbyr funksjonalitet for å måle testdekning. Testene vi har skrevet gir full dekning for klassen "DateUtils":
Det finnes også en Maven-plugin som måler testdekning, kalt JaCoCo. Hvis vi legger til følgende konfigurasjon i POMen vil verktøyet analysere koden vår og generere en detaljert rapport i HTML-format:
xxxxxxxxxx
231<build>
2 <plugins>
3 <plugin>
4 <groupId>org.jacoco</groupId>
5 <artifactId>jacoco-maven-plugin</artifactId>
6 <version>0.8.12</version>
7 <executions>
8 <execution>
9 <goals>
10 <goal>prepare-agent</goal>
11 </goals>
12 </execution>
13 <execution>
14 <id>report</id>
15 <phase>prepare-package</phase>
16 <goals>
17 <goal>report</goal>
18 </goals>
19 </execution>
20 </executions>
21 </plugin>
22 </plugins>
23</build>
Rapporten lagres under target/site/jacoco.
Fullstendig kildekode kan lastes ned fra https://gitlab.com/atleolso/junit-test.