JUnit5 migratie: lessons learned

Me and my big mouth… Het is eind oktober 2019, en ik heb net een presentatie over het relatief nieuwe JUnit5 gegeven voor collega’s. Een vraag uit het publiek: “Je besteedt slechts één sheet aan migratie. Het klinkt simpel. Maar heb je dat eigenlijk zelf al gedaan op de applicatie die jullie team onderhoudt?” Mijn reactie: “Nog niet, maar dat kan niet moeilijk zijn. Ik zal het eens doen; wellicht komen er nog leuke randgevallen naar boven. Ik kom erop terug, wellicht in een nieuwe kennissessie.” [ Auteur Hedzer Westra ]

Tsja. Daar zat ik dan. Je moet weten, we hebben het over een applicatie die al sinds 2012 in ontwikkeling is. Zo’n 185KLOC productiecode, verdeeld over 40 maven modules. Bijna 9000 unit tests. Niet de enige applicatie die mijn team onderhoudt, maar wel de grootste, oudste, en daardoor meest uitdagende. En een heel goed studieobject! Na zes maanden, met tussenpozen maar gestaag doorwerkend in de avonduurtjes, bijna 50 commits later, kan ik zeggen dat de migratie gelukt is. De geleerde lessen deel ik graag met jullie.

JUnit5

op nljug.org of binnen Java Magazine is nog niet eerder over JUnit5 geschreven. Voor hen die er nog niet mee werken, een crash course.

JUnit5 is van de grond af aan opnieuw opgebouwd. Er is nu een splitsing in API (artifact junit-jupiter-api, met base package org.junit.jupiter.api) en implementatie (junit-jupiter-engine). IDE’s en build tools gebruiken het Platform (junit-platform-*). Als overbrugging naar JUnit4 is er junit-vintage.

Onder de motorkap veel veranderingen dus. Als ontwikkelaar van tests heb je met name te maken met opgefriste annotaties – en andere packages in je imports. In simpele unit tests ziet JUnit4- en JUnit5-code er niet eens zo heel verschillend uit.

import static org.junit.Assert.assertEquals;

 

import org.junit.Before;

import org.junit.Test;


public class SomeTest {

    private Sut sut;


    @Before

    public void setUp() {

        sut = new Sut();

    }


    @Test

    public void shouldTestSome() {

        assertEquals(2, sut.add(1,1));

    }

}

wordt:

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



import org.junit.jupiter.api.BeforeEach;

import org.junit.jupiter.api.Test;


public class SomeTest {

    private Sut sut;


    @BeforeEach     // was '@Before'

    void setUp() {  // geen 'public' meer nodig

        sut = new Sut();

    }

    @Test

    void shouldTestSome() {

        assertEquals(2, sut.add(1,1));

    }

}

JUnit5 brengt – naast betere modularisatie, zelfs geschikt voor Java9 Modules – enkele interessante nieuwe functies. Enkele zullen de revue passeren in de migratiebeschrijving; ik eindig het artikel met echt nieuwe functionaliteit waar je mee aan de slag kunt na migratie.

Aanpak

In hoofdlijnen:

  • voeg JUnit5 dependencies toe, en behoud – voor het moment – JUnit4 op het classpath
  • schrijf alle tests om naar JUnit5
  • los alle problemen op die we tegenkomen
  • haal JUnit4 van het classpath
  • klaar!

Hoe verliep dit eenvoudige plan nu in de praktijk?

Voorbereiding

Stap 1. toevoegen jupiter dependencies aan <dependencyManagement/>:

<dependency>

    <groupId>org.junit</groupId>

    <artifactId>junit-bom</artifactId>

    <version>5.6.2</version>

    <type>pom</type>

    <scope>import</scope>

</dependency>

<dependency>

    <groupId>org.junit.jupiter</groupId>

    <artifactId>junit-jupiter</artifactId>

    <scope>test</scope>

</dependency>

<dependency>

    <groupId>org.mockito</groupId>

    <artifactId>mockito-junit-jupiter</artifactId>

    <version>3.3.3</version>

    <scope>test</scope>

</dependency>

In Gradle is het mogelijk junit-jupiter-engine op testRuntimeOnly scope te zetten, immers alleen de API hoeft zichtbaar te zijn in je testcode. Maven kent deze scope niet; we zetten bovenliggende module junit-jupiter op scope test.

 

Stap 2. vervang in alle modules:

<dependency>

    <groupId>junit</groupId>

    <artifactId>junit</artifactId>

</dependency>

door

<dependency>

    <groupId>org.junit.jupiter</groupId>

    <artifactId>junit-jupiter</artifactId>

</dependency>

 

Stap 3. exclude junit:junit van alle test-scoped dependencies.

In onze applicatie ging het uiteindelijk om een achttal dependencies, waaronder bijvoorbeeld org.dbunit:dbunit. Je bent pas klaar als er geen enkele junit:junit dependency meer over is in de uitvoer van mvn dependency:tree!

Stap 4. toevoegen junit-vintage:

<dependency>

    <groupId>org.junit.vintage</groupId>

    <artifactId>junit-vintage-engine</artifactId>

    <scope>test</scope>

</dependency>

Hiermee voeg je JUnit4 weer toe op het classpath. Maar nu gecontroleerd; dat gaan we nog nodig hebben verderop in de migratie.

Stap 5. draai alle tests

Dit zou soepel moeten gaan; junit-vintage-engine dependt rechtstreeks op junit:junit:4.13.

We zijn nu klaar voor de echte migratie. Dus…

Aan de slag! Of niet?

Na een valse start – waarin ik alle modules ineens wilde upgraden – heb ik alle maven modules één voor één omgezet. Geleerde les 1: verdeel en heers!

  1. pas alle imports aan: search-replace org.junit => org.junit.jupiter.api
  2. search-replace annotaties @Before/@After => @BeforeEach/@AfterEach
  3. search-replace annotaties @BeforeClass/@AfterClass => @BeforeAll/@AfterAll
  4. search-replace annotatie @RunWith => @ExtendWith

Dat ging eenvoudig. We draaien de unit tests van de 1e module. Ai, wat nu? Onze code is zo ‘out of sync’ met de moderne tijd dat we nog dependen op org.mockito:mockito-core:jar:1.10.19. En JUnit5 kan daar niet mee overweg. Dus: update naar mockito-core:3.2.0 (inmiddels is 3.3.3 uit – 3.2.0 was destijds de nieuwste).

En toen kwam les 2. Een harde. Er is echt veel veranderd tussen Mockito 1 en 3. Deprecation, breaking changes, verwijderde (in v2 deprecated) API’s, “unnecessary stubbing” meldingen, any(MyClass.class) match inmiddels niet meer op null, et cetera … Feitelijk een nieuwe valse start – alle modules moesten eerst stuk voor stuk naar Mockito 3 omgezet. Laten we zeggen, @RunWith(MockitoJUnitRunner.Silent.class) werd mijn nieuwe vriend. Daarnaast echt veel elbow grease nodig. Geleerde les 2: blijf – als het ook maar even kan – bij met upgrades van de componenten die je in je project gebruikt!

Na nog een upgrade naar maven-surefire-plugin:2.22.0 – nodig voor JUnit5 platform API, kon JUnit5 migratie dan toch echt beginnen.

Slag 2!

Een bloemlezing van wat er verder op het pad kwam.

Asserts

Deze API change had ik niet aan zien komen..

assertTrue(msg, expected)

moet inmiddels zijn:

assertTrue(expected, msg)

Zoek het verschil.. Dit geldt evenzo voor assertEquals() en assertFalse().

Interessant leerpunt: als de import aanwezig is, en geen verwarring mogelijk, biedt IntelliJ de optie “permute arguments”. Dat scheelt!

Skipped tests

Annotatie @org.junit.Ignore moet worden @org.junit.jupiter.api.Disabled.

Asserts, 2

Class org.junit.Assert heet nu org.junit.jupiter.api.Assertions; de eenvoudige search-replace waarmee we startten is onvoldoende.

Asserts, 3

org.junit.Assert.assertThat heeft geen tegenhanger in JUnit5. In ons qua dependencies rijk voorziene project kon gekozen worden uit zowel Hamcrest als AssertJ. Ik koos voor org.hamcrest.MatcherAssert.assertThat aangezien die API-compatible is. Gaf wel enkele problemen met testclasses waar al AssertJ geïmporteerd werd. Les 3: beperk je dependencies tot één implementatie van een bepaalde techniek!

Extensions

In ‘aan de slag’-stap 4 noemde ik ‘terloops’ de hernoeming van @RunWith (in package org.junit.runner) naar @ExtendWith (package org.junit.jupiter.api.extension). Daar gaat echter een hele wereld achter schuil. De oude Runners en nieuwe Extensions zijn heel verschillende zaken. Wat moeten we daarvoor doen?

Mockito

@RunWith(MockitoJUnitRunner.class) wordt @ExtendWith(MockitoExtension.class). Die laatste vind je in package org.mockito.junit.jupiter.

De – af te raden, maar in onze code inmiddels veelgebruikte – @RunWith(MockitoJUnitRunner.Silent.class) annotatie (zie les 2) moet worden: @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = LENIENT) voor het toestaan van “unnecessary stubbing”.

Onverwacht was overigens dat na de conversie van JUnit4+Mockito3 naar JUnit5+Mockito3, opeens nog meer testclasses de ‘lenient’ setting nodig hadden. Netter was natuurlijk geweest om de stubbing beter op orde te krijgen, maar eerlijk gezegd was dat Sisyphusarbeid geweest – onbegonnen werk. Was les 2 maar eerder, en continu, toegepast…

Spring

org.springframework.test.context.junit4.SpringRunner wordt org.springframework.test.context.junit.jupiter.SpringExtension. Oftewel, vervang @RunWith(SpringRunner.class) – en overigens ook @RunWith(SpringJUnit4ClassRunner.class) – door @ExtendWith(SpringExtension.class). Deze extensie is beschikbaar via Spring Boot 2.2. Onze code is daar – en dat zal geen verbazing wekken – niet compatibel mee. Dependency com.github.sbrannen:spring-test-junit5:1.0.3 levert ‘m alsnog.

Een alternatief voor @ExtendWith(SpringExtension.class) is @SpringJUnitJupiterWebConfig. Deze omvat ook meteen @ContextConfiguration @WebAppConfiguration. Hiermee kun je bijvoorbeeld één van de volgende twee contexts laten injecteren in een Integration Test:

@Autowired private WebApplicationContext webApplicationContext;

@Autowired private ApplicationContext applicationContext;

Support is overigens (nog?) incompleet, zo verscheen o.a.:

java.io.FileNotFoundException: Could not open ServletContext resource [/scripts/testdata.sql]

 

Uiteindelijk konden niet al onze Spring IT’s omgezet worden naar JUnit5. Les 4: voorlopig heb je voor enkele randgevallen nog JUnit4 nodig! De eerste les die specifiek van pas komt bij JUnit5 migraties.

Wat wel kan, is de constructie:

class MyIT extends AbstractJUnit4SpringContextTests { .. }

omzetten naar

@SpringJUnitJupiterWebConfig

class MyIT { .. }

Maar ook dit binnen de beperkingen van SpringExtension.

Parameterized

@RunWith(Parameterized.class) moet worden vervangen (op methodeniveau) door @ParameterizedTest @MethodSource(“methodeDieDataOplevert”). Daarnaast: verwijder @org.junit.runners.Parameterized.Parameters van methodeDieDataOplevert(), en verwijder de constructor en velden die de parameterwaarden verwerkten. Voor deze (Experimental!) feature is dependency junit-jupiter-params nodig. Die krijgen we cadeau van org.junit.jupiter:junit-jupiter.

Een codevoorbeeld:

@RunWith(Parameterized.class)

@RequiredArgsConstructor

public class SomeTest {

    private final boolean allowed;

    private final String mimeType;




    @Parameters

    public static Collection<Object[]> data() {

        return Arrays.asList(new Object[][] {

            { false, "image/gif" },

            { true, "image/png" }

        });

    }




    @Test

    public void testIsExtensionAllowed() {

        assertEquals(allowed, sut.isAllowed(mimeType));

    }

}

wordt

class SomeTest {

    static Collection<Object[]> data() {

        return Arrays.asList(new Object[][] {

                { false, "image/gif" },

                { true, "image/png" }

        });

    }


    @ParameterizedTest

    @MethodSource("data")

    void testIsExtensionAllowed(boolean allowed, String mimeType) {

        assertEquals(allowed, sut.isAllowed(mimeType));

    }

}

Enclosed

@RunWith(Enclosed.class) (op de toplevel class) moet worden vervangen door @Nestedop de nested classes. Nested classes mogen niet static zijn, tenzij we @TestInstance(PER_CLASS) toevoegen. Normaal geen probleem, maar noodzakelijk als we dit combineren met @ParameterizedTest.

Overigens werkte dit niet in alle gevallen; enkele @Nested @ParameterizedTests zijn uiteindelijk omgezet naar een toplevel class.

DataProvider

We gebruikten ook com.tngtech.java:junit-dataprovider. @RunWith(DataProviderRunner.class) en op methodeniveau @Test @UseDataProvider(“methodeDieDataOplevert”) wordt simpelweg @ParameterizedTest @MethodSource(“methodeDieDataOplevert”). We kunnen nu zonder dependency junit-dataprovider.

JUnit4

@RunWith(org.junit.runners.JUnit4.class) was al niet nodig. Afvoeren!

Exceptions

Excepties hebben een veel krachtiger API in JUnit5. De benodigde aanpassingen zijn doodeenvoudig, maar moeilijk in een search-replace te vangen. Even buffelen dus, als je codebase groot is…

Code zoals dit:

@Test(expected = SomeCheckedException.class)
public void when_running_test_code_then_exception_is_thrown() throws SomeCheckedException {
    .. test setup ..
    testCode();
    .. asserts?! ..
}
wordt:
@Test
public void when_running_test_code_then_exception_is_thrown() {
    .. test setup ..
    assertThrows(SomeCheckedException.class, () -> testCode());
}

Hieruit zijn enkele leerpunten te halen:

  • de nieuwe API voorkomt dode testcode. De (waarschijnlijk door het favoriete copy-paste-adapt patroon ontstane) “.. asserts?! ..” coderegels werden nooit geraakt; de SomeCheckedException was dan al opgetreden. Enkele tests faalden dan ook spontaan op deze asserts!
  • assertThrows() maakt het mogelijk om exact te pinpointen waar de exceptie plaatsvindt; met het expected = ..-annotatieattribuut kon je alleen aangeven dat de testmethode moest falen; niet waar.
  • het is niet meer nodig om throws SomeCheckedException aan een testmethode toe te voegen

Overigens geeft assertThrows() de Exceptie terug als resultaat, waarna het eenvoudig is om er asserts op toe te passen. Bijvoorbeeld om de juistheid van de message of root cause vast te stellen.

Rules & Suites

Evenals Runners, worden ook Rules en Suites niet ondersteund in JUnit5.

Een voorbeeld: testcode die de ExpectedException Rule gebruikt, dient te worden omgeschreven naar Exception assertion.

Ons project bevat helaas enkele Integration Tests die alleen in een Suite correct draaien, tenzij we ze herschrijven voor onafhankelijkheid. Ook deze tests blijven voorlopig in JUnit4…

Afronding

De paar modules die nog JUnit4 tests bevatten, krijgen een expliciete dependency op junit-vintage. In de main pom is deze verwijderd, om zeker te zijn dat er geen JUnit4 code is achtergebleven, of nieuwe wordt toegevoegd.

Een voor-en-na test run meting geeft aan dat mvn clean install is gegaan van 6:30 naar 7:30 minuten. Ik had eigenlijk juist een versnelling verwacht; dat was te optimistisch. Vermoedelijk hebben de strengere (Mockito) tests hun prijs.

Ik ben zelfs zo ver gegaan om de complete Maven uitvoer voor en na te vergelijken. Stack traces zien er een beetje anders uit, vermoedelijk door assertThrows(), maar dat is het eigenlijk wel.

Pluk de vruchten!

Welke nieuwe features zijn beschikbaar, nu (bijna) alle tests over zijn naar JUnit5? Enkele highlights:

  • assertAll() waarmee asserts gegroepeerd kunnen worden. Ze worden allemaaluitgevoerd en gerapporteerd, waar voorheen een individuele unit test meerdere keren kon falen, elke keer op een volgende assert.
  • assumingThat() en andere Assumptions & Conditionals. Nota bene: org.junit.Assume wordt org.junit.jupiter.api.Assumptions
  • @DisplayName om tests beschrijvende namen te geven, onafhankelijk van de methodenaam. Ook templating wordt ondersteund, bijvoorbeeld voor @RepeatedTesten @ParameterizedTest. En Unicode 🎉!
  • @Nested. Dit gebruiken we al enige tijd in onze modernere projecten, om samenhangende tests te groeperen, bijvoorbeeld alternatieve gevallen van één te testen SUT methode.

Als je echt alles uit de kan wil halen, zijn er ook nog: @TestFactory (voor dynamic tests); @EnabledOnOs & @DisabledOnOs, @EnabledOnJre & @DisabledOnJre, etc.; @RepeatedTest; @Timeout; @TempDir; @ParameterizedTest met andere sources: @ValueSource, @NullSource, @EmptySource, @EnumSource; Tagging & FilteringExecution OrderDependency Injection for Constructors and Methods en Parallel Execution.

Behandelen hiervan voert te ver voor dit artikel. De JUnit5 user guide (zie de referenties) bespreekt ze tot in detail.

Voor later

Vooralsnog is cucumber-jvm niet geschikt voor JUnit5. Hopelijk komt dat binnenkort beschikbaar. We zullen ook spring-test-junit5 in de gaten houden, om de laatste Spring IT’s ook om te zetten.

Nog meer?

Mocht je je eigen projecten gaan migreren, dan kom je misschien, naast wat hierboven aan bod kwam, ook onderstaande zaken tegen. In ons project werden ze niet gebruikt.

  • Naast @Rule kent JUnit4 ook @ClassRule. Vervang dit door @ExtendWith en @RegisterExtension – maak je eigen extension.
  • @Category wordt @Tag.
  • Kun of wil je niet alles migreren, dan zou je artifact junit-jupiter-migrationsupportkunnen overwegen. Annotatie @EnableRuleMigrationSupport levert dan turn key support voor (JUnit4) Rules: ExternalResource, TemporaryFolder, Verifier, ErrorCollector, ExpectedException. Annotatie @EnableJUnit4MigrationSupport levert datzelfde, met daarbij @Ignore.

Conclusie

Zou ik JUnit5 aanbevelen bij nieuwe projecten? Zonder enige twijfel! Is het nuttig en effectief om bestaande projecten te migreren? Met een slag om de arm: ja.

De hier beschreven migratie was een hele goede exercitie om uit te voeren op dit grote project. Behoorlijk wat oude cruft werd opgerakeld en vernieuwd, zoals ook benoemd in de diverse geleerde lessen. Wat met name opviel waren de vele stijlen, ‘idiomen’ zo je wilt, die in de jaren zijn toegepast door de diverse ontwikkelaars. Niet alleen in de code zelf, maar ook in de redundante libraries – waarvan AssertJ vs. Hamcrest maar één voorbeeld is. De stofkam die nodig was voor deze migratie heeft een eerste stap in unificatie afgedwongen, waarmee toekomstige upgrades eenvoudiger worden.

De Exception assertion, en met name Mockito 1 naar 3 upgrade, kostten veel (zij het eenvoudig) werk; verder is JUnit5 migratie helemaal niet ingewikkeld. Pas het eens toe op jouw project en laat me weten welke ‘bijvangst’ jullie dat opgeleverd heeft!

Referenties

bio

Hedzer Westra is Full Stack DevOps Engineer bij Rabobank. Zijn carrière met Java begon in 1996, tijdens een studie Informatica. Je kunt hem bereiken op hedzer.westra@rabobank.nl.