Block Image

Nel precedente articolo (TDD e Unit Test), abbiamo visto come implementare un test unitario sfruttando jUnit 5 e Mockito.
In questo nuovo articolo copriremo invece la parte di Integration Test sfruttando le potenzialità di Spring Boot utilizzando sempre il Test-Driven Development.
Ipotizzeremo di usare un database MySQL per memorizzare gli users (potete seguire le istruzioni riportate qui per creare un container Docker di MySQL: Container Docker MySQL).
Per i test di integrazione invece, useremo H2, un database in-memory. Perché? Un buon test deve essere autoconsistente, deve funzionare per chiunque lo esegua senza vincolare installazioni di database e application server per il corretto funzionamento.

Riprendiamo i requisiti:

«Ci viene chiesto d'implementare delle API REST di findById e create di un model User.
In particolare, nella findById, il client deve ricevere un 200 e nella response body il JSON dello User trovato.
Se non esiste nessun User con l'id dato in input, il client deve ricevere 404 con un body vuoto.
Inoltre il client vede 2 campi dello User: name e address; il name è composto dal cognome, uno spazio e il nome»

Dipendenze Maven da importare

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.3.4.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
...

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
</dependencies>

Primo passo: modifichiamo l'application.properties

spring.datasource.url=jdbc:mysql://localhost:3306/users_DB?useSSL=false&serverTimezone=Europe/Rome
spring.datasource.username=root
spring.datasource.password=root

spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.database-platform=org.hibernate.dialect.MySQL57Dialect

Usiamo la property spring.jpa.hibernate.ddl-auto=create-drop per informare ad Hibernate che sarà lui responsabile della creazione delle tabelle nel database (dobbiamo però creare noi il database users_DB).

Secondo passo: creiamo il controller

@RestController
@RequestMapping("/users")
public class UserController {

    private UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public UserDTO findById(@PathVariable Long id) {
        return null;
    }
}

Inizialmente findById restituirà null; implementeremo il metodo dopo aver creato la classe di test.

Terzo passo: creiamo l'application.properties per i test

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

spring.jpa.hibernate.ddl-auto=create-drop

Spring Boot se trova un file application.properties in src/test/resources, userà quello, altrimenti prenderà in considerazione quello situato in src/main/resources.

Quarto passo: creiamo la classe di test per UserController

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserControllerTest {

    @LocalServerPort
    private int port;

    private String baseUrl = "http://localhost";


    private static RestTemplate restTemplate = null;

    @BeforeAll
    public static void init() {
        restTemplate = new RestTemplate();
    }

    @BeforeEach
    public void setUp() {
        baseUrl = baseUrl.concat(":").concat(port+ "").concat("/users");
    }

    @Test
    @Sql(statements = "INSERT INTO user (id, name, surname, address) VALUES (1, 'Vincenzo', 'Racca', 'via Roma')",
            executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    @Sql(statements = "DELETE FROM user",
            executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    public void returnAPersonWithIdOne() {

        UserDTO userDTOResponse = restTemplate.getForObject(baseUrl.concat("/{id}"), UserDTO.class, 1);
        assertAll(
                () -> assertNotNull(userDTOResponse),
                () -> assertEquals("Racca Vincenzo", userDTOResponse.getName())
        );
    }
}

Analizziamo il codice:

  1. @SpringBootTest ci permette di caricare il contesto di Spring. Inoltre con SpringBootTest.WebEnvironment.RANDOM_PORT diciamo che la porta

del Web Service durante il test deve essere scelta in modo casuale cosicché i test non collidano tra loro.

  1. @LocalServerPort ci permette di iniettare il valore casuale della porta dentro la variabile port.

  2. @BeforeAll ci permette di inizializzare la variabile di tipo RestTemplate (che è un client REST di Spring), prima che vengano eseguiti i metodi della classe di Test.

Quindi nella pratica, quando si eseguiranno tutti i test della classe UserControllerTest, il metodo init verrà chiamato una sola volta. Inoltre @BeforeAll corrisponde all'annotation @BeforeClass di JUnit 4.

  1. @BeforeEach ci permette di inizializzare la variabile baseUrl ogni volta che viene chiamato un metodo della classe di test. Quindi quando eseguiremo tutti i test della classe

UserControllerTest, il metodo setUp verrà chiamato tante volte quanti sono i metodi nella classe. Corrisponde a @Before di Junit 4. 5) @Sql ci permette di effettuare delle operazioni al db di test, come statement sql o anche leggere un file .sql. Come possiamo vedere, si può annotare più volte lo stesso metodo con questa annotation. Nella prima, effettuiamo una INSERT; executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD garantisce il fatto che la statement venga effettuata prima che venga eseguito il metodo. Nella seconda, effettuaiamo una delete dopo l'esecuzione del metodo grazie a executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD.

Analizziamo il test:
Facciamo una chiamata localhost:{port}/users/1 al Web Service utilizzando RestTemplate e ci aspettiamo che il servizio restituisca uno UserDTO con name Racca Vincenzo.
Ovviamente il test fallisce già alla prima asserzione in quanto il servizio restituisce null.

Nota 1: Invece di eseguire una delete dopo l'esecuzione del metodo, un modo più elegente per annullare le modifiche al DB fatte da un metodo di test è annotare il metodo con @Transactional. Infatti Spring, quando trova un test con questa annotazione, esegue automaticamente il rollback delle modifiche quando il metodo termina.

Quinto passo: fixiamo il metodo

@GetMapping("/{id}")
public UserDTO findById(@PathVariable Long id) {
    return userService.findById(id);
}

Il test ora è superato!

Sesto passo: testiamo il metodo con un input non valido

@Test
public void return404() {
    ResponseEntity<String> err = restTemplate.getForEntity
            (baseUrl.concat("/{id}"), String.class, 1);

    assertAll(
            () -> assertNotNull(err),
            () -> assertEquals(HttpStatus.NOT_FOUND, err.getStatusCode()),
            () -> assertNull(err.getBody())
    );
}

Inoltre aggiustiamo l'inizializzazione di RestTemplate in quanto non vogliamo che venga gestito l'error handler custom di questa classe ma piuttosto venga invocato un eventuale handler creato da noi nell'applicazione.
Il test fallisce nella 2 ultime asserzioni:

expected: <404 NOT_FOUND> but was: <500 INTERNAL_SERVER_ERROR>
Comparison Failure: 
Expected :404 NOT_FOUND
Actual   :500 INTERNAL_SERVER_ERROR

expected: <null> but was: <{"timestamp":"2020-10-04T17:21:37.644+00:00","status":500,"error":"Internal Server Error","message":"","path":"/users/1"}>
Comparison Failure: 
Expected :null
Actual   :{"timestamp":"2020-10-04T17:21:37.644+00:00","status":500,"error":"Internal Server Error","message":"","path":"/users/1"}

Il test evidenzia il fatto che attualmente il metodo findById non rispetta questa parte di requisito:
¨Se non esiste nessun User con l'id dato in input, il client deve ricevere 404 con un body vuoto.¨

Settimo passo: fixiamo ancora una volta il metodo

All'interno della classe UserController, scriviamo un metodo che gestisce l'eccezione UserNotFoundException:

@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler({UserNotFoundException.class})
public void handleNotFound() {
    // just return empty 404
}

Con questo metodo stiamo dicendo che ogni servizio che lancia l'eccezione UserNotFoundException, restituisca un HttpStatus 404 con un body a null. Rieseguiamo il test, questa volta è superato!

Nota 2: Un modo più elegante di gestire le eccezioni in un Web Service REST è quello di creare una classe apposita.

Conclusioni

Abbiamo visto come effettuare un Integration Test sfruttando Spring Boot oltre a JUnit 5 e H2, continuando ad utilizzare il metodo del Test-Driven Development.

Potete trovare il progetto completo con anche il test sulla create e un Integration Test sul Service al mio github: Spring Test

Precedente articolo sugli Unit Test: TDD e Unit Test

Articoli su Spring: Spring

Libri consigliati su Spring:

  • Pro Spring 5 (Spring da zero a hero): https://amzn.to/3KvfWWO
  • Pivotal Certified Professional Core Spring 5 Developer Exam: A Study Guide Using Spring Framework 5 (per certificazione Spring): https://amzn.to/3KxbJSC
  • Pro Spring Boot 2: An Authoritative Guide to Building Microservices, Web and Enterprise Applications, and Best Practices (Spring Boot del dettaglio): https://amzn.to/3TrIZic