Aller au contenu

Tests en Java

L'écosystème de test Java est l'un des plus matures de l'industrie. JUnit 5 est le standard actuel pour les tests unitaires, Mockito pour le mocking, et Testcontainers pour les tests d'intégration avec de vraies bases de données. Cette section couvre les différentes couches de test d'une application Spring Boot 3, avec des exemples bases sur les classes du chapitre 03.


Comparatif des outils de test

Outil Type Points forts Usage typique
JUnit 5 Framework de base Parametrize, extensions, annotations expressives Tous types de tests
Mockito Mocking API fluide, vérification des interactions, @MockBean Isolation des dépendances
Testcontainers Intégration Vrais services Docker (PG, Kafka, Redis), reproductible Tests d'intégration fiables
ArchUnit Architecture Vérifié les règles de packaging, dépendances cycliques Gouvernance architecture en CI
AssertJ Assertions API fluide et lisible, messages d'erreur clairs Remplacement de Hamcrest / assertions JUnit
JaCoCo Couverture Rapport HTML, intégration Maven/Gradle, seuils CI Mesure et enforcement de couverture

Configuration des dépendances (pom.xml)

<dependencies>
    <!-- Spring Boot Starter Test inclut JUnit 5, Mockito, AssertJ, Hamcrest -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Testcontainers — base PostgreSQL pour les tests d'integration -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Tests unitaires du contrôleur avec @WebMvcTest

@WebMvcTest charge uniquement la couche web (contrôleurs, filtres, serialisation JSON). Les beans de service sont exclus et doivent être mockes avec @MockBean.

package com.example.item;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

// Charge uniquement le contexte web — rapide (pas de BDD)
@WebMvcTest(ItemController.class)
class ItemControllerTest {

    @Autowired
    private MockMvc mockMvc;  // Client HTTP en memoire

    @MockBean
    private ItemService service;  // Service mocke par Mockito

    @Test
    @DisplayName("GET /api/items retourne la liste des items en JSON")
    void listerTous_retourneListeJson() throws Exception {
        // Arrange — configure le mock
        Item item = new Item("Clavier", "Clavier mecanique", 89.99);
        when(service.listerTous()).thenReturn(List.of(item));

        // Act + Assert — requete HTTP simulee et assertions
        mockMvc.perform(get("/api/items")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$[0].nom").value("Clavier"))
            .andExpect(jsonPath("$[0].prix").value(89.99));

        verify(service, times(1)).listerTous();
    }

    @Test
    @DisplayName("GET /api/items/{id} retourne 404 si item inexistant")
    void trouverParId_retourne404SiInexistant() throws Exception {
        // Arrange — le service leve une exception
        when(service.trouverParId(99L))
            .thenThrow(new ItemNotFoundException(99L));

        // Act + Assert
        mockMvc.perform(get("/api/items/99"))
            .andExpect(status().isNotFound());
    }

    @Test
    @DisplayName("POST /api/items cree un item et retourne 201")
    void creer_retourne201AvecItemCree() throws Exception {
        // Arrange
        Item itemCree = new Item("Souris", "Souris sans fil", 45.0);
        when(service.creer(any(ItemDto.class))).thenReturn(itemCree);

        String corpsJson = """
            {
                "nom": "Souris",
                "description": "Souris sans fil",
                "prix": 45.0
            }
            """;

        // Act + Assert
        mockMvc.perform(post("/api/items")
                .contentType(MediaType.APPLICATION_JSON)
                .content(corpsJson))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.nom").value("Souris"));
    }

    @Test
    @DisplayName("POST /api/items retourne 400 si validation echoue")
    void creer_retourne400SiNomVide() throws Exception {
        String corpsInvalide = """
            {
                "nom": "",
                "prix": 10.0
            }
            """;

        mockMvc.perform(post("/api/items")
                .contentType(MediaType.APPLICATION_JSON)
                .content(corpsInvalide))
            .andExpect(status().isBadRequest());

        // Le service ne doit jamais etre appele si la validation echoue
        verifyNoInteractions(service);
    }

    @Test
    @DisplayName("DELETE /api/items/{id} retourne 204")
    void supprimer_retourne204() throws Exception {
        doNothing().when(service).supprimer(1L);

        mockMvc.perform(delete("/api/items/1"))
            .andExpect(status().isNoContent());

        verify(service).supprimer(1L);
    }
}

Tests de service avec Mockito pur

package com.example.item;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

// MockitoExtension initialise les mocks sans Spring
@ExtendWith(MockitoExtension.class)
class ItemServiceTest {

    @Mock
    private ItemRepository repository;

    @InjectMocks  // Injecte les mocks dans le service
    private ItemService service;

    @Test
    void trouverParId_retourneItem_siExiste() {
        // Arrange
        Item item = new Item("Ecran", "27 pouces 4K", 399.0);
        when(repository.findById(1L)).thenReturn(Optional.of(item));

        // Act
        Item resultat = service.trouverParId(1L);

        // Assert — AssertJ : assertions fluides et lisibles
        assertThat(resultat.getNom()).isEqualTo("Ecran");
        assertThat(resultat.getPrix()).isEqualTo(399.0);
    }

    @Test
    void trouverParId_leveException_siInexistant() {
        when(repository.findById(99L)).thenReturn(Optional.empty());

        // AssertJ : verification que l'exception est bien levee
        assertThatThrownBy(() -> service.trouverParId(99L))
            .isInstanceOf(ItemNotFoundException.class)
            .hasMessageContaining("99");
    }
}

Tests d'intégration avec Testcontainers

Testcontainers démarré de vrais conteneurs Docker pendant les tests, garantissant une fidelite maximale par rapport à la production.

package com.example;

import com.example.item.ItemDto;
import com.example.item.ItemRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.assertj.core.api.Assertions.assertThat;

// @SpringBootTest charge le contexte Spring complet
// RANDOM_PORT demarre un vrai serveur HTTP sur un port aleatoire
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class ItemIntegrationTest {

    // @ServiceConnection configure automatiquement le datasource Spring
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private ItemRepository repository;

    @BeforeEach
    void nettoyerBase() {
        repository.deleteAll();
    }

    @Test
    void creerEtListerItems_scenario_complet() {
        // Creer un item via l'API HTTP
        ItemDto dto = new ItemDto(null, "Casque audio", "Casque Bluetooth", 129.0);
        ResponseEntity<Object> reponseCreation =
            restTemplate.postForEntity("/api/items", dto, Object.class);

        assertThat(reponseCreation.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        // Verifier qu'il apparait dans la liste
        ResponseEntity<Object[]> reponseListe =
            restTemplate.getForEntity("/api/items", Object[].class);

        assertThat(reponseListe.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(reponseListe.getBody()).hasSize(1);
    }

    @Test
    void trouverParId_retourne404_siInexistant() {
        ResponseEntity<Object> reponse =
            restTemplate.getForEntity("/api/items/9999", Object.class);

        assertThat(reponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
    }
}

Configuration JaCoCo

JaCoCo mesure la couverture de code et peut faire échouer le build si les seuils ne sont pas atteints.

<!-- pom.xml — plugin JaCoCo -->
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <!-- Initialise l'agent JaCoCo avant les tests -->
        <execution>
            <id>prepare-agent</id>
            <goals><goal>prepare-agent</goal></goals>
        </execution>

        <!-- Genere le rapport HTML apres les tests -->
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>

        <!-- Verifie les seuils de couverture — echec du build si non atteints -->
        <execution>
            <id>check</id>
            <goals><goal>check</goal></goals>
            <configuration>
                <rules>
                    <rule>
                        <element>BUNDLE</element>
                        <limits>
                            <!-- 80% des lignes doivent etre couvertes -->
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                            <!-- 70% des branches (if/else, switch) -->
                            <limit>
                                <counter>BRANCH</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.70</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>
# Generer le rapport de couverture
mvn test jacoco:report

# Rapport disponible dans : target/site/jacoco/index.html

# Verifier les seuils sans generer le rapport
mvn verify

Exclure des classes de la couverture

Certaines classes n'ont pas besoin d'être couvertes : classes de configuration, entités JPA, DTOs. Excluez-les dans la configuration JaCoCo pour ne pas fausser les métriques.

<configuration>
    <excludes>
        <exclude>com/example/config/**</exclude>
        <exclude>com/example/**/*Dto.class</exclude>
        <exclude>com/example/DemoApplication.class</exclude>
    </excludes>
</configuration>