Tester les opérations CUD comme comportements observables
Tests & débogageAnalyse approfondie du schéma Arrange-Act-Assert pour les opérations de création, mise à jour et suppression via un service de liste de lecture.
Dans cet article
L’approche comportementale des tests CUD
Le test des opérations de Création, Mise à jour et Suppression (CUD) tombe souvent dans le piège du « test miroir » : appeler une méthode et vérifier simplement que le mock a été sollicité. Pour apporter une valeur réelle, les tests doivent traiter ces opérations comme des comportements observables : étant donné un état spécifique, le système produit-il le résultat attendu ou empêche-t-il une action invalide ?
Dans ce scénario, nous avons un ReadingListService qui gère des listes de livres. Il impose trois règles métier :
- Les livres doivent être uniques au sein d’une liste.
- Les listes archivées ne peuvent pas être modifiées.
- La suppression d’un livre inexistant doit être traitée explicitement.
Le modèle Arrange-Act-Assert (AAA)
Chaque test doit suivre la structure AAA pour garantir la clarté et la maintenabilité :
- Arrange (Préparer) : Configuration des objets, des réponses des mocks et de l’état initial.
- Act (Agir) : Exécution de la méthode spécifique testée.
- Assert (Vérifier) : Validation du résultat, du changement d’état ou de l’exception levée.
Exemple concret : ReadingListService
Voici une suite de tests complète. Nous supposons que ReadingListRepository est mocké avec Mockito. Notez que @InjectMocks gère l’instanciation du service, mais ne démarre pas de contexte Spring.
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 java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ReadingListServiceTest {
@Mock
private ReadingListRepository repository;
@InjectMocks
private ReadingListService service;
// --- COMPORTEMENT DE CRÉATION ---
@Test
void createList_ShouldReturnSavedList_WhenValid() {
// Arrange
ReadingList input = new ReadingList("Java Mastery", false);
ReadingList saved = new ReadingList(1L, "Java Mastery", false);
when(repository.save(any(ReadingList.class))).thenReturn(saved);
// Act
ReadingList result = service.createList(input);
// Assert
assertNotNull(result.id());
assertEquals("Java Mastery", result.name());
verify(repository).save(input);
}
@Test
void createList_ShouldThrowException_WhenBookAlreadyExists() {
// Arrange
ReadingList list = new ReadingList(1L, "Java Mastery", false);
when(repository.findById(1L)).thenReturn(Optional.of(list));
// Simulation : le livre est déjà présent dans la liste
when(repository.containsBook(1L, "Effective Java")).thenReturn(true);
// Act & Assert
assertThrows(DuplicateBookException.class, () -> {
service.addBookToList(1L, "Effective Java");
});
}
// --- COMPORTEMENT DE MISE À JOUR ---
@Test
void updateList_ShouldUpdateName_WhenNotArchived() {
// Arrange
ReadingList existing = new ReadingList(1L, "Old Name", false);
when(repository.findById(1L)).thenReturn(Optional.of(existing));
when(repository.save(any())).thenAnswer(i -> i.getArguments()[0]);
// Act
ReadingList updated = service.updateListName(1L, "New Name");
// Assert
assertEquals("New Name", updated.name());
}
@Test
void updateList_ShouldThrowException_WhenArchived() {
// Arrange
ReadingList archived = new ReadingList(1L, "Old Name", true);
when(repository.findById(1L)).thenReturn(Optional.of(archived));
// Act & Assert
assertThrows(ArchivedListException.class, () -> {
service.updateListName(1L, "New Name");
});
verify(repository, never()).save(any());
}
// --- COMPORTEMENT DE SUPPRESSION ---
@Test
void deleteList_ShouldCallRepository_WhenPresent() {
// Arrange
when(repository.existsById(1L)).thenReturn(true);
// Act
service.deleteList(1L);
// Assert
verify(repository).deleteById(1L);
}
@Test
void deleteList_ShouldThrowException_WhenAbsent() {
// Arrange
when(repository.existsById(1L)).thenReturn(false);
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> {
service.deleteList(1L);
});
verify(repository, never()).deleteById(anyLong());
}
}
Analyse des résultats des tests
Le test de création avec mock prouve que le service renvoie l’identifiant et le nom fournis par le stub repository ; il ne démontre pas une persistance réelle. Le test de liste archivée prouve que cette branche lève une exception avant save ; il ne vérifie ni dirty checking ni tous les chemins de persistance.
Ajoutez des tests qui créent, rechargent, modifient et suppriment avec une base réelle lorsque ce comportement présente un risque. Retirer un livre absent et supprimer une liste absente sont deux opérations distinctes. Un test avec mocks doit annoncer précisément la valeur, règle ou interaction qu’il observe.
Exercice
Scénario : Ajoutez une fonctionnalité où une liste de lecture ne peut pas être supprimée si elle contient plus de 100 livres (pour éviter la suppression accidentelle de listes curatées).
Tâche : Écrivez les étapes Arrange, Act et Assert pour un test qui garantit qu’une MassDeletionException est levée lorsqu’une liste de 101 livres est supprimée.
Réponse :
- Arrange : Moquer
repository.findById(id)pour retourner uneReadingListcontenant 101 livres. Moquerrepository.existsById(id)pour retournertrue. - Act : Appeler
service.deleteList(id)à l’intérieur d’un blocassertThrows(MassDeletionException.class, ...). - Assert : Vérifier que
repository.deleteById(id)n’anever()été appelé.