Développement Backend Robuste avec Java et Spring Boot
Développement Backend Robuste avec Java et Spring Boot

Tests Unitaires et d'Intégration avec Spring Boot

Dans le cadre du cours "Développement Backend Robuste avec Java et Spring Boot", la capacité à garantir la fiabilité et la maintenabilité de vos applications est primordiale. Les tests, qu'ils soient unitaires ou d'intégration, constituent le pilier de cette robustesse. Cette leçon vous guidera à travers les concepts, les outils et les bonnes pratiques pour implémenter des stratégies de test efficaces au sein de vos projets Spring Boot.

Introduction : L'Indispensable du Test Logiciel

Le développement de logiciels de qualité ne se limite pas à l'écriture de code fonctionnel ; il implique également de s'assurer que ce code est correct, fiable et résistant aux changements. C'est là que les tests entrent en jeu. Ils agissent comme un filet de sécurité, permettant aux développeurs de modifier, d'étendre ou de refactoriser le code avec confiance, sachant que toute régression sera rapidement détectée.

Avec Spring Boot, qui encourage une architecture microservices et des applications auto-contenues, la capacité à tester chaque composant isolément, puis à vérifier leurs interactions, est plus critique que jamais.

Nous allons explorer deux catégories de tests fondamentales pour nos applications Spring Boot :

  • Les Tests Unitaires : Axés sur l'isolation et la vérification des plus petites unités de code.
  • Les Tests d'Intégration : Axés sur la vérification des interactions entre différents composants et avec des systèmes externes.

1. Les Fondamentaux du Test

Pourquoi Tester ?

Les bénéfices des tests sont multiples et touchent tous les aspects du cycle de vie du développement logiciel :

  • Qualité et Fiabilité : Les tests identifient les bugs et les erreurs tôt dans le processus de développement, réduisant ainsi les coûts de correction et améliorant la qualité globale du produit.
  • Maintenance Simplifiée : Un jeu de tests robuste sert de documentation vivante du comportement attendu du système. Il facilite la compréhension du code existant pour les nouveaux développeurs et pour les futures évolutions.
  • Refactoring en Toute Confiance : Avec des tests en place, vous pouvez refactoriser votre code (améliorer sa structure sans changer son comportement externe) sans craindre d'introduire de nouvelles régressions.
  • Détection Précoce des Régressions : Chaque fois qu'une nouvelle fonctionnalité est ajoutée ou qu'une modification est apportée, les tests existants peuvent être exécutés pour s'assurer que les fonctionnalités précédemment implémentées fonctionnent toujours comme prévu.
  • Conception Améliorée : Écrire des tests peut révéler des défauts de conception dans le code, encourageant à écrire des composants plus modulaires et testables (principe du Test-Driven Development - TDD).

Types de Tests

Bien qu'il existe de nombreux types de tests (performance, sécurité, E2E, etc.), nous nous concentrerons sur les deux plus couramment utilisés pour le développement backend :

Tests Unitaires

  • Définition : Les tests unitaires vérifient la plus petite unité de code isolément. Une "unité" est généralement une méthode individuelle, une classe ou un petit ensemble de classes collaborant étroitement, sans dépendances externes réelles (base de données, services web, système de fichiers).
  • Objectif : Valider la logique métier interne d'un composant spécifique.
  • Caractéristiques :
    • Isolation : Chaque test doit être indépendant des autres et ne pas interagir avec des ressources externes.
    • Rapidité : Ils doivent s'exécuter très rapidement, permettant un feedback immédiat au développeur.
    • Répétabilité : Un test doit produire le même résultat à chaque exécution, quelles que soient les conditions extérieures.
  • Outils clés : JUnit 5 (framework de test), Mockito (pour simuler/mocker les dépendances).

Tests d'Intégration

  • Définition : Les tests d'intégration vérifient la collaboration entre plusieurs composants d'un système ou entre le système et des services externes (base de données, API REST, file d'attente de messages). Ils ne sont pas isolés au sens strict du terme ; ils font appel à un sous-ensemble du système réel.
  • Objectif : S'assurer que les différentes parties de l'application fonctionnent ensemble comme prévu et que les interactions avec les systèmes externes sont correctes.
  • Caractéristiques :
    • Dépendances réelles (ou quasi-réelles) : Ils peuvent démarrer une partie du contexte Spring, interagir avec une base de données embarquée ou un conteneur Dockerisé.
    • Lenteur relative : Généralement plus lents que les tests unitaires car ils impliquent plus de composants et potentiellement des I/O.
    • Couverture du flux : Vérifient des flux de données et des comportements de bout en bout pour un sous-système.
  • Outils clés : Spring Boot Test (@SpringBootTest, @WebMvcTest, @DataJpaTest), Testcontainers (pour des bases de données ou services externes réels dans des conteneurs Docker), MockMvc / WebTestClient (pour tester les API REST).

2. Tests Unitaires avec Spring Boot

Les tests unitaires sont la première ligne de défense de votre code. Pour les applications Spring Boot, cela signifie souvent tester les services, les utilitaires et parfois les composants de la couche de persistance avec des dépendances mockées.

Principes des Tests Unitaires

  • FAST : Acronyme pour Fast, Isolated, Repeatable, Self-validating, Timely.
    • Fast : Rapides à exécuter.
    • Isolated : Indépendants les uns des autres et des ressources externes.
    • Repeatable : Produisent le même résultat à chaque exécution.
    • Self-validating : Le résultat est un simple "réussite" ou "échec".
    • Timely : Écrits avant ou en même temps que le code qu'ils testent.
  • Focalisation : Chaque test se concentre sur une seule assertion ou un petit groupe d'assertions liées à un comportement spécifique.

Outils Clés

  • JUnit 5 : Le framework de test standard en Java. Il fournit des annotations comme @Test, @BeforeEach, @DisplayName, et des assertions comme Assertions.assertEquals(), Assertions.assertTrue().
  • Mockito : Une bibliothèque de mocking très populaire. Elle permet de créer des objets mocks ou stubs pour les dépendances d'une classe que vous testez. Cela garantit que votre test porte uniquement sur la logique de l'unité que vous testez, et non sur le comportement de ses dépendances.

Exemple Pratique : Test d'un Service

Imaginons un service simple pour gérer des produits. Ce service dépendra d'un repository pour interagir avec la base de données. Pour tester ce service de manière unitaire, nous allons mocker le repository.

Code du Service à Tester

// src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    // Constructeur par défaut requis par JPA
    public Product() {}

    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }

    // Getters et Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
}
// src/main/java/com/example/demo/product/ProductRepository.java
package com.example.demo.product;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    Optional<Product> findByName(String name);
}
// src/main/java/com/example/demo/product/ProductService.java
package com.example.demo.product;

import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public List<Product> getAllProducts() {
        return productRepository.findAll();
    }

    public Optional<Product> getProductById(Long id) {
        return productRepository.findById(id);
    }

    public Product createProduct(Product product) {
        // Logique métier : s'assurer qu'un produit avec le même nom n'existe pas déjà
        if (productRepository.findByName(product.getName()).isPresent()) {
            throw new IllegalArgumentException("Product with name '" + product.getName() + "' already exists.");
        }
        return productRepository.save(product);
    }

    public Product updateProduct(Long id, Product productDetails) {
        Product existingProduct = productRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("Product not found with id: " + id));

        existingProduct.setName(productDetails.getName());
        existingProduct.setPrice(productDetails.getPrice());
        return productRepository.save(existingProduct);
    }

    public void deleteProduct(Long id) {
        if (!productRepository.existsById(id)) {
            throw new IllegalArgumentException("Product not found with id: " + id);
        }
        productRepository.deleteById(id);
    }
}

Code du Test Unitaire du Service

// src/test/java/com/example/demo/product/ProductServiceUnitTest.java
package com.example.demo.product;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.Arrays;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class) // Active l'extension Mockito pour JUnit 5
class ProductServiceUnitTest {

    @Mock // Crée un mock du ProductRepository
    private ProductRepository productRepository;

    @InjectMocks // Injecte le mock productRepository dans une instance de ProductService
    private ProductService productService;

    private Product product1;
    private Product product2;

    @BeforeEach // Exécuté avant chaque méthode de test
    void setUp() {
        // Initialise les objets Product pour les tests
        product1 = new Product("Laptop", 1200.0);
        product1.setId(1L);
        product2 = new Product("Mouse", 25.0);
        product2.setId(2L);
    }

    @Test
    @DisplayName("Devrait récupérer tous les produits")
    void shouldGetAllProducts() {
        // GIVEN : Définir le comportement du mock
        when(productRepository.findAll()).thenReturn(Arrays.asList(product1, product2));

        // WHEN : Appeler la méthode à tester
        List<Product> products = productService.getAllProducts();

        // THEN : Vérifier le résultat et les interactions avec le mock
        assertNotNull(products);
        assertEquals(2, products.size());
        assertEquals("Laptop", products.get(0).getName());
        verify(productRepository, times(1)).findAll(); // Vérifie que findAll a été appelé une fois
    }

    @Test
    @DisplayName("Devrait récupérer un produit par son ID")
    void shouldGetProductById() {
        // GIVEN
        when(productRepository.findById(1L)).thenReturn(Optional.of(product1));

        // WHEN
        Optional<Product> foundProduct = productService.getProductById(1L);

        // THEN
        assertTrue(foundProduct.isPresent());
        assertEquals("Laptop", foundProduct.get().getName());
        verify(productRepository, times(1)).findById(1L);
    }

    @Test
    @DisplayName("Devrait créer un nouveau produit")
    void shouldCreateProduct() {
        // GIVEN
        Product newProduct = new Product("Keyboard", 75.0);
        // Simule que le produit n'existe pas encore par son nom
        when(productRepository.findByName(newProduct.getName())).thenReturn(Optional.empty());
        // Simule la sauvegarde réussie
        when(productRepository.save(any(Product.class))).thenReturn(newProduct);

        // WHEN
        Product createdProduct = productService.createProduct(newProduct);

        // THEN
        assertNotNull(createdProduct);
        assertEquals("Keyboard", createdProduct.getName());
        // Vérifie les appels aux méthodes du mock
        verify(productRepository, times(1)).findByName(newProduct.getName());
        verify(productRepository, times(1)).save(newProduct);
    }

    @Test
    @DisplayName("Ne devrait pas créer un produit si le nom existe déjà")
    void shouldNotCreateProductIfNameExists() {
        // GIVEN
        Product existingProduct = new Product("Laptop", 1200.0);
        when(productRepository.findByName(existingProduct.getName())).thenReturn(Optional.of(product1));

        // WHEN & THEN
        // Vérifie qu'une exception est levée
        IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, () -> {
            productService.createProduct(existingProduct);
        });
        assertEquals("Product with name 'Laptop' already exists.", thrown.getMessage());
        // Vérifie que save() n'a pas été appelé
        verify(productRepository, never()).save(any(Product.class));
    }
}

Explication du Code

  • @ExtendWith(MockitoExtension.class) : Cette annotation JUnit 5 intègre Mockito dans le cycle de vie des tests, permettant d'utiliser les annotations @Mock et @InjectMocks.
  • @Mock private ProductRepository productRepository; : Déclare un mock de l'interface ProductRepository. Mockito créera une implémentation simulée de cette interface.
  • @InjectMocks private ProductService productService; : Demande à Mockito d'injecter automatiquement les mocks déclarés (ici productRepository) dans l'instance de ProductService. C'est une commodité pour éviter l'injection manuelle dans le constructeur.
  • @BeforeEach : La méthode setUp() est exécutée avant chaque test. C'est idéal pour initialiser des objets ou des données de test communes à plusieurs tests.
  • when(mock.method()).thenReturn(value); : C'est le cœur de Mockito. Il définit le comportement attendu du mock. Ici, nous disons que lorsque productRepository.findAll() est appelé, il doit retourner une liste spécifique de produits.
  • verify(mock, times(n)).method(); : Permet de vérifier que certaines méthodes du mock ont été appelées un certain nombre de fois. times(1) signifie une fois, never() signifie jamais. any(Product.class) est un argument matcher qui correspond à n'importe quelle instance de Product.
  • Assertions.assertEquals(), Assertions.assertTrue(), Assertions.assertThrows() : Ce sont les méthodes d'assertion de JUnit 5 pour vérifier les résultats des appels de méthode.
  • @DisplayName : Permet de donner un nom plus lisible au test dans les rapports JUnit.

Ce test unitaire garantit que la logique métier de ProductService fonctionne correctement, indépendamment de la complexité ou du fonctionnement réel de ProductRepository.

3. Tests d'Intégration avec Spring Boot

Les tests d'intégration vérifient que les différentes couches et composants de votre application (contrôleurs, services, repositories) fonctionnent bien ensemble, et que l'intégration avec des ressources externes (comme une base de données) est correcte. Spring Boot offre des outils puissants pour faciliter ces tests.

Principes des Tests d'Intégration

  • Chargement du Contexte Spring : Pour simuler un environnement d'exécution réel, les tests d'intégration Spring Boot chargent une partie ou la totalité du contexte d'application Spring.
  • Interaction Réelle (ou simulée) : Ils interagissent souvent avec une base de données réelle (souvent en mémoire comme H2, ou via Testcontainers) ou simulent des appels HTTP.
  • Granularité : Ils sont moins granulaires que les tests unitaires, couvrant un flux de travail plus large.

Outils Clés

  • @SpringBootTest : Annotation principale pour les tests d'intégration. Elle démarre le contexte d'application Spring Boot complet (ou une partie configurable). C'est l'outil le plus lourd, mais aussi le plus complet.
    • webEnvironment : Permet de configurer l'environnement web (par exemple, WebEnvironment.RANDOM_PORT pour démarrer un serveur web sur un port aléatoire).
  • @Autowired : Permet d'injecter des beans depuis le contexte Spring chargé, directement dans la classe de test.
  • TestRestTemplate / WebTestClient :
    • TestRestTemplate : Un client REST synchrone pour tester les API HTTP. Plus simple pour les cas d'usage basiques.
    • WebTestClient : Un client REST réactif et non bloquant, idéal pour les applications utilisant Spring WebFlux ou pour des tests plus avancés sur Spring MVC (via MockMvc). Il peut être utilisé avec MockMvc pour tester les contrôleurs sans démarrer un serveur HTTP réel.
  • @WebMvcTest : Une annotation plus légère que @SpringBootTest qui se concentre sur les tests de la couche web (contrôleurs). Elle ne charge que les composants liés à Spring MVC. Les autres beans (services, repositories) doivent être mockés.
  • @DataJpaTest : Une annotation spécialisée pour tester la couche de persistance JPA. Elle configure une base de données embarquée (comme H2) et ne charge que les composants liés à JPA (repositories). Les autres beans doivent être mockés ou ne sont pas chargés.
  • Testcontainers : Une bibliothèque Java qui permet de démarrer des conteneurs Docker (bases de données, files de messages, services tiers) de manière programmatique pour les tests d'intégration. Cela permet de tester contre des environnements "réels" sans les inconvénients de leur gestion manuelle.

Exemple Pratique : Test d'un Contrôleur REST et de la Couche de Persistance

Nous allons étendre notre exemple précédent avec un contrôleur REST et des tests d'intégration pour celui-ci, ainsi qu'un test pour la couche de persistance.

Code du Contrôleur REST à Tester

// src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public ResponseEntity<List<Product>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> getProductById(@PathVariable Long id) {
        return productService.getProductById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        try {
            Product createdProduct = productService.createProduct(product);
            return new ResponseEntity<>(createdProduct, HttpStatus.CREATED);
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body(null); // Gérer les erreurs de validation
        }
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(@PathVariable Long id, @RequestBody Product product) {
        try {
            Product updatedProduct = productService.updateProduct(id, product);
            return ResponseEntity.ok(updatedProduct);
        } catch (IllegalArgumentException e) {
            return ResponseEntity.notFound().build(); // Ou badRequest si validation
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        try {
            productService.deleteProduct(id);
            return ResponseEntity.noContent().build();
        } catch (IllegalArgumentException e) {
            return ResponseEntity.notFound().build();
        }
    }
}

Test d'Intégration de la Couche de Persistance avec @DataJpaTest

Ce test vérifie le bon fonctionnement du ProductRepository avec une base de données réelle (ici, H2 en mémoire, configurée automatiquement par @DataJpaTest).

// src/test/java/com/example/demo/product/ProductRepositoryIntegrationTest.java
package com.example.demo.product;

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.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat; // Plus expressif que JUnit pour certaines assertions

@DataJpaTest // Charge uniquement les composants JPA et configure une DB embarquée
class ProductRepositoryIntegrationTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager entityManager; // Utile pour insérer des données directement dans la DB de test

    @Test
    @DisplayName("Devrait sauvegarder et trouver un produit par son ID")
    void shouldSaveAndFindProductById() {
        // GIVEN
        Product product = new Product("Smartphone", 800.0);
        entityManager.persist(product); // Sauvegarde directement via l'entityManager pour s'assurer que c'est en DB
        entityManager.flush(); // Force la synchronisation avec la DB

        // WHEN
        Optional<Product> foundProduct = productRepository.findById(product.getId());

        // THEN
        assertThat(foundProduct).isPresent();
        assertThat(foundProduct.get().getName()).isEqualTo("Smartphone");
    }

    @Test
    @DisplayName("Devrait trouver un produit par son nom")
    void shouldFindProductByName() {
        // GIVEN
        Product product = new Product("Tablet", 500.0);
        entityManager.persist(product);
        entityManager.flush();

        // WHEN
        Optional<Product> foundProduct = productRepository.findByName("Tablet");

        // THEN
        assertThat(foundProduct).isPresent();
        assertThat(foundProduct.get().getPrice()).isEqualTo(500.0);
    }

    @Test
    @DisplayName("Devrait supprimer un produit")
    void shouldDeleteProduct() {
        // GIVEN
        Product product = new Product("Monitor", 300.0);
        entityManager.persist(product);
        entityManager.flush();

        // WHEN
        productRepository.deleteById(product.getId());

        // THEN
        Optional<Product> deletedProduct = productRepository.findById(product.getId());
        assertThat(deletedProduct).isNotPresent();
    }
}

Explication du Code ProductRepositoryIntegrationTest

  • @DataJpaTest : Cette annotation configure un environnement de test pour les composants JPA. Elle :
    • Scan les classes @Entity.
    • Configure une source de données H2 en mémoire.
    • Configure un EntityManager et les JpaRepository pour les tests.
    • Par défaut, les transactions sont rollbackées après chaque test, assurant l'isolation.
  • @Autowired ProductRepository productRepository; : Le ProductRepository est injecté depuis le contexte de test de JPA.
  • @Autowired TestEntityManager entityManager; : Cet objet est fourni par Spring Test et permet d'interagir directement avec la base de données de test, par exemple pour insérer des données avant un test, sans passer par les méthodes du repository.

Test d'Intégration du Contrôleur REST avec @WebMvcTest et MockMvc

Ce test se concentre sur la couche web, en mockant les dépendances de la couche service.

// src/test/java/com/example/demo/product/ProductControllerIntegrationTest.java
package com.example.demo.product;

import com.fasterxml.jackson.databind.ObjectMapper;
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.Arrays;
import java.util.Optional;

import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(ProductController.class) // Ne charge que le contexte Spring MVC, en ciblant ProductController
class ProductControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc; // Objet pour simuler des requêtes HTTP

    @MockBean // Crée un mock et l'ajoute au contexte Spring, remplaçant la vraie instance de ProductService
    private ProductService productService;

    @Autowired
    private ObjectMapper objectMapper; // Utile pour convertir des objets Java en JSON

    @Test
    @DisplayName("Devrait récupérer tous les produits via GET /api/products")
    void shouldGetAllProducts() throws Exception {
        Product product1 = new Product("Laptop", 1200.0);
        product1.setId(1L);
        Product product2 = new Product("Mouse", 25.0);
        product2.setId(2L);

        // GIVEN: Le service retourne une liste de produits
        when(productService.getAllProducts()).thenReturn(Arrays.asList(product1, product2));

        // WHEN & THEN: Exécuter la requête GET et vérifier la réponse
        mockMvc.perform(get("/api/products")
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk()) // Attendre un statut HTTP 200 OK
                .andExpect(jsonPath("$", hasSize(2))) // Attendre un tableau JSON de taille 2
                .andExpect(jsonPath("$[0].name", is("Laptop"))); // Vérifier le nom du premier produit

        verify(productService, times(1)).getAllProducts(); // Vérifier que le service a bien été appelé
    }

    @Test
    @DisplayName("Devrait créer un nouveau produit via POST /api/products")
    void shouldCreateProduct() throws Exception {
        Product newProduct = new Product("Keyboard", 75.0);
        Product createdProduct = new Product("Keyboard", 75.0);
        createdProduct.setId(3L);

        // GIVEN
        when(productService.createProduct(any(Product.class))).thenReturn(createdProduct);

        // WHEN & THEN
        mockMvc.perform(post("/api/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(newProduct))) // Convertit l'objet en JSON
                .andExpect(status().isCreated()) // Attendre un statut HTTP 201 Created
                .andExpect(jsonPath("$.id", is(3)))
                .andExpect(jsonPath("$.name", is("Keyboard")));

        verify(productService, times(1)).createProduct(any(Product.class));
    }

    @Test
    @DisplayName("Devrait retourner 400 Bad Request si le produit existe déjà lors de la création")
    void shouldReturnBadRequestIfProductExistsOnCreation() throws Exception {
        Product existingProduct = new Product("Laptop", 1200.0);

        // GIVEN
        when(productService.createProduct(any(Product.class)))
                .thenThrow(new IllegalArgumentException("Product with name 'Laptop' already exists."));

        // WHEN & THEN
        mockMvc.perform(post("/api/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(existingProduct)))
                .andExpect(status().isBadRequest()); // Attendre un statut HTTP 400 Bad Request

        verify(productService, times(1)).createProduct(any(Product.class));
    }

    @Test
    @DisplayName("Devrait supprimer un produit via DELETE /api/products/{id}")
    void shouldDeleteProduct() throws Exception {
        // GIVEN: Aucun retour nécessaire pour void, mais on vérifie l'appel
        doNothing().when(productService).deleteProduct(1L);

        // WHEN & THEN
        mockMvc.perform(delete("/api/products/{id}", 1L))
                .andExpect(status().isNoContent()); // Attendre un statut HTTP 204 No Content

        verify(productService, times(1)).deleteProduct(1L);
    }
}

Explication du Code ProductControllerIntegrationTest

  • @WebMvcTest(ProductController.class) : Cette annotation est plus légère que @SpringBootTest. Elle ne charge que les composants nécessaires pour tester la couche web, en particulier ProductController. Les autres beans (comme ProductService) ne sont pas chargés automatiquement, il faut les mocker.
  • @Autowired private MockMvc mockMvc; : MockMvc est l'objet principal pour simuler des requêtes HTTP sans démarrer un serveur web réel. Il permet de tester les contrôleurs de manière très efficace.
  • @MockBean private ProductService productService; : Pour un test @WebMvcTest, le service que le contrôleur utilise doit être mocké. @MockBean fait cela et injecte ce mock dans le contrôleur dans le contexte Spring.
  • mockMvc.perform(get("/api/products")...), .andExpect(status().isOk()), .andExpect(jsonPath("$[0].name", is("Laptop"))) :
    • mockMvc.perform() : Lance la requête HTTP simulée.
    • get(), post(), put(), delete() : Des bâtisseurs pour les différents types de requêtes.
    • contentType() : Définit l'en-tête Content-Type.
    • content() : Définit le corps de la requête (souvent JSON).
    • andExpect() : Permet d'ajouter des assertions sur la réponse HTTP (statut, en-têtes, corps JSON).
    • jsonPath() : Utilise la syntaxe JsonPath pour naviguer et vérifier des valeurs spécifiques dans le corps JSON de la réponse. $ représente la racine du document JSON. $[0].name accède au champ name du premier élément d'un tableau.
    • is() et hasSize() : Des matchers de Hamcrest (souvent importés statiquement) pour rendre les assertions plus lisibles.
  • ObjectMapper objectMapper; : Utilisé pour sérialiser/désérialiser des objets Java en/depuis du JSON, essentiel pour envoyer des corps de requête.

Ces exemples démontrent comment Spring Boot facilite l'écriture de tests unitaires et d'intégration, permettant une couverture de test complète et efficace.

4. Bonnes Pratiques et Pièges à Éviter

Bonnes Pratiques

  • Règle FIRST pour les tests unitaires : Rappelez-vous toujours les principes de rapidité, isolation, répétabilité, auto-validation et ponctualité.
  • Tests Atomiques : Chaque test devrait tester une seule chose. Cela rend les tests plus faciles à comprendre, à maintenir et à déboguer.
  • Données de Test Réalistes et Minimalistes : Utilisez des données qui reflètent la réalité, mais ne surchargez pas vos tests avec des données inutiles. Préparez vos données de test dans les méthodes @BeforeEach ou directement dans le test.
  • Nommage Clair des Tests : Des noms de méthodes de test expressifs (ex: shouldReturnProductWhenIdExists) améliorent grandement la lisibilité et la compréhension de ce que chaque test est censé vérifier.
  • Séparez Unitaire et Intégration : Idéalement, les tests unitaires et d'intégration devraient être dans des modules ou des répertoires distincts (par exemple, src/test/java pour tout, mais avec des packages comme com.example.demo.product.unit et com.example.demo.product.integration). Certains projets utilisent le profile Maven/Gradle pour les séparer complètement pour l'exécution.
  • Considérez Testcontainers : Pour les tests d'intégration avec des bases de données ou des services externes, Testcontainers est une solution fantastique pour garantir que vos tests s'exécutent contre des instances réelles (bien que Dockerisées) plutôt que des mocks ou des bases de données embarquées qui peuvent avoir des comportements différents.
  • Ne Testez Pas le Framework : Ne perdez pas de temps à tester JUnit, Spring ou Mockito eux-mêmes. Concentrez-vous sur votre propre logique métier.
  • Couverture de Code : Utilisez des outils comme JaCoCo pour mesurer la couverture de code, mais ne faites pas de la couverture à 100% un objectif absolu. Une haute couverture est bonne, mais la qualité des tests est plus importante que la quantité.

Pièges Courants

  • Tests Trop Lents : Des tests qui prennent trop de temps à s'exécuter sont souvent ignorés ou exécutés moins fréquemment, ce qui annule leur objectif. Optimisez les tests d'intégration et assurez-vous que les tests unitaires sont rapides.
  • Tests Qui Ne Testent Rien : Un test sans assertion est inutile. Assurez-vous que chaque test contient au moins une assertion significative.
  • Tests Fragiles (Flaky Tests) : Des tests qui échouent occasionnellement sans raison apparente (par exemple, à cause de problèmes de concurrence, de dépendances externes non gérées ou de données de test non nettoyées) minent la confiance dans la suite de tests. Corrigez-les immédiatement.
  • Dépendances non Gérées : Ne laissez pas vos tests d'intégration dépendre de ressources externes qui ne sont pas sous votre contrôle (ex: un service web distant non fiable). Utilisez des mocks ou Testcontainers pour créer des environnements de test contrôlés.
  • Ignorer les Tests d'Intégration : Si les tests unitaires sont essentiels, ils ne suffisent pas. Ignorer les tests d'intégration, c'est risquer que les composants ne fonctionnent pas ensemble, même si chacun fonctionne individuellement.

Conclusion

Les tests unitaires et d'intégration sont des outils complémentaires et indispensables pour tout développeur Spring Boot souhaitant construire des applications robustes et fiables. Les tests unitaires, rapides et isolés, sont parfaits pour valider la logique métier interne. Les tests d'intégration, bien que plus lents, sont cruciaux pour s'assurer que les différents composants de votre application et leurs interactions avec les systèmes externes fonctionnent comme prévu.

En maîtrisant JUnit, Mockito, et les capacités de test de Spring Boot (comme @SpringBootTest, @WebMvcTest, @DataJpaTest et MockMvc), vous serez en mesure de livrer du code de haute qualité, facile à maintenir et à faire évoluer. Adoptez une culture de test solide, et vos applications vous remercieront !