{ "year": "1999", "title": "JUnit", "exercisesLink": "opgaver/opgaver.htm" }

Dette kapitel beskriver hvordan JUnit 5 anvendes i forbindelse med Eclipse. JUnit kan også anvendes sammen med andre udviklingsmiljøer, men dette er ikke beskrevet.

 

Framework JUnit er et framework, der understøtter test af komponenter ("units"). Det er udviklet af Erich Gamma and Kent Beck. Erich Gamma er en af de fire forfattere til den skelsættende bog. "Design Patterns", der for alvor satte gang i design pattern bølgen i midten af 90'erne. Kent Beck er kendt som manden bag extreme programming, hvor netop regressiv test af komponenter spiller en central rolle.
Regressiv test Idéen med regressiv (dk.: gentagen) test er, at man til hver en tid kan køre samtlige tests igen, og dermed sikre sig at man ikke indfører fejl i noget man allerede har fået til at virke. Regressiv test giver mange fordele, men man skal hele tiden huske, at fordelene ikke er bedre end de tests man laver. Som bekendt kan ingen test garantere fuldstændig mod fejl, og derfor er fordelene ikke absolutte. Regressiv test giver en betydelig større tryghed, når man laver refaktorisering, da man efter en refaktorisering nemt kan gentage ens tests, og på den måde skabe en større sikkerhed for at man ved refaktorisering ikke indfører nye fejl. Refaktoriserings største udfordring er nemlig: "If it works, don't fix it". Denne sætning er sund i sig selv, men når vi laver refaktorisering er det fordi vi ikke vil nøjes med at det virker, vi vil også have et godt design, der er til at arbejde videre med.
Forsømt område Et problem med tests, er at de har dårlige vilkår. Det er ofte noget udviklerne laver kort før systemet skal leveres. JUnit har været med til at gøre opbygningen af en samling af test cases lettere og dermed mere spiselig. På den måde har extreme programming opnået positive resultater, fordi man har formået at give tests en styrket rolle i udviklings-processen.
Der er dem der mener, at det ligefrem bliver sjovt at lave tests når man bruger JUnit, men det er nok lidt en overdrivelse — der er trods alt det, der er sjovere! Til gengæld kan man sige at smerten ikke længere er så stor, og det er bestemt et gode, for test har altid haft ry for at være kedeligt — og det ikke uden grund!
1. Installation
Eclipse installerer tilsyneladende selv JUnit 5, hvorfor man ikke selv skal foretage sig noget i den anledning.
Når man ønsker at lave en klasse, med en eller flere test-cases, som det gøres i det følgende, skal man blot vælge: New > JUnit Test Case, i stedet for: New > Class. Gør man det, vil man få en klasse, der opsat til at indholde test-cases. Man vil i den forbindelse få tilbudt af Eclipse, at den inkluderer JUnit 5 på build path ("JUnit 5 is not on the build path. Do you want to add it?"), hvilket man tager imod (trykker OK).
2. Et simpelt eksempel
Lad os starte med et simpelt eksempel, hvor vi tester en klasse, der er så enkel, at vi i stedet kan koncentere os om hvordan vi anvender JUnit.
Kildetekst 1:
Simpel anvendelse af JUnit
public class Heltal {
  private int tal;
  
  public Heltal() {
    this(0);
  }
  
  public Heltal(int tal) {
    set(tal);
  }
  
  public void set(int tal) {
    this.tal = tal;
  }
  
  public int get() {
    return tal;
  }
}
      
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

public class HeltalTest {

  @Test
  public void testKonstruktor() {
    Heltal t = new Heltal(8);
    
    assertEquals(8, t.get());
  }
  
  @Test
  public void testSet() {
    Heltal t = new Heltal();
    t.set(5);
    
    assertEquals(5, t.get());
  }
}
      
Først har vi klassen: Heltal som vi vil gøre til genstand for test. Det er en triviel integer wrapper.
Dernæst har vi selve test-klassen: HeltalTest, denne klasse har til formål at teste Heltal-klassen. Der er et par ting at bemærke i forbindelse med denne klasse.
@Test Klassen har ingen konstruktor, men til gengæld to metoder. Disse to metoder udgør to test cases. Dette markeres overfor JUnit med annoteringen: @Test. Denne annotering er, sammen med flere vi skal se i det følgende, erklæret i package: org.junit.jupiter.api, som vi af samme grund importerer. At man anfører denne annotering, gør det muligt også at have service-metoder i klassen, der ikke selv er test cases, men kan anvendes at forskellige test cases, der måtte være i klassen. Disse service-metoder vil ikke have nævnte annotering.
Assert-
metoderne
En anden ting man bemærker, er kaldene af assertEquals-metoden. Vi skal senere se en lang række af disse assert-metoder, men man kunne indledningsvis undre sig over, hvor denne metode er erklæret, da HeltalTest-klassen tydeligvis ikke arver denne metode (den nedarver jo fra Object). Assert-metoderne stammer fra klassen: Assertions, der også er erklæret i package: org.junit.jupiter.api. Ved at anføre en statisk import af denne klasse, bliver alle (public) statiske metoder i denne klasse tilgængelige, og dermed også assertEquals-metoden, uden at vi ved anvendelsen skal referere til Assertions-klassen.
Alternativt til denne statiske import, kunne man have kaldt metoderne med, f.eks.:
import org.junit.jupiter.api.Assertions;

...

Assertions.assertEquals(8, t.get());
        
assertEquals-kaldene er vore to test-metoders, eller test cases', måde at kontrollere om henholdsvis konstruktoren og set-metoden i Heltal-klassen fungerer korrekt. Metoden sammenligner det forventede resultat (8 og 5) med det observerede, der i begge tilfælde hentes med get-metoden. Denne sammenligning er i sig selv ikke noget særligt, den er faktisk ganske banal, men hvis sammenligningen ikke falder gunstigt ud opfanger JUnit dette, og vi får besked om at en eller flere at vores test cases har fejlet. (Rent teknisk sker dette ved at assert-metoderne kaster en exception.)

De enkelte test cases er uafhængige af hinanden, og rækkefølgen de udføres i er udefineret. Når man laver en test-metode, til en test case, skal man derfor starte forfra som vi ser det gjort i begge metoder, ved at de laver en instans af Heltal, som eftefølgende anvendes.

Vi har nu gennemgået de specielle ting omkring HeltalTest-klassen, men hvad gør man for at få udført testen med de to test cases?

Ctrl-F11 I Eclipse kan det gøres på flere måde, men den simpleste er at aktivere det editor vindue i Eclipse, der indeholder test klassen, og køre den med Ctrl-F11. Man kan også vælge at højreklippe på klassen i package explorer, og vælge Run as > JUnit Test. Ønsker man kun at køre en enkelt test case, kan dette gøres ved at place cursoren i navnet på test-metoden, f.eks.: testKonstruktor, og trykke Ctrl-F11.
Kører man testen i HeltalTest-klassen (begge test cases), får man et specielt JUnit panel, der har følgende udseende:
Figur 1:
JUnit i Eclipse
JUnit i Eclipse
Her er panelet samlet med package panelet med tabs, men det kan man selv vælge ved at drag'e det rundt i Eclipse.
Expand All En anden ting er, at der ovenfor er højreklikket på: HeltalTest... (det markerede) og valgt: Expand All. (Man kan også klikke på den linie trekant, der er ved starten af linien). Hvis der ikke er nogen fejl i test-kørslen (hvilket der ikke er med vores to test cases) vil den pågældende knude i træet være kolapset, og vi kan ikke se de enkelte test cases' resultat.
Hvis der havde været en fejl i forbindelse med den test case, der testede set-konstruktoren (f.eks. ved at den altid ville sætte datakernen til 14), ville det i stedet have set ud som følger:
Figur 2:
Use case der fejler
Failed Test Case

Her er træet automatisk expanded, og vi kan se hvilke test cases der har fejlet. Denne gang har vi også taget den nederste del af panelet med; hvor en fejlmeddelelse fortæller at den observerede værdi: 14, ikke svarer til den forventede værdi: 8. Den anden linie fortæller hvor i test-metoden fejlen opstod (i HeltalTest.java, linie 11). Man kan klikke på denne linie og automatisk komme til dette sted i kildeteksten.

Rerun Test

Efterhånden som man retter fejl (eller forsøger på det) kan man køre testen igen ved at trykke på Rerun Test ikonet (det er det grønne ikon med den hvide trekant øverst i JUnit panelet).

Errors og
failures

Skulle der opstå en fejl i forbindelse med en test case, der ligger udenfor rammerne af JUnit (f.eks. at der opstår en uventet null pointer exception) vil dette blive registreret for sig, som en error. Som man kan se i panelet skelner JUnit mellem failures og errors, og denne skelnen ligger i om fejlen er en (forventet) fejl i en test case (dvs. en failure), eller en (uventet) fejl (dvs. en error). En uventet fejl ville f.eks. være hvis vores test af konstruktoren gav en null pointer exception; hvilket ikke ville være den form for fejl vi havde forventet, idet vi tester for om den rigtige værdi bliver sat. En uventet fejl er rent teknisk, at der bliver kastet en exception, der ikke er en instans af JUnits egne exception klasser.

3. Assertions
Vi har ovenfor brugt assertEquals-metoden, men der findes en lang række af assert-metoder. Vi vil i det følgende kort gennemgå et udvalg af disse metoder:
Først har vi assert-metoden vi netop har brugt:
void assertEquals(... , ...)
assertEquals-metoden er overloaded i et utal af varianter, alt efter parametrenes type. Man kan sammenligne værdier af de primitive typer, samt objekter.
Alle varianter af assertEquals-metoden, har en ekstra variant tilknyttet, der som første parameter tager en tekststreng, der anvendes hvis testen fejler. Dette gør sig generelt gældende for alle assert-metoder, og det vil derfor ikke blive gentaget i det følgende.
En anden meget brugt assert-metode er:
void assertTrue(boolean assertion)
void assertTrue(String message, boolean assertion)
Den bruges når man formulerer en assertion, i form af et boolsk udtryk.
Til at checke om en reference er null eller ej, har man:
void assertNotNull(Object ref)
void assertNotNull(String message, Object ref)
void assertNull(Object ref)
void assertNull(String message, Object ref)
Hvis det forventede output skal være null eller forskelligt fra null, kan disse assert-metoder anvendes. Bemærk at dette ikke forhindrer test-casen i efterfølgende at anvende det returnerede, såfremt det var forskellig fra null.
En anden assert-metode, der ser på referencer er følgende:
void assertSame(Object forventet, Object observeret)
void assertSame(String message, Object forventet, Object observeret)
Her sammenlignes de to referencer med hensyn til reference-lighed, dvs. refererer forventet og observeret til det samme objekt?
Følgende metode er egentlig ikke en rigtig assert-metode, da den altid fejler, men det vil alligevel være naturligt at nævne den her:
void fail()
void fail(String message)
Idéen med den er, at hvis test casen når til det sted i kildeteksten, hvor dette kald af fail er placeret, så er der noget galt!
4. Test opsætning
I vores eksempel ovenfor havde vi to test-cases, der begge anvendte en instans af Heltal. For den førstes vedkommende skulle datakernen sættes til 8 af set-konstruktoren, mens den anden for så vidt var uinteresseret i objektets initielle tilstand, da et kald af set-metoden skulle testes. I situationer, hvor en række test cases har behov for det samme udgangspunkt, kunne man ønske at lave en fælles initialisering til dem. Den kunne i vores eksempel være det at lave en instans af Heltal, der fik sat sin datakerne til 8 af set-konstruktoren. JUnit understøtter, at man kan erklære en sådan fælles initialiserings-metode for de test cases, der er i den samme klasse.
I vores eksempel, kunne det gøres på følgende måde:
Kildetekst 3:
@BeforeEach og @AfterEach
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;

import static org.junit.jupiter.api.Assertions.*;

public class HeltalTest {
  private Heltal t;

  @BeforeEach
  public void setUp() {
    t = new Heltal(8);
  }

  @AfterEach
  public void tearDown() {
    t = null;
  }

  @Test
  public void testKonstruktor() {
    assertEquals(8, t.get());
  }

  @Test
  public void testSet() {
    t.set(5);

    assertEquals(5, t.get());
  }
}
        
Vi har gjort instansen af Heltal tilgængelig vha. en instansvariabel, så alle test cases kan deles om den. Før udførelsen af hver af de to test cases, vil JUnit kalde setUp-metoden, så der er gjort klar til testen. Bemærk, at ingen af de to test cases selv gør noget klar. Grunden til at setUp-metoden bliver kaldt, er at den er annoteret med @BeforeEach. Metoden kunne udemærket have et andet navn, men navnet: setUp, er mere eller mindre en konvention, da det i tidligere versioner af JUnit har været et krav at metoden skulle havde dette navn.
Udfaktorisere setUp-metoden behøver naturligvis ikke gøre alt klar til en given test. Hvis test casene i en test-klasse kun har visse dele af klargøringen til fælles, kan man blot udfaktorisere det pågældende, og lade resten stå i de enkelte test-cases. Af samme grund er setUp-metoden ikke så speciel som man umiddelbart kunne tro, da en udfaktorisering af fælles initialiseringskode også kunne ske ved et manuelt kald af en sådan metode i starten af hver test-case — en løsning som man ville vælge hvis kun en del af test-casene havde en fælles initialisering.
Rydde op Det er ikke al test-opsætning, der blot består i at gøre en række objekter klar. Det kan f.eks. også dreje sig om at etablere en netværksforbindelse. I så fald har vi også brug for at rydde op efter hver test (e.g. lukke en netværksforbindelse). Dette kan tilsvarende gøres ved at annotere en metode med: @AfterEach. I eksemplet ovenfor, har vi ikke noget reelt behov for en sådan metode, og dens indhold er derfor også banalt, idet den blot overlader Heltal-objektet til garbage collectoren, ved at droppe referencen til den — noget der alligevel ville ske ved næste kalde af setUp-metoden. Det er sædvane, at man kalder denne metode: tearDown, selvom det igen er valgfrit, og dét igen skyldes at det tidligere har været påkrævet at man anvendte dette navn.
Ligesom setUp-metoden kaldes før hver enkelt test case, således kaldes tearDown-metoden også efter hver enkelt test case er blevet udført.
5. Exceptions
Hvis man vil teste om en exception bliver kastet i den rigtige situation, kan det gøres på følgende måde:
Kildetekst 4:
Test af exception
public class UpsException extends Exception {

  public UpsException(String message) {
    super(message);
  }
}
      
public class Exceptional {

  public void go() throws UpsException {
    // throw new UpsException("Oh, dear!");
  }
}
      
import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

public class ExceptionalTest {

  @Test
  public void testUpsException() throws UpsException {
    Exception exception = assertThrows(UpsException.class, () -> {
      Exceptional exceptional = new Exceptional();
      exceptional.go();
    });
    
    assertEquals("Oh, dear!", exception.getMessage());
  }
}
      
Forglemmelse Vi har lavet en simpel exception: UpsException, og en klasse: Exceptional, med en simpel metode: go, der skal kaste en UpsException — men det gør den ikke! Vi har nemlig (midlertidigt) udkommenteret linien, og glemt af indkommentere den igen.
I forbindelse med eksemplet ovenfor, meddeles fejlen i JUnit på følgende måde:
Figur 3:
Test af exception
Exception failed