Come si esegue un unit test di una classe @Controller

Bassem 24/05


credit: Markus Spiske on Unsplash

In questo articolo, vediamo come eseguire un unit test di una classe annotata con @Controller. Ciò significa che la isoliamo dal contesto di Spring.
Normalmente quando testiamo la nostra applicazione con @SpringBootTest, stiamo eseguendo un test di integrazione, poiché stiamo utilizzando lo "SpringBootContextLoader" come "ContextLoader" predefinito, che di conseguenza inizializza tutti i bean nella nostra applicazione.

Vale la pena ricordare che il framework Spring fornisce anche altre annotazioni, che ci consentono di suddividere il contesto. Per esempio@WebMvcTest per testare il livello Web, dove possiamo scegliere un controller specifico da controllare,@DataJpaTest per testare JPA e il livello base dati, è possibile trovare l'elenco completo qui.

Quindi, per prima cosa, facciamo un RestController che presenta il seguente oggetto Post:

public class Post {

    private String id;
    private String postTitle;
    private String body;

    public Post (String postTitle, String body) {
        this.id = UUID.randomUUID().toString();
        this.postTitle = postTitle;
        this.body = body;
    }
    // getters and setters
}

Niente di speciale, abbiamo tre proprietà private e stiamo simulando l'id generato con un UUID casuale.

Successivamente abbiamo il nostro controller:


@RestController
public class PostController {
   
    @GetMapping("/post/{random}")
    public Post getPost(@PathVariable Integer random) {
        return new Post("postTitle "+ random, "some body");
    }


}

La nostra classe è annotata con @RestController, che ci permette di convertire il post generato in una "response" automaticamente, ciò accade perché l'annotazione stessa è annotata con @ResponseBody.
Quindi abbiamo il nostro metodo, in cui restituiamo un nuovo post con un titolo concatenato con un numero scambiato tramite il @PathVariable. Infine, il punto rest è esposto mediante un "get" usando l'annotazione @GetMapping.
Ora vediamo il nostro test:

@ExtendWith(SpringExtension.class)
class UnitestcontrollerApplicationTests {

    private static MockMvc mockMvc;

    @BeforeAll
    static void init () {
        mockMvc = MockMvcBuilders.standaloneSetup(PostController.class).build();
    }

    @Test
    void testController() throws Exception {
        mockMvc.perform(get("/post/1")).andDo(print()).andExpect(status().isOk());
    }
    
    @Test
    void shouldGive405ForPost() throws Exception {
        mockMvc.perform(post("/post/1")).andDo(print()).andExpect(status().isMethodNotAllowed());
    }

}

La nostra classe di test è annotata con @ExtendWith (SpringExtension.class), in questo modo stiamo integrando "Spring TestContext Framework" in "JUnit 5", quindi stiamo usando l'ovveride di alcuni metodi junit come beforAll, afterAll, ecc.
Inoltre cosi facendo, possiamo usare l'annotazione @MockBean per aggiungere "mock" al nostro contesto e inoltre otteniamo anche l'accesso ad ApplicationContext associato al nostro test. Infatti se dessimo un'occhiata ai messaggi di log durante il test, potremmo notare che il nostro array contextInitializerClasses è vuoto:

[main] DEBUG org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate - Storing ApplicationContext [1808884231] in cache under key [[MergedContextConfiguration@510f3d34 testClass = UnitestcontrollerApplicationTests, locations = '{}', classes = '{}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{}', 

Per ulteriori informazioni sulla memorizzazione nella cache del contesto dell'applicazione, potete dare un'occhiata ai documenti ufficiali qui.

Successivamente abbiamo l'oggetto MockMvc, lo strumento principale per testare la nostra classe di controller, è una variabile statica, quindi la inizializziamo nel metodo @BeforeAll:

private static MockMvc mockMvc;

@BeforeAll
static void init () {
        mockMvc = MockMvcBuilders.standaloneSetup(PostController.class).build();
    }

Stiamo realizzando una build personalizzata per il nostro oggetto mockMvc con MockMvcBuilders, per cui il test viene avviato solo con l'infrastruttura minima richiesta e stiamo testando un solo controller (può accettare anche un array).

Diamo ora un'occhiata ai nostri test:

@Test
    void testController() throws Exception {
        mockMvc.perform(get("/post/1")).andDo(print()).andExpect(status().isOk());
    }
    
    @Test
    void shouldGive405ForPost() throws Exception {
        mockMvc.perform(post("/post/1")).andDo(print()).andExpect(status().isMethodNotAllowed());
    }

Niente di particolare, ci stiamo assicurando che il nostro end point stia funzionando con il primo test e che accetti solo richieste di tipo "Get" con il secondo.

Ora introduciamo una complicazione al nostro controller iniettando un servizio, che è responsabile della generazione del nuovo post, il risultato è il seguente:

@RestController
public class PostController {

    private final PostService service;

    public PostController(PostService service) {
        this.service = service;
    }
    @GetMapping("/post/{random}")
    public Post getPost(@PathVariable Integer random) {
        return service.getAPost(random);
    }
}

Di conseguenza dobbiamo cambiare il nostro test per integrare la nuova modifica, come segue:

@ExtendWith(SpringExtension.class)
class UnitestcontrollerApplicationTests {

    private static MockMvc mockMvc;
    private static PostService service = mock(PostService.class);

    @BeforeAll
    static void init () {
        mockMvc = MockMvcBuilders.standaloneSetup(new PostController(service)).build();
    }

    @Test
    void testController() throws Exception {
        when(service.getAPost(1)).thenReturn(new Post("postTitle", "body"));
        mockMvc.perform(get("/post/1")).andDo(print()).andExpect(status().isOk());
    }
    
    @Test
    void shouldGive405ForPost() throws Exception {
        when(service.getAPost(1)).thenReturn(new Post("postTitle", "body"));
        mockMvc.perform(post("/post/1")).andDo(print()).andExpect(status().isMethodNotAllowed());
    }

}

Come possiamo vedere, "moccato" il postService usando il metodo statico mock () di Mockito, potevamo anche usare @MockBean, poiché stiamo usando SpringExtension.class. Quindi abbiamo costruito il nostro oggetto controller usando il mock appena creato.Inoltre in ogni test stiamo simulando la risposta usando l'istruzione when(..).ThenReturn(..) di Mockito.

Infine, se volessimo eliminare tutte le dipendenze di Spring e usare solo Mockito (come nostro test), dovremmo rimuovere solo l'annotazione @ExtendWith (SpringExtension.class). In questo modo abbiamo un vero test junit.

Tutto qua, il codice completo può essere trovato su GitHub.