Sådan skriver du unit tests

Illustration: Bigstock/REDPIXEL.PL
Gik du glip af unit tests? Ingen problemer. Vi gennemgår teknikken, der fører til stabile programmer uden frustrerende debugging.

»Hvorfor skriver du ikke en artikel om unit tests,« sagde min kollega Jakob en dag.

I sin ungdom programmerede Jakob magasinet Alt Om Data's CMS-system, så nybegynder eller uerfaren er han altså ikke.

»Der er sikkert mange ligesom mig, der godt kunne tænke sig at kende mere til det,« mener han.

Og det lyder jo fornuftigt nok.

Unit tests er programmer, der tester små dele af et programs kode.

Motivet er klart: Manuel test er tidskrævende, benytter som regel alt for få testeksempler, det er dødkedeligt arbejde og fejlbehæftet i den helt store stil. Test er altså en oplagt kandidat til automatisering.

Drop debuggeren

Dertil kommer, at debuggere, hvor man trinvist kan gennemløbe programlinjers udførsel én for én, er et skrækkeligt værktøj til at finde fejl med. Det er som at finde nålen i høstakken ved at kigge på ét strå ad gangen.

Unit test stiller i stedet spørgsmålet: Hvad forventede du af programstumpens resultat?

Det giver både hurtigere og mere sikker fejlfinding, og ofte en bedre forståelse af kodens virkemåde og robusthed i tilgift.

En anden vigtig funktion for unit tests er dets anvendelse som såkaldt regressionstest. Hver gang ny kode tilføjes programmet, er der en hvis mulighed for, at den tidligere og ellers gennemtestede kode fejler.

Med automatiserede tests handler det blot at klikke på knappen i udviklingsværktøjet og så er der vished for, at ens ændringer ikke udløser nye fejl, eller ændrer adfærd i tidligere kode.

Derudover kan man også teste biblioteker, man anvender i sin kode. Dokumentation er godt, men det er endnu bedre at teste, at man får præcist det resultat man forventer, når en given metode kaldes i et bibliotek under et bestemt brugsscenarie, der passer til programmets anvendelse.

I vore dage knyttes unit tests sammen med ‘continous integration’, hvor koden testes i forbindelse med automatiske builds, altså kompilering, konfiguration og udrulning af det færdige program til et test- eller produktionssystem.

Og i 2019 er der næppe nogen softwarevirksomhed eller udviklerafdeling, der kunne klare sig uden, hvis kvaliteten skal være i orden.

Ekstrem fed programmering

Der er ikke noget nyt i unit tests - det har været brugt i ganske mange år. Men det store gennembrud kom, da datalogen Kent Beck, som var bannerfører for Extreme Programming - en tidlig udgave af agile metodikker - flyttede sit testmiljø fra sproget Scheme til Java i starten af det nye århundrede.

Junit, som biblioteket hedder, blev en kæmpe success i samme tempo som de agile metoder slog an som en løbeild. Tilsvarende biblioteker findes til stort set alle andre populære sprog og ofte i mange udgaver og alternativer.

Her kigger vi nærmere på originalen. Junit er integreret i de fleste større Java-værktøjer, så som gode gamle Eclipse.

Jeg kan bedst lide at have mine test i en selvstændig pakke. Hvis klassen, jeg skal teste, ligger i pakken v2ml, ligger de tilhørende tests i v2ml.test. Så er der styr på sagerne.

I Eclipse skabes en Junit-test nemt ved at vælge menuen File > New > JUnit Test Case. En test case er bare en almindelig klasse med metoder, der starter med ‘test’ i navnet.

Illustration: Version2

Vi kalder vores testklasse for TestUtil. Guiden skaber denne klasse:

package v2ml.test;
 
import static org.junit.jupiter.api.Assertions.*;
 
import org.junit.jupiter.api.Test;
 
class TestUtil {
    @Test
    void test() {
        fail("Not yet implemented");
    }
}

Annotationen @Test bevirker, at metoden test() kan udføres automatisk.

I Eclipse kan testen nu udføres ved at vælge menuen Run > Run (Ctrl+F11). Nu åbnes Eclipses Junit-view.

Der kommer en rød bjælke i værktøjets brugerflade, for vores test har fejlet. Det er fordi testen kun indeholder linjen:

fail("Not yet implemented");

Test med tænkehatten

Til Version2's machine learning kunne jeg godt bruge et lille program, der kan tage tabeller med ordhyppigheder og finde et gennemsnit.

Det kan vel ikke være så svært, og jeg gider ikke lige lede efter et bibliotek, der kan gøre det for mig. Jeg skriver en håndfuld linjer:

public class Util {
  public static Map<String, Integer> 
       sum(Map<String, Integer> a, Map<String, Integer> b) {
    var sum = new HashMap<String, Integer>();
    for (var key : a.keySet()) {
      sum.put(key, a.get(key) + b.getOrDefault(key, 0));
    }
    return sum;
  }
}

(En map i Java er ligesom associative arrays i andre sprog, og knytter her et ord til et tal.)

Ideen er, at summen for en ordhyppighed må være summen af værdierne for en given nøgle i begge tabeller, og hvis ordet ikke findes i den sidste tabel, lægges 0 til.

Så er vi parat til at teste.

Konventionen er at kalde testmetoderne noget med ‘test’ plus det, man ønsker at teste. Vi kalder vores for ‘testSum.’

void testSum() {
   var x = Map.of("a", 1, "b", 2, "c", 3);
   var y = Map.of("b", -1, "c", 4, "d", 5);
   var z = Util.sum(x, y);
}

Her skaber vi to tabeller - maps - og lægger dem sammen. Nu skal vi teste, at resultatet er rigtigt. Det gør vi ved at benytte Junits ‘assert’-metoder.

De tager to værdier - den, vi forventer, og det som metoden rent faktisk afleverer, og holder dem op imod hinanden. Man kan også angive en fejlmeddelelse, så man hurtigt kan overskue, hvad der gik galt. Det er praktisk, hvis der er mange tests. Så ser det sådan ud:

void testSum() {
   var x = Map.of("a", 1, "b", 2, "c", 3);
   var y = Map.of("b", -1, "c", 4, "d", 5);
   var z = Util.sum(x, y);
   assertEquals(1, (int) z.get("a"), "Gal sum");
}

I sidste linje forventer vi værdien 1, og vi tester den op imod den faktiske værdi z.get("a") (som lige skal castes fra Integer til int).

Så er det bare at køre i testen igen, med Ctrl+F11. Nu får vi en grøn bjælke, og det betyder at testen er passeret. Men vores test er naturligvis ikke fyldestgørende, så vi skriver nogle flere:

assertEquals(1, (int) z.get("a"), "Gal sum");
assertEquals(1, (int) z.get("b"), "Gal sum");
assertEquals(7, (int) z.get("c"), "Gal sum");
assertEquals(5, (int) z.get("d"), "Gal sum");

Vi kører testen igen, og den er gal: Bjælken bliver rød.

Illustration: Version2

Det er ikke engang vores assert-sætninger der giver fejlen, som er en gemen nullpointer-fejl.

I nederste venstre side kan vi af ‘failure tracet’ se, at fejlen opstår i linje 21. Det er denne her:

assertEquals(5, (int) z.get("d"), "Gal sum");

Det må være z.get(“d”), der giver fejlen, for der er ingen andre objekter i sigte. Med andre ord er “d” ikke med i sum-tabellen.

På med tænkehatten.

Når vi kigger på sum-metoden, gennemløber vi elementerne i første tabel, og lægger dem til elementerne i den anden tabel, hvis de findes. Med hvad med de elementer i anden tabel, som ikke findes i første tabel?

Dem havde jeg lykkeligt glemt alt om.

Løsningen er at tilføje hyppighederne fra de ord i tabel b, som ikke findes i tabel a:

var bKeys = new HashSet<String>(b.keySet());
bKeys.removeAll(a.keySet());
for (var key : bKeys) {
   sum.put(key, b.get(key));
}

Jeg kører min test igen med Ctrl+F11 - og bjælken bliver nydeligt grøn. Selvtilfredshedens sødme føles helt ud i fingerspidserne.

Hvad skal man teste

Men hvad skal man egentlig teste med assert-sætninger?

En klassisk fremgangsmåde er såkaldt ‘grænseværditest.’ Det betyder, at man skal teste sine metoder med henholdsvis typiske, ekstreme og ulovlige værdier, som parametre til et funktionskald.

Typiske værdier er dem, man vil forvente i et funktionskald i forbindelse med et helt almindeligt brugerscenarie.

Ekstreme værdier kan eksempelvis være en øvre eller nedre grænse i et interval, eller en streng, som er usædvanlig lang - altså en parameterværdi, som måske kan give mavebesvær for funktionen.

Ulovlige værdier - som dog ikke har noget med straffeloven at gøre - er parameter-værdier, hvor programstumpen eller funktionen skal udløse en fejl, som for eksempel en RuntimeException.

I vores eksempel kan en ‘ekstrem’ værdi for eksempel være to nulls som argumenter til sum-funktionen. Jeg aner ikke, hvad der sker, hvis jeg gør dette, så jeg tilføjer linjen

var resultat = Util.sum(null, null);

til min testmetode, og kører igen.

Bom, bjælken er rød, nullpointer-fejl, og det skyldes linje 17 i sum-funktionen, siger mit failure trace:

for (var key : a.keySet()) {

Jeg har altså implicit forestillet mig, at metoden ikke kaldes med null-værdier som parametre.

Det må jeg hellere gøre explicit med denne linje, en såkaldt ‘guard’ eller ‘precondition’, lige efter metodens overskrift:

if (a == null || b == null)
   throw
      new IllegalArgumentException("Argumenterne må ikke være null.");

Det er bedre end før, for de sagesløse klienter, der kalder min metode, får nu klar besked om, hvad der gik galt.

Nu har jeg altså besluttet, at null er en ulovlig værdi, og jeg kan teste det i min testmetode:

try {
   var resultat = Util.sum(null, null);
   fail("Ulovlige værdier kaster ikke exception.");
} catch (IllegalArgumentException ex) {
   // Hvis vi når hertil, er testen gået godt.
}

Hvis kaldet til sum-metoden med null som argumenter ikke kaster en exception, som forventet, går programmet videre til fail-kaldet, som siger, at min guard åbenbart ikke fungerer.

Den situation kunne måske opstå, hvis jeg senere tilføjer flere betingelser til min guard og klokker i de logiske operatorer, som godt kan være drilske.

Det er det, der gør unit tests godt som regressionstest - når jeg fylder ny kode på mit program, kan jeg sikre mig, at den tidligere adfærd stadig er gyldig.

Der er meget mere at sige om unit tests, end hvad vi har berørt her. En vigtig mulighed er at samle sine tests som ‘testsuiter’, så man får et hierarki af test, hvor man går højere og højere op i niveau i programmets funktionalitet og ender med at teste de overordnede brugerscenarier.

En sidste ting, vi vil se på, er 'coverage', hvor et værktøj kan se, hvor store dele af koden som testen gennemløber. Det findes i mange udviklingsværktøjer og sprog. I Eclipse benyttes faciliteten med menuen Run > Coverage.

Illustration: Version2

Nu viser værktøjet de linjer, som blev gennemløbet, da unit testen blev udført. Det kan være en smart måde at sikre sig, at man ikke har glemt at teste dele af programmet.

Tips og korrekturforslag til denne historie sendes til tip@version2.dk
Følg forløbet
Kommentarer (2)
sortSortér kommentarer
  • Ældste først
  • Nyeste først
  • Bedste først
Esben Bjerre

I JUnit 4 kan man bruge annoteringen
@Test(expected = TypeAfException.class) før en test for at vise at den forventes at afføde en exception.
Så dit eksempel med IllegalArgumentException kunne være
@Test(expected = IllegalArgumentException.class)
public void shouldThrowIllegalArgumentException() { int sum = Util.sum(null, null); }

  • 1
  • 0
Claus Bobjerg Juul

Glimerende artikel, der giver mig som ikke udvikler en langt bedre forståelse for unit- og regressionstest og eksempler på førstnævnte.

Et lille forslag: hvad med at skrive en artikel (serie evt) om alle de forskellige typer af tests der findes/anvendes professionelt? og deres indbyrdes relation, altså hvordan dækker forskellige behov i forskellige situationer.

  • 3
  • 0
Log ind eller Opret konto for at kommentere
IT Company Rank
maximize minimize