Our Blog

Sharing Our Experiences

Testing your Spring Boot 3.5.x applications

Testen is voor veel ontwikkelaars een moeilijk onderwerp. Of het nu komt door een afkeer van het schrijven van tests, een misverstand over het belang van testen of gewoon een gebrek aan kennis, de tests die voor onze codebase zijn geschreven, kunnen veel verbetering gebruiken. Het is zover gekomen dat ik het vermijd om naar onze test coverage te kijken. TDD? Nog nooit van gehoord.

Ik heb gemerkt dat de eerste twee cases meestal voortkomen uit het derde geval. Toen mijn collega-ontwikkelaars begrepen hoe ze iets moesten testen, realiseerden ze zich al snel waarom we dat moesten doen, en de afkeer verminderde omdat ze de taak snel en gemakkelijk konden afmaken. Nog steeds vervelend, maar geen last meer.

En zo komen we bij dit artikel: een intro tot het schrijven van tests in Spring Boot 3.5.0. De cases zullen zich concentreren op de nieuwste toevoegingen aan Spring Boot, maar de essentiële klassiekers zullen natuurlijk ook worden opgenomen. En laten we er nu in vliegen!

JUnit 5

De eerste benodigdheid is de JUnit-bibliotheek. Versie 5 is al een tijdje uit: de grootste verandering was de toevoeging van de Jupiter test engine. In de praktijk zit het grootste verschil in de annotaties: @Test, @BeforeAll, @BeforeEach, @AfterAll, @AfterEach en @ExtendWith. Ze bevinden zich allemaal onder het org.junit.jupiter.api package.

Hier is een kort overzicht van wat ze doen:

  • @Test: geplaatst op een methode om het als een test aan te duiden.
  • @BeforeAll: vervangt @BeforeClass, geannoteerde methode wordt één keer uitgevoerd voor de testklasse vóór de tests. De geannoteerde methode moet statisch zijn.
  • @BeforeEach: vervangt @Before, geannoteerde methode wordt één keer uitgevoerd voor elke testmethode
  • @AfterAll: vervangt @AfterClass, geannoteerde methode wordt één keer uitgevoerd voor de testklasse na de tests. De geannoteerde methode moet statisch zijn.
  • @AfterEach: vervangt @Before, geannoteerde methode wordt na elke testmethode eenmaal uitgevoerd
  • @ExtendWith: wordt gebruikt om een extensie te registreren voor de tests om mee te draaien. @SpringBootTest heeft dit als meta-annotatie om de SpringExtension te registreren.

@SpringBootTest

Een andere klasse is de SpringBootTest-annotatie, die op uw testklassen kan worden geplaatst om ze de Spring-context te laten initialiseren voordat de tests worden uitgevoerd. Dit biedt de mogelijkheid om beans automatisch in de testklasse te brengen, mocking op beans toe te passen en tal van andere functies. Over het algemeen raad ik degenen die @SpringBootTest gebruiken aan om dit alleen te doen voor integratietests. Hoewel het mogelijk is om alle hierna beschreven tests met deze annotatie te schrijven, kan dit de looptijd van uw tests enorm verlengen. Het kan immers even duren voordat de Spring context is opgestart, en @SpringBootTest doet het voor elke test klasse.

Voorbeeld context

De context voor alle volgende voorbeelden is een eenvoudige API-bibliotheek. Gebruikers halen doormiddel van de API informatie op over bibliotheken en de boeken die ze bevatten, en voegen boeken aan de bibliotheken toe. De bron van de boeken is een externe API waar de bibliotheek zelf op vertrouwt.

MockServerRestClientCustomizer

Een van de nieuwere toevoegingen aan Spring is de RestClient. Deze interface is gebaseerd op de fluent API van de reactive WebClient, terwijl dezelfde basisinfrastructuur wordt gebruikt als de RestTemplate. RestClient zal de focus zijn van nieuwe functies voor het Spring team, dus het is het beste om zo snel mogelijk te leren kennen.

Als alternatief voor het mocken van een RestTemplate, leverde Spring al de MockRestServiceServer. Met dit object kunt u de onderliggende HTTP-API mocken die een RestTemplate zou aanroepen.

Dezelfde functionaliteit is voorzien voor RestClient, door middel van de MockRestServiceServer.

Het verschil zit hem in het registreren van de MockRestServiceServer bij de RestClient, waar de MockServerRestClientCustomizer bij komt kijken. Een instance van deze klasse kan worden geregistreerd op RestClient.Builder instance.

Je kan deze set-up altijd handmatig uitvoeren, maar Spring Boot biedt ons standaard twee methodes aan.

  • @AutoConfigureMockRestServiceServer registreert alle benodigde beans en configuratie voor RestClients en RestTemplates. Dit zal worden gebruikt in combinatie met @SpringBootTest om ons te helpen HTTP API’s te mocken in integratietests.
  • @RestClientTest combineert de reikwijdte van een unit test met de vorige annotatie. Hoewel het ook een Spring context opstart, is het veel kleiner in vergelijking met de volledige @SpringBootTest context. Voel je vrij om de documentatie te bekijken voor de details, maar om het in cijfers uit te drukken: het opstarten van de RestClientTest duurde ongeveer 1 seconde, terwijl het opstarten van de SpringBootTest er 4 duurde.

 

En daarmee komen we bij de voorbeelden. Eerst de RestClientTest:

				
					import *;

@RestClientTest(BookClient.class)
class BookClientTest {

    @Autowired
    private BookClient bookClient;

    @Autowired
    private MockRestServiceServer mockServer;

    @Test
    void getBook() {
        mockServer.expect(requestTo("/books/01969175-ebb8-7fad-b229-0e4d1364ff06"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(withStatus(HttpStatus.OK)
                        .contentType(MediaType.APPLICATION_JSON)
                        .body("""
                        {
                            "isbn": "01969175-ebb8-7fad-b229-0e4d1364ff06",
                            "author": "a bad test author",
                            "title": "a fantastic book on testing"
                        }
                        """));

        Book book = bookClient.getBook("01969175-ebb8-7fad-b229-0e4d1364ff06");


        Book expectedBook = new Book();
        expectedBook.setIsbn("01969175-ebb8-7fad-b229-0e4d1364ff06");
        expectedBook.setAuthor("a bad test author");
        expectedBook.setTitle("a fantastic book on testing");

        assertThat(book).usingRecursiveComparison().isEqualTo(expectedBook);
    }
}

				
			

Dit test een enkele bean, de BookClient, die zelf afhankelijk is van een RestClient.Builder. @RestClientTest is gebouwd om dit soort beans te testen: ze zijn afhankelijk van een enkele RestClient.Builder, en om ze te testen moeten we een enkele MockRestServiceServer in de testklasse opnemen.

De @SpringBootTest is wat meer betrokken. De volgende extra’s  zijn ten opzichte van de RestClientTest nodig:

  1. @AutoConfigureMockRestServiceServer: @RestClientTest doet de inrichting van de MockRestServiceServer voor ons, maar @SpringBootTest niet. Deze annotatie activeert Spring Boot om dit te doen.
  2. Een singleton RestClient.Builder bean: Spring Boot biedt standaard prototype scoped RestClient.Builder beans. Omdat we toegang nodig hebben tot het specifieke RestClient.Builder-object dat in onze applicatie is ingebouwd, definiëren we het zelf, net als Spring Boot, maar zonder de prototype-scope, alleen voor deze test.
  3. Autowire de RestClient.Builder en MockServerRestClientCustomizer: deze worden gebruikt om de MockRestServiceServer op te halen die is gekoppeld aan de RestClient en om de verwachte calls die erop zouden moeten plaatsvinden te registreren.
				
					import *;

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureMockRestServiceServer
class LibraryControllerIT {
    @Autowired
    private MockServerRestClientCustomizer mockServerRestClientCustomizer;
    @Autowired
    private RestClient.Builder bookIndexRestClientBuilder;

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser
    @Sql(statements = "INSERT INTO LIBRARY(ID, NAME) VALUES ('01969175-ebb8-7fad-b229-0e4d1364ff04', 'Test library name')")
    void testAddBookToLibrary() throws Exception {
        MockRestServiceServer mockServer = mockServerRestClientCustomizer.getServer(bookIndexRestClientBuilder);
        mockServer.expect(requestTo("http://localhost:20000/books/isbn"))
                .andExpect(method(GET))
                .andRespond(MockRestResponseCreators.withAccepted()
                        .contentType(MediaType.APPLICATION_JSON)
                        .body("""
                                {
                                    "isbn": "isbn",
                                    "title": "a nice title",
                                    "author": "a bad test author"
                                }
                                """));

        mockMvc.perform(post("/libraries/01969175-ebb8-7fad-b229-0e4d1364ff04/books")
                        .content("isbn"))
                .andExpect(status().isOk())
                .andExpect(content().json("""
                        {
                         "isbn": "isbn",
                         "title": "a nice title",
                         "author": "a bad test author"
                        }
                        """));
    }

    @TestConfiguration
    public static class TestConfig {
        @Bean
        RestClient.Builder bookIndexRestClientBuilder(RestClientBuilderConfigurer configurer) {
            return configurer.configure(RestClient.builder());
        }
    }
}

				
			

@TestBean

Een nieuwe toevoeging aan Spring Boot in 6.2, deze annotatie kan worden gebruikt om een bean te overschrijven in de Spring-context voor uw test. Hiermee kunt u een stub, mock of andere varianten van een specifieke bean in uw applicatie en tests injecteren. De manier waarop het werkt is simpel: annoteer een veld in je test met @TestBean zoals je zou doen bij het autowiren van een bean, en schrijf een statische methode met dezelfde naam als het veld dat een object teruggeeft om de bean door te vervangen. Sommige extra parameters zijn mogelijk in het geval van meerdere beans van hetzelfde type, maar zijn in de meeste gevallen waarschijnlijk niet nodig.

				
					import *;

@WebMvcTest(BookIndexController.class)
public class BookIndexControllerTest {
    @Autowired
    private MockMvc mvc;

    @TestBean
    private BookIndexService bookIndexService;

    /**
     * Stubbed bookIndexService
     **/
    static BookIndexService bookIndexService() {
        return new BookIndexService() {
            @Override
            public List<BookDTO> searchBooks(String keyword) {
                BookDTO bookDTO = new BookDTO();
                bookDTO.setTitle("contains the keyword");
                bookDTO.setAuthor("author");
                bookDTO.setIsbn("a unique isbn");
                return List.of(bookDTO);
            }

            @Override
            public BookDTO getBookByISBN(String isbn) {
                BookDTO bookDTO = new BookDTO();
                bookDTO.setTitle("a random title");
                bookDTO.setAuthor("author");
                bookDTO.setIsbn(isbn);
                return bookDTO;
            }
        };
    }

    @Test
    @WithMockUser
    void getBooks() throws Exception {
        mvc.perform(get("/books"))
                .andExpect(status().isOk())
                .andExpect(content().json("""
                        [{
                        "id": null,
                        "title": "contains the keyword",
                        "author": "author",
                        "isbn": "a unique isbn"
                        }]
                        """));
    }

    @Test
    @WithMockUser
    void getBook() throws Exception {
        mvc.perform(get("/books/01969175-ebb8-7fad-b229-0e4d1364ff05"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().json("""
                        {
                        "id": null,
                        "title": "a random title",
                        "author": "author",
                        "isbn": "01969175-ebb8-7fad-b229-0e4d1364ff05"
                        }
                        """));
    }
}

				
			

@MockitoBean en @MockitoSpyBean

Deze annotaties komen je misschien bekend voor: dit zijn de vervangingen van @MockBean en @SpyBean. De laatste werden geleverd door de spring-boot-test bibliotheek, terwijl de eerste worden geleverd door de spring-test bibliotheek. @MockBean en @SpyBean zijn ook gemarkeerd als verouderd, dus het is het beste om eerder vroeger dan later te migreren.

				
					import *;

@SpringBootTest
class BookIndexServiceImplTest {
    @MockitoBean
    private BookApi bookApi;

    @Autowired
    private BookIndexServiceImpl bookIndexService;

    @AfterEach
    void tearDown() {
        verifyNoMoreInteractions(bookApi);
    }

    @Test
    void searchBooks() {
        Book book = new Book();
        book.setTitle("Book Title");
        book.setAuthor("Author");
        book.setIsbn("ISBN");

        when(bookApi.getBooks("key")).thenReturn(List.of(book));

        List<BookDTO> result = bookIndexService.searchBooks("key");

        BookDTO expectedBook = new BookDTO();
        expectedBook.setTitle("Book Title");
        expectedBook.setAuthor("Author");
        expectedBook.setIsbn("ISBN");
        assertThat(result).usingRecursiveComparison().isEqualTo(List.of(expectedBook));

        verify(bookApi).getBooks("key");
    }

    @Test
    void getBookByISBN() {
        Book book = new Book();
        book.setTitle("Book Title");
        book.setAuthor("Author");
        book.setIsbn("ISBN");

        when(bookApi.getBookByISBN("ISBN")).thenReturn(book);

        BookDTO result = bookIndexService.getBookByISBN("ISBN");
        BookDTO expectedBook = new BookDTO();
        expectedBook.setTitle("Book Title");
        expectedBook.setAuthor("Author");
        expectedBook.setIsbn("ISBN");
        assertThat(result).usingRecursiveComparison().isEqualTo(expectedBook);

        verify(bookApi).getBookByISBN("ISBN");
    }
}

				
			

Belangrijk: een veelgemaakte fout die ik heb gezien, is dat tests zelden gebruik maken van Mockito.verifyNoMoreInteractions(). Dit zorgt ervoor dat er geen andere oproepen naar de nep-exemplaren van uw beans zijn gedaan. Als dat het geval is, zal uw test mislukken omdat u waarschijnlijk een when en/of verify statement bent vergeten voor een call naar de mock. Wanneer u tests rechtstreeks met de MockitoExtension uitvoert, kan verificatie worden weggelaten, maar verifyNoMoreInteractions blijft een goede indicator dat uw test alles dekt.

Overzicht

En dus hebben we de meeste nieuwe dingen behandeld in Spring tests, evenals enkele basisprincipes. De voorbeelden gaan over meer testonderwerpen, zoals @DataJpaTest, @Sql en het gebruik van de Mockito-extensie.

Link naar alle cases en extra voorbeelden: https://github.com/FarosBelgium/spring-testing-examples

Share this post