Tests dans Spring Boot : Guide complet pour débutants et experts
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
- Tests unitaires : tester en isolation
- Tests d'intégration : tester l'ensemble
- Tester un Service avec Mockito
- Tester un Controller avec MockMvc
- Tester un Repository
- Bonnes pratiques et pièges à éviter
- Pour aller plus loin
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/1post("/api/users"): POST /api/usersput("/api/users/1"): PUT /api/users/1delete("/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énamedans l'objetuser$[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
- Comment structurer un projet Spring Boot - La base : organiser votre code proprement
- Configuration multi-environnements avec Spring Boot - Gérez vos profils de test
- Gestion des exceptions dans Spring Boot - Testez vos cas d'erreur
- CRUD complet avec Spring Boot - Un exemple complet à tester
- Spring Boot en production : Checklist - Couverture de code et CI/CD
🎯 Points clés à retenir
- Testez beaucoup en unitaire (rapide, ciblé)
- Quelques tests d'intégration (pour tester l'ensemble)
- Mockez les dépendances avec Mockito
- Utilisez MockMvc pour tester les controllers
- Nommez clairement vos tests (shouldDoSomethingWhenCondition)
- 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 ! 🧪