Unit testen in Java met Maven Surefire en JUnit

Some artwork to go along with this unit testing/TDD example

Bart van der Wal, augustus 2024.


Dit is de Java/Spring Boot variant van de tutorial 'Unit testing C# in .NET using dotnet test and xUnit' op Microsoft Learn (2024). Deze tutorial toont een project opzet met een:

  • unit test-project
  • en een broncode-project

Om de tutorial te volgen met een vooraf opgebouwd project, kun je de voorbeeldcode op GitHub (van der Wal, 2024) bekijken of downloaden. We gaan er van uit dat je weet hoe dit werkt.

Hieronder eerst een toelichting over de wijzigingen t.o.v. de originele C# variant/tutorial en de context. Dit is ook interessant voor studenten die deze opdracht doen. Daarna 5 instructiestappen om te volgen meer specifiek voor Java Spring Boot variant. Hiervan is de laatste optioneel.

1. Toelichting

Deze tutorial is naast starter van de week opdracht ook een beetje een voorbeeld blog post, zoals je zelf in week 6 moet schrijven. Met in ieder geval een hands-on karakter via voorbeeld code om over te nemen en terminal commands om uit te voeren voor jou als lezer. Ook bevat deze APA referenties, zoals je zelf in de blog moet gebruiken. is in het Nederlands Alleen het 'onderzoekskarakter' is wellicht ondermaats als voorbeeld. Alhoewel ik hieronder wel wat beschouwingen doe en aansluit bij DevOps concepten zoals de DevOps roadmap en het Continuous Delivery Maturity Model (CDMM) die je ook een beetje, respectievelijk zelfs heel goed moet leren kennen in de minor.

Deze tutorial schreef ik naar aanleiding van minor DevOps 2024 waarin we licht varieren/diverisificeren t.o.v. eerdere edities. Dit in het kader van concept van 'Polyglot X' uit DevOps: dat microservices verschillende talen/frameworks kunnen gebruiken. Hiermee stijg je uit boven het basis niveau (Base level) uit het CDMM (een soort DevOps Maturity Model, InfoQ, 2013):

"Many organizations at the base maturity level will have a diversified technology stack but have started to consolidate the choice of technology and platform, this is important to get best value from the effort spent on automation."

Merk op dat er hierbij het checkpunt uit CDMM 'consolidate' (MinorDevOps, z.d.)) er wel een gemene deler Docker of Kubernetes is. Qua communicatie tussen de microservices is (gewoon via een) http API een standaard, maar in een latere week van de minor leren studenten ook een voorbeeld van een 'message bus' of (eigenlijk) 'message broker' namelijk RabbitMQ. ZO ga je naar meer advanced level van CDMM:

"...split the entire system into self contained components and adopt[ed] a strict api-based approach to inter-communication so that each component can be deployed and released individually." (InfoQ, 2013)

Deel van DevOps roadmap wat aangepast naar context van minor DevOps

Figuur 1: Deel van DevOps roadmap wat aangepast naar context van minor DevOps (roadmap.sh, 2024)

De helft van de studenten (-teams/-duo's) doet de opdracht met .NET. De andere helft met Java, Spring Boot. In opdracht van week 2 ga je verder door de deze basis applicatie 'containerizen'.

2. Installeren scoop of SDKMan en dan Spring Boot CLI

We start een Java project met Spring Boot. Hierbij gebruiken we Maven als package manager zoals gebruikelijk bij Software Engineering op de HAN. Je bent misschien geneigd om IntelliJ te gebruiken. En voor het editen van code is dit prima, maar voor de herhaalbaarheid/scriptability ga je nu als DevOps-er, de initiele opzet via de commandline doen.

Installeer daarvoor de Spring Boot CLI (Command line interface) conform de instructies op de website (Spring Boot, z.d.): Maar het lijkt wel 'turtles all the way down', want voordat je Spring CLI kunt installeren (Spring Boot, z.d.) moet je eerst een package manager installeren, maar gebruik (en installeer) bv. scoop op Windows (scoop, z.d.), brew op macOS of sdkman als je Linux gebruikt.

Dit is een korte samenvatting voor Windows gebruikers (macOS en Linux komen er sowieso wel uit, toch?)

"Open een PowerShell terminal (version 5.1 or later) en vanaf de PS C:> prompt, run:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression

En daarna op de command line:

scoop bucket add extras
scoop install springboot

Bij onverhoopte errors, backtrace check de gegeven/originele bronnen/website voor installatie instructies, dit verandert vaak snel over de tijd.

3. Start Java Spring Boot project

Deze sectie beschrijft het maken van een Spring Boot Java project met de applicatie code en ook meteen een eigen test package. De project moet de volgende directory structuur en bestanden krijgen, conform standaard Maven structuur:

/priemtester
    pom.xml
    src/main/java/nl/han/devops/
        PriemTesterApplication.java
        PriemTester.java
    src/main/java/nl/han/devops
        PriemTesterTests.java
Gebruik de Spring Initilizer in IntelliJ Spring boot CLI

Figuur 2: Je zou de Spring Initializer in IntelliJ CLI kunnen gebruiken, maar...

Of als DevOpser (in opleiding) gebruik je natuurlijk de Spring boot CLI voor scriptability en 'screenshotty tutorials' te voorkomen

Figuur 3: Beter: gebruik de Spring boot CLI, don't be afraid of the command line!

Voer onderstaand commando uit. Superkort is het ook niet meer, maar je customized meteen de package naam en naam van java klasse met je main(..) functie (dit wordt PriemTesterApplication).

spring init --build=maven --package-name=nl.han.devops priemtester

Je mag nu de applicatie is aangemaakt deze wel openen in IntelliJ IDEA. Installeer deze als je deze nog niet hebt staan (best meteen Ultimate editie via je HAN student e-mail). Of navigeer naar je project directory en open deze in de VS Code editor:

cd priemtester
code .

4. Make it your own!

Hernoem DemoApplication.java naar PriemTesterApplication.java.

Maak een nieuwe klasse PriemTester.java met de volgende code:

package nl.han.devops;

public class PriemTester {
    public boolean isPriemgetal(int candidate) {
        throw new UnsupportedOperationException("Nog niet geïmplementeerd.");
    }

}

Aangezien we een unit test gaan schrijven van de 'domein' logica, en NIET de gehele Applicatie (dat zou een integratietest zijn) hernoem ook de DemoApplicationTests.java naar PriemTesterTests.java om deze te 'repurposen'. Verwijder dan ook de gegenereerde contextLoads() methode, die hier dan niet meer thuishoort.

Deze code gooit nu een UnsupportedOperationException met een bericht dat deze methode nog geimplementeerd is. Op deze manier compileert de code wel, en kun je het runnen, maar is duidelijk wat er aan de hand is, ook als je dit stuk code een paar dagen laat liggen, en dan pas weer terug komt. Maar nu gaan we dit gaan we al veel sneller aanpassen ;).

De NIET TDD manier om de code te runnen/testen zou zijn om in je main de nieuwe methode aan te roepen. Je kunt even tijdelijk de code in je main methode in PriemTesterApplication.java als volgt maken, aangezien deze nu nog niet echt iets doet, en om wel te checken of je nog Java programma's kunt runnen ;). Maar verwijder deze code daarna weer.

   public static void main(String[] args) {
      SpringApplication.run(PriemTesterApplication.class, args);

      // Tijdelijke test code.
      var priemTester = new PriemTester();
      for(int i = 0; i<10; i++) {
      System.out.println("Getal " + i + " is een priemgetal?" + priemTester.isPriemgetal(i));
      // Einde tijdelijke test code, beter via TDD
   }
}

We gaan nu unit tests gebruiken als entry points in plaats van de main methode. Zo kom je ook netjes tot TDD: Test Driven Development, zoals verplicht in deze minor! En 'vervuil' je je main methode niet met test code.

5. Maak een test, TDD all the way!

Een populaire aanpak in testgestuurde ontwikkeling (TDD) is om eerst een (mislukkende) test te schrijven voordat de doelcode wordt geïmplementeerd. Deze tutorial gebruikt de TDD-aanpak. De methode IsPrime kan worden aangeroepen, maar is niet geïmplementeerd. Een testaanroep naar IsPrime faalt. Bij TDD wordt een test geschreven waarvan bekend is dat deze zal falen. De doelcode wordt vervolgens bijgewerkt om de test te laten slagen. Je herhaalt deze aanpak steeds opnieuw: je schrijft een falende test en werkt daarna de doelcode bij om te slagen.

Vul de code in PriemTesterApplicationTests met test methodes voor input 1 en 2. Later komen er meer methodes in de verzameling tests. Zo'n verzameling tests noemt men ook wel een test suite.

NB: Dit woorde 'suite' in 'test suite' spreek je ANDERS uit dan het woord 'suit' (=pak), wat veel mensen foutief zeggen. Je zegt 'suite' meer als 'zwiete' (of 'sweet' in het Engels). Luister anders eens op Google Translate naar het Nederlands en evt. ook de Engelse variant, via de volgende link:

https://translate.google.com/?sl=nl&tl=en&text=test%20suite&op=translate (als developer/devopser moet je ook kunnen praten over je vak, dus probeer hier zelf eens zinnen of woorden uit als je twijfelt hoe je ze uitspreekt)

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PriemTesterApplicationTests {

  PriemTester sut;

  @BeforeAll
  void beforeAll() {
    sut = new PriemTester();
  }

  @Test
  void PriemTester_1_is_geen_priemgetal() {
    // Arrange.
    // TODO Refactoren dit soort herhalende code.
    // DAMP: DRY voor unit tests: Descriptive And Meaningfull Phrases.
    sut = new PriemTester();

    var input = 1;
    var expected = false;

    // Act.
    var actual = sut.isPriemgetal(input);

    // Assert.
    Assertions.assertEquals(expected, actual);
  }

  @Test
  void PriemTester_2_is_een_priemgetal() {
    // Arrange.
    // TODO Refactoren dit soort herhalende code.
    // DAMP: DRY voor unit tests: Descriptive And Meaningfull Phrases.
    sut = new PriemTester();

    var input = 2;
    var expected = true;

    // Act.
    var actual = sut.isPriemgetal(input);

    // Assert.
    Assertions.assertEquals(expected, actual);
}

De @Test annotatie maakt een methode als een test methode die gerund wordt door de 'test runner'. De naam van een test methode maakt technisch niet uit, maar het is belangrijk deze een beschrijvende naam te geven.

Verder biedt JUnit ook de @BeforeEach en @BeforeAll annotatie die telkens voor elke test (in de testsuite) respectievelijk eenmaal voor start van op de (gelijknamige) methode)

Run mvn test. En/of gebruik de 'play' toets op toetsmethodes in je editor/IDE.

Los de TODO's op door herhalende code te refactoren naar de BeforeAll. En run de tests opnieuw om te checken dat alles nog werkt.

Breidt nu de applicatie code isPriemgetal uit met de volgende code:

public boolean isPriemgetal(int kandidaat) {
    if (kandidaat == 1) {
        return false;
    }
    throw new UnsupportedOperationException("Niet geheel geïmplementeerd.");
}

Run weer de tests.

6. Meer testgevallen via parametriseren testmethode

Voeg tests toe voor input 0 en -1. Je kan verder gaan copy-pasten en wat aanpassen. Maar dat is niet DRY (Don't Repeat Yourself), maar WET (Write Everything Twice). Er is een betere manier!

bool result = sut.IsPrime(1);

Assert.False(result, "1 zou geen priemgetal moeten zijn");

Er bestaan nog extra JUnit annotaties die het mogelijk maken een suite van vergelijkbare tests op te stellen.

Je kunt gebruik maken van @ParameterizedTest om een reeks tests uit te voeren met verschillende invoerargumenten. De annotatie @ValueSource specificeert de waarden voor die invoer. In plaats van afzonderlijke test methodes te schrijven, gebruik je deze JUnit annotaties om één enkele testtheorie te creëren. Als volgt:

package nl.han.devops;

import org.junit.jupiter.api.*;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class PriemTesterTest {

  private final PriemTester priemTester = new PriemTester();

  @ParameterizedTest
  @ValueSource(ints = {-1, 0, 1, 2})
  public void isPriemgetal_WaardenKleinerDan2_ReturnFalse(int value) {
    boolean result = priemTester.isPriemgetal(value);

    Assertions.assertFalse(result, value + " zou geen priemgetal moeten zijn");
  }
}

NB Conform lessen uit het 1e Semester van Software Engineering, is het goed de priemTester variabele in bovenstaande code te hernoemen naar sut om evident te maken dat de PriemTester klasse de system under test is in deze unit test.

Run mvn test en twee van de testen zullen falen. Om alle tests te laten slagen, update de IsPriem methode met de volgende code:

public bool IsPriemgetal(int kandidaat) {
    if (kandidaat < 2) {
        return false;
    }
    throw new NotImplementedException("Not fully implemented.");
}

Volg de TDD aanpak, en voeg meer falende testen toe, en update de applicatie code. Zie evt. de uiteindelijke versie van de implementatie op GitHub.

Let op: Deze IsPriem methode is GEEN efficient algoritme om te testen of een getal een priemgetal is (hiervoor kun je best een bestaande oplossing/package uit NuGet of MavenCentral repository halen).

7. Optionele extra opdracht: Make it fast.

Binnen Software Engineering, en ook in deze minor, richten we ons vooral op de 'make it work' fase en dan de 'make it nice' fase uit het bekende adagium van Beck's bekende gezegde over code:

Make it nice, only then make it fast

Figuur 4: "Make it work, make it nice, make it fast." - Kent Beck, z.d. (TODO: APA), Bron plaatje: the tombomb

Maar voor de liefhebbers, die eens willen kijken naar 'make it fast'. Best manier is toch dit van andere te jatten, en je eigen originaliteit te bewaren voor onopgeloste problemen (of oplossing van bestaande problemen toepassen in nieuwe domeinen, waar niemand dit probleem al echt heeft gezien ... 😉 💶).

Voor een WEL efficiente methode, of in ieder geval efficientere aanpak zoek zelf een package in NuGet of Maven central, bestudeer de README/documentatie of community feedback of deze goed is, en voeg hiervan de URL als bron toe. En voeg een variant toe van deze implementatie. Liefst via eerst extracten van een interface uit je huidige PriemTester (met refactor optie van IntelliJ bv; of anders handmatig. Deze interface noem je PriemTester en je huidige implementatie hernoem je naar CustomPriemTester. Dan maak je een nieuwe implementatie EfficientPriemTester, en hierin roep je de Maven package aan. Feitelijk is dit nu een.... Adapter patroon). Later zou je een load test kunnen bouwen, om de twee implementaties op performance/snelheidsverschillen te kunnen testen.

Bronnen