Tests dans Spring Boot : Guide complet pour débutants et experts

11 min readPar SpringCraft

Tu as créé ta première application Spring Boot. Ça fonctionne en local. Super ! Mais comment être sûr que ça fonctionnera toujours après tes prochaines modifications ? C'est là qu'interviennent les tests.

Tester son code, ce n'est pas une perte de temps. C'est un investissement. Les tests te permettent de :

  • Détecter les bugs avant qu'ils n'arrivent en production
  • Refactorer le code en toute confiance
  • Documenter le comportement attendu de ton application
  • Dormir tranquille la nuit 😴

Dans cet article, nous allons voir comment tester une application Spring Boot de A à Z, avec des exemples concrets et des explications détaillées.

Contexte technique

Versions utilisées :

  • Spring Boot 3.2+
  • Java 17+
  • JUnit 5 pour l'exécution des tests
  • Mockito pour les mocks
  • MockMvc pour les tests de controllers
  • H2 pour les tests de repository

Sommaire


Les différents types de tests

Il existe principalement trois types de tests dans une application Spring Boot :

1. Tests unitaires

Objectif : Tester une classe isolément, sans ses dépendances réelles.

Exemple : Tester la logique d'un UserService sans vraiment appeler la base de données.

Outils : JUnit 5 + Mockito

Rapidité : ⚡️ Très rapide (millisecondes)

2. Tests d'intégration

Objectif : Tester plusieurs composants ensemble, avec le contexte Spring complet.

Exemple : Tester un endpoint /api/users en appelant vraiment le service et le repository.

Outils : JUnit 5 + @SpringBootTest + MockMvc

Rapidité : 🐢 Plus lent (secondes) car Spring démarre

3. Tests end-to-end (E2E)

Objectif : Tester l'application complète, y compris la vraie base de données, le vrai serveur.

Exemple : Démarrer l'application et faire des vraies requêtes HTTP.

Outils : Rest Assured, Testcontainers

Rapidité : 🐌 Lent (peut prendre plusieurs secondes par test)

Quelle stratégie adopter ?

La pyramide des tests recommande :

        /\
       /  \      ← 10% Tests E2E (lents, coûteux)
      /____\
     /      \    ← 20% Tests d'intégration
    /________\
   /          \  ← 70% Tests unitaires (rapides, nombreux)
  /____________\

En pratique : écris beaucoup de tests unitaires (rapides, ciblés), quelques tests d'intégration (vérification globale), et très peu de tests E2E (coûteux en temps).


Tests unitaires : tester en isolation

Un test unitaire teste une seule unité de code (une classe, une méthode) sans démarrer Spring ni appeler de vraies dépendances.

Pourquoi "mocker" les dépendances ?

Imaginons que vous voulez tester UserService. Ce service dépend de UserRepository qui accède à la base de données.

Problème : Si vous testez avec la vraie base de données :

  • Vous devez créer des données de test
  • Le test sera lent
  • Si la base est down, le test échoue (mais le service n'est peut-être pas en cause)

Solution : Utilisez un mock (faux objet) à la place du vrai repository. Vous contrôlez exactement ce qu'il renvoie.


Tester un Service avec Mockito

Prenons un exemple concret : un service qui récupère un utilisateur par son ID.

Le code à tester

@Service
public class UserService {
    private final UserRepository userRepository;
 
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
 
    public UserDTO getUserById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Utilisateur non trouvé avec l'ID : " + id));
 
        return new UserDTO(user.getId(), user.getEmail(), user.getName());
    }
}

Le test unitaire

@ExtendWith(MockitoExtension.class)  // Active Mockito
class UserServiceTest {
 
    @Mock  // Crée un faux UserRepository
    private UserRepository userRepository;
 
    @InjectMocks  // Injecte le mock dans UserService
    private UserService userService;
 
    @Test
    void shouldReturnUserWhenUserExists() {
        // ARRANGE : Préparer le test
        // On dit au mock : "Quand on appelle findById(1L), renvoie cet utilisateur"
        User user = new User(1L, "john@example.com", "John Doe");
        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
 
        // ACT : Exécuter le code à tester
        UserDTO result = userService.getUserById(1L);
 
        // ASSERT : Vérifier le résultat
        assertNotNull(result);
        assertEquals(1L, result.getId());
        assertEquals("john@example.com", result.getEmail());
        assertEquals("John Doe", result.getName());
 
        // Vérifier que le repository a bien été appelé
        verify(userRepository, times(1)).findById(1L);
    }
 
    @Test
    void shouldThrowExceptionWhenUserNotFound() {
        // ARRANGE
        // On dit au mock : "Quand on appelle findById(999L), renvoie un Optional vide"
        when(userRepository.findById(999L)).thenReturn(Optional.empty());
 
        // ACT & ASSERT
        // On s'attend à ce qu'une exception soit lancée
        assertThrows(ResourceNotFoundException.class, () -> {
            userService.getUserById(999L);
        });
 
        // Vérifier que le repository a bien été appelé
        verify(userRepository, times(1)).findById(999L);
    }
}

Décortiquons ce test

@ExtendWith(MockitoExtension.class)

Cette annotation dit à JUnit : "Utilise Mockito pour ce test". Sans ça, les annotations @Mock et @InjectMocks ne fonctionnent pas.

@Mock

Crée un faux objet (mock) de UserRepository. Ce mock ne fait rien par défaut. Vous devez lui dire quoi faire avec when().

@InjectMocks

Crée une instance de UserService et injecte automatiquement les mocks dedans. Pratique !

when(...).thenReturn(...)

C'est comme dire au mock : "Quand quelqu'un t'appelle avec ces paramètres, renvoie cette valeur".

Exemple :

when(userRepository.findById(1L)).thenReturn(Optional.of(user));

Signifie : "Quand on appelle findById(1L), renvoie Optional.of(user)".

verify(...)

Vérifie que le mock a bien été appelé. C'est utile pour s'assurer que votre code fait ce qu'il est censé faire.

Exemple :

verify(userRepository, times(1)).findById(1L);

Signifie : "Vérifie que findById(1L) a été appelé exactement 1 fois".

Tester les cas limites

Un bon test couvre tous les scénarios possibles :

@Test
void shouldHandleNullId() {
    // Que se passe-t-il si on passe null ?
    assertThrows(IllegalArgumentException.class, () -> {
        userService.getUserById(null);
    });
}
 
@Test
void shouldHandleNegativeId() {
    // Que se passe-t-il si on passe un ID négatif ?
    when(userRepository.findById(-1L)).thenReturn(Optional.empty());
 
    assertThrows(ResourceNotFoundException.class, () -> {
        userService.getUserById(-1L);
    });
}

Tester un Controller avec MockMvc

Les controllers gèrent les requêtes HTTP. Pour les tester, on utilise MockMvc : un outil qui simule des requêtes HTTP sans démarrer un vrai serveur.

Le code à tester

@RestController
@RequestMapping("/api/users")
public class UserController {
    private final UserService userService;
 
    public UserController(UserService userService) {
        this.userService = userService;
    }
 
    @GetMapping("/{id}")
    public ResponseEntity<UserDTO> getUserById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserById(id));
    }
 
    @PostMapping
    public ResponseEntity<UserDTO> createUser(@Valid @RequestBody CreateUserRequest request) {
        UserDTO createdUser = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
    }
}

Le test d'intégration

@SpringBootTest  // Démarre le contexte Spring complet
@AutoConfigureMockMvc  // Configure MockMvc automatiquement
class UserControllerIntegrationTest {
 
    @Autowired
    private MockMvc mockMvc;  // Pour simuler des requêtes HTTP
 
    @Autowired
    private ObjectMapper objectMapper;  // Pour convertir JSON ↔ Java
 
    @Test
    void shouldGetUserById() throws Exception {
        // On suppose qu'un utilisateur avec l'ID 1 existe en base
 
        mockMvc.perform(get("/api/users/1"))  // Faire un GET /api/users/1
                .andExpect(status().isOk())  // Attendre un statut 200
                .andExpect(jsonPath("$.id").value(1))  // Vérifier que l'ID est 1
                .andExpect(jsonPath("$.email").exists())  // Vérifier que l'email existe
                .andExpect(jsonPath("$.name").exists());  // Vérifier que le nom existe
    }
 
    @Test
    void shouldReturnNotFoundWhenUserDoesNotExist() throws Exception {
        mockMvc.perform(get("/api/users/999"))  // GET sur un ID qui n'existe pas
                .andExpect(status().isNotFound());  // Attendre un 404
    }
 
    @Test
    void shouldCreateUser() throws Exception {
        CreateUserRequest request = new CreateUserRequest("john@example.com", "John Doe");
 
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))  // Envoyer le JSON
                .andExpect(status().isCreated())  // Attendre un 201
                .andExpect(jsonPath("$.email").value("john@example.com"))
                .andExpect(jsonPath("$.name").value("John Doe"));
    }
 
    @Test
    void shouldReturnBadRequestWhenEmailIsInvalid() throws Exception {
        CreateUserRequest request = new CreateUserRequest("invalid-email", "John Doe");
 
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest());  // Attendre un 400
    }
}

Décortiquons ce test

@SpringBootTest

Démarre tout le contexte Spring : tous les beans, la base de données (H2 en mémoire par défaut), etc.

Avantage : Vous testez votre application comme en production.

Inconvénient : C'est plus lent qu'un test unitaire.

@AutoConfigureMockMvc

Configure automatiquement MockMvc pour vous. Sans ça, vous devriez le configurer manuellement.

mockMvc.perform(...)

Simule une requête HTTP. Vous pouvez faire :

  • get("/api/users/1") : GET /api/users/1
  • post("/api/users") : POST /api/users
  • put("/api/users/1") : PUT /api/users/1
  • delete("/api/users/1") : DELETE /api/users/1

.andExpect(...)

Vérifie la réponse. Exemples :

  • .andExpect(status().isOk()) : Vérifie que le statut HTTP est 200
  • .andExpect(jsonPath("$.id").value(1)) : Vérifie que le JSON contient "id": 1
  • .andExpect(jsonPath("$.email").exists()) : Vérifie que le JSON contient une clé email

jsonPath

C'est un langage pour naviguer dans du JSON. Exemples :

  • $.id : La propriété id à la racine
  • $.user.name : La propriété name dans l'objet user
  • $[0].id : L'ID du premier élément d'un tableau

Tester un Repository

Les repositories sont des interfaces. Spring Data les implémente automatiquement. Faut-il les tester ?

Oui, si :

  • Vous écrivez des requêtes personnalisées avec @Query
  • Vous utilisez des méthodes dérivées complexes (findByNameAndEmailOrCreatedAtBefore)

Non, si :

  • Vous utilisez uniquement les méthodes standard (findById, save, delete)

Test d'un repository avec requête personnalisée

@DataJpaTest  // Configure une base H2 en mémoire pour les tests
class UserRepositoryTest {
 
    @Autowired
    private UserRepository userRepository;
 
    @Test
    void shouldFindUserByEmail() {
        // ARRANGE : Créer un utilisateur en base
        User user = new User();
        user.setEmail("john@example.com");
        user.setName("John Doe");
        userRepository.save(user);
 
        // ACT : Chercher l'utilisateur par email
        Optional<User> found = userRepository.findByEmail("john@example.com");
 
        // ASSERT : Vérifier qu'on l'a trouvé
        assertTrue(found.isPresent());
        assertEquals("John Doe", found.get().getName());
    }
 
    @Test
    void shouldReturnEmptyWhenEmailNotFound() {
        Optional<User> found = userRepository.findByEmail("unknown@example.com");
 
        assertFalse(found.isPresent());
    }
 
    @Test
    void shouldCheckIfEmailExists() {
        User user = new User();
        user.setEmail("john@example.com");
        user.setName("John Doe");
        userRepository.save(user);
 
        assertTrue(userRepository.existsByEmail("john@example.com"));
        assertFalse(userRepository.existsByEmail("unknown@example.com"));
    }
}

@DataJpaTest

Configure automatiquement :

  • Une base de données H2 en mémoire (pas besoin de vraie base)
  • Les repositories Spring Data
  • Les transactions (chaque test est rollback automatiquement)

Avantage : Tests rapides et isolés.


Bonnes pratiques et pièges à éviter

✅ Bonnes pratiques

1. Nommage clair des tests

Mauvais :

@Test
void test1() { ... }

Bon :

@Test
void shouldReturnUserWhenUserExists() { ... }
 
@Test
void shouldThrowExceptionWhenUserNotFound() { ... }

Le nom du test doit décrire ce qui est testé.

2. Structure AAA (Arrange, Act, Assert)

Tous vos tests devraient suivre cette structure :

@Test
void shouldDoSomething() {
    // ARRANGE : Préparer les données
    User user = new User(1L, "john@example.com", "John");
    when(userRepository.findById(1L)).thenReturn(Optional.of(user));
 
    // ACT : Exécuter le code à tester
    UserDTO result = userService.getUserById(1L);
 
    // ASSERT : Vérifier le résultat
    assertNotNull(result);
    assertEquals("john@example.com", result.getEmail());
}

C'est lisible et structuré.

3. Un test = un scénario

Ne testez qu'une seule chose par test.

Mauvais :

@Test
void testEverything() {
    // Teste la création, la lecture, la mise à jour, la suppression
    // dans un seul test géant
}

Bon :

@Test
void shouldCreateUser() { ... }
 
@Test
void shouldUpdateUser() { ... }
 
@Test
void shouldDeleteUser() { ... }

4. Testez les cas limites

Ne testez pas seulement le "happy path" (le cas où tout se passe bien).

Testez aussi :

  • Les valeurs nulles
  • Les listes vides
  • Les erreurs de validation
  • Les cas où la base de données est vide

❌ Pièges à éviter

1. Ne pas mocker ce qui n'a pas besoin de l'être

Mauvais :

@Test
void test() {
    UserDTO dto = mock(UserDTO.class);  // Pourquoi mocker un simple DTO ?
}

Bon :

@Test
void test() {
    UserDTO dto = new UserDTO(1L, "john@example.com", "John");  // Créer l'objet directement
}

Ne mockez que les dépendances complexes (repositories, services externes, etc.).

2. Tester des détails d'implémentation

Mauvais :

@Test
void shouldCallRepositoryTwice() {
    // Test que le repository est appelé 2 fois
    verify(userRepository, times(2)).findById(any());
}

Si vous refactorez et n'appelez plus le repository que 1 fois (par exemple avec un cache), le test casse. Mais la fonctionnalité marche toujours !

Bon :

@Test
void shouldReturnUser() {
    // Tester le résultat, pas comment on y arrive
    UserDTO result = userService.getUserById(1L);
    assertNotNull(result);
}

Testez le comportement, pas l'implémentation.

3. Dépendances entre tests

Mauvais :

@Test
void test1() {
    userService.createUser(...);  // Crée un user
}
 
@Test
void test2() {
    userService.getUserById(1L);  // Suppose que test1 a déjà créé le user
}

Si test2 s'exécute avant test1, il échoue. Les tests doivent être indépendants.

Bon :

@Test
void test1() {
    userService.createUser(...);
}
 
@Test
void test2() {
    // Créer le user directement dans le test
    User user = new User(...);
    userRepository.save(user);
 
    userService.getUserById(1L);
}

Pour aller plus loin

Vous savez maintenant tester votre application Spring Boot ! Voici les prochaines étapes :

📚 Série d'articles complémentaires

  1. Comment structurer un projet Spring Boot - La base : organiser votre code proprement
  2. Configuration multi-environnements avec Spring Boot - Gérez vos profils de test
  3. Gestion des exceptions dans Spring Boot - Testez vos cas d'erreur
  4. CRUD complet avec Spring Boot - Un exemple complet à tester
  5. Spring Boot en production : Checklist - Couverture de code et CI/CD

🎯 Points clés à retenir

  1. Testez beaucoup en unitaire (rapide, ciblé)
  2. Quelques tests d'intégration (pour tester l'ensemble)
  3. Mockez les dépendances avec Mockito
  4. Utilisez MockMvc pour tester les controllers
  5. Nommez clairement vos tests (shouldDoSomethingWhenCondition)
  6. Structure AAA : Arrange, Act, Assert

Les tests ne sont pas une corvée, c'est votre filet de sécurité. Plus vous testez, plus vous êtes confiant pour faire évoluer votre code. 🚀

Bon testing ! 🧪