1. Se concentrer sur le test de la couche web
Pour être honnête, la plupart des API « cassent » non pas parce que quelqu’un voulait tout casser. Cela arrive généralement à cause d’une petite modification « de cinq minutes » : on change le nom d’un champ dans un DTO, on modifie un peu le code de statut, on ajoute @Valid — et soudainement le client ne comprend plus votre serveur. Notre tâche aujourd’hui est donc d’apprendre à tester le contrat externe des contrôleurs, et non de repasser un examen sur l’ensemble de l’application d’un coup.
Au niveau des sensations, on se dit : « J’ai déjà vérifié dans Postman, tout fonctionne. » Le problème, c’est que Postman est une excellente loupe pour une vérification manuelle, mais un très mauvais système d’alerte. Il ne se réveillera pas la nuit pour dire : « Hé, tu as commencé à renvoyer 200 au lieu de 404 par hasard. » Les tests automatisés servent justement à cela : ils ne remplacent pas la réflexion, mais rendent les régressions visibles immédiatement, tant que vous vous souvenez encore de ce que vous avez modifié.
Le contrat est déjà visible de l’extérieur : URI, DTO, validation, ProblemDetail, scénarios multipart et documentation sont réunis en une surface API cohérente. Il est maintenant important qu’une modification accidentelle ne modifie pas ce contrat en silence. Par conséquent, la suite s’articule autour d’une chaîne simple : d’abord choisir un plafond de test raisonnable, puis assembler la slice du contrôleur, fixer le happy path, ensuite le negative path, et enfin aborder les points de terminaison de fichiers et l’audit général de la couche web.
Et là surgit une discipline importante : que voulons-nous exactement fixer par les tests dans le cours REST. Dans ce cours, nous avons construit un service sans base de données ni security, et c’est une limitation consciente. Pour nous, le « produit principal » n’est ni la persistance, ni les transactions, ni l’infrastructure, mais la couche de contrat web/API : le routage, le binding, le format JSON des réponses, la validation, la gestion globale des erreurs, les statuts corrects et les en-têtes. C’est cette couche qui doit avoir son propre « plafond de test » — et dans le cadre du cours, ce plafond sera la slice web-layer via @WebMvcTest.
Pour que ce soit plus compréhensible, imaginez l’API comme la porte d’entrée d’un appartement. Vous n’avez pas besoin, à chaque fois que vous vérifiez la serrure, de démonter toute la plomberie et de refaire le câblage. Mais vous devez être sûr que la porte s’ouvre avec la bonne clé et que l’interphone ne répond pas « 200 OK » à n’importe quelle pression.
2. Contrat web dans Task Tracker API
Quand nous parlons de « couche web » dans ce cours, nous ne faisons pas référence à une abstraction « contrôleurs ». Nous parlons du contrat public que voit le client externe : application mobile, frontend, intégration, ou même un script Python. Le client se moque de la façon dont votre service est organisé à l’intérieur, mais il se soucie beaucoup de ce qui est retourné sur GET /api/v1/tasks et de ce qui se produit avec un JSON incorrect.
Ce contrat a plusieurs dimensions, et elles se testent très bien au niveau Spring MVC :
| Partie du contrat | Exemple dans le projet | Pourquoi c’est critique | Ce que le test vérifie réellement |
|---|---|---|---|
| Route + méthode HTTP | GET /api/v1/tasks/{taskId} | Le client doit tomber « dans la bonne méthode » | status, parfois le handler indirectement via le résultat |
| Binding des données d’entrée | taskId depuis le path, page/size/sort depuis la query | L’erreur survient souvent « avant le service » | qu’avec un paramètre incorrect, une erreur appropriée est retournée |
| Codes de statut | 201 Created, 204 No Content, 404 Not Found, 409 Conflict | C’est de la sémantique, pas de la cosmétique | status().isCreated(), isNoContent(), etc. |
| En-têtes | Location à la création, Content-Disposition au téléchargement | Le code client en dépend souvent | header().string(...) |
| Forme JSON | PagedResponse<T>, DTO de détail, payload d’erreur | Stabilité du contrat | Vérifications de forme avec jsonPath(...) |
| Validation | @Valid pour create/update, contraintes pour les critères | Une entrée invalide ne doit pas atteindre la logique | 400 + code=INVALID_INPUT + erreurs de champ |
| Contrat d’erreur | ProblemDetail + code applicatif | Les erreurs font partie de l’API | application/problem+json et les champs de la réponse |
Notez un point important : presque toutes ces choses se vérifient sans démarrer un vrai serveur sur un port, sans base de données réelle et sans système de fichiers réel. Et ce n’est pas de la « triche », c’est une économie d’ingénierie normale : vous testez exactement ce que vous voulez consolider.
Les détails internes (comment TaskService cherche une tâche dans le dépôt en mémoire, comment le storage enregistre un fichier sur disque) relèvent d’un autre domaine de responsabilité. Ils peuvent avoir leurs propres tests, mais dans notre cours ils ne sont pas l’axe principal. Nous protégeons actuellement la couche « d’accord avec le monde extérieur ».
3. @WebMvcTest vs @SpringBootTest
Dans l’écosystème Spring Boot, il existe plusieurs façons d’écrire des tests, et le piège principal du débutant est celui-ci : « J’ai vu @SpringBootTest, donc c’est la bonne voie. » Un peu comme « j’ai vu une masse, donc tout clou est son affaire ». La masse est certes universelle, mais parfois vous vouliez juste accrocher un tableau.
@SpringBootTest démarre le contexte complet de l’application. C’est utile lorsque vous testez réellement l’intégration des couches : contrôleur → service → dépôt → configuration → infrastructure. Mais le contexte complet a un prix : démarrage plus lent, plus de dépendances, plus de raisons pour lesquelles un test peut échouer à cause de détails insignifiants. Pour un cours sur la conception d’API REST, cela se transforme souvent en « bruit au lieu de signal ».
@WebMvcTest est un autre état d’esprit. C’est un test de slice web-layer : un mode de test qui ne démarre que la partie MVC de l’application (contrôleurs, convertisseurs, validation, gestionnaires d’erreurs, etc.) et vous fournit MockMvc pour envoyer des requêtes et vérifier les réponses. Il est spécialement conçu pour les cas où vous vous intéressez au contrat HTTP/JSON.
On peut retenir cette comparaison :
| Annotation | Ce qui est démarré | Ce que nous testons | Coût (temps/bruit) |
|---|---|---|---|
| @WebMvcTest | couche web + infrastructure MVC | Contrat HTTP des contrôleurs | généralement rapide et local |
| @SpringBootTest | contexte complet de l’application | Intégration des couches « comme en production » | généralement plus lent et plus lourd |
Et voici pourquoi dans ce cours nous choisissons @WebMvcTest comme mode principal : notre objectif est de confirmer que le contrat externe correspond à ce que nous avons conçu pendant tout le cours. Nous ne construisons pas ici un « environnement de production », mais des tests qui protègent les accords de l’API.
Pour contraster, je montre un squelette de test « complet » minimal. Il n’est pas mauvais, il est simplement d’une échelle différente :
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
// Démarre le contexte COMPLET de l'application (plus lent, plus de dépendances).
@SpringBootTest
// Branche MockMvc sur le contexte complet.
@AutoConfigureMockMvc
class TaskControllerFullContextTest {
// Ici on aura généralement @Autowired MockMvc et/ou des vérifications d'intégration.
}
Et voici @WebMvcTest — exactement le « projecteur étroit » qui éclaire là où il faut :
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import com.example.tasktracker.api.controller.TaskController;
// Démarre uniquement la slice MVC autour d'un contrôleur spécifique.
@WebMvcTest(TaskController.class)
class TaskControllerWebMvcTest {
// Ici nous testons le contrat HTTP/JSON, pas les services/dépôts/infrastructure.
}
En résumé : @SpringBootTest vérifie « comment le système fonctionne dans son ensemble », tandis que @WebMvcTest vérifie « comment la porte parle au client ». Dans ce cours, nous avons enseigné le « langage de la porte ».
4. Ce que démarre @WebMvcTest
Le mot « slice » semble un peu gastronomique, mais l’idée est simple : nous prenons l’application et ne démarrons dans le test que la partie qui concerne la couche web. Ce n’est pas de la magie, mais une limitation consciente du contexte Spring. Et c’est précisément pourquoi les tests sont rapides et ciblés.
À l’intérieur de @WebMvcTest, on trouve généralement les éléments sans lesquels MVC ne fonctionne tout simplement pas : DispatcherServlet, la mécanique de routage, les argument resolvers qui comprennent @PathVariable, @RequestParam, @RequestBody, HttpMessageConverter pour le JSON, Bean Validation, ainsi que vos contrôleurs et @ControllerAdvice s’ils participent au contrat d’erreur unifié.
Les services, dépôts et composants d’infrastructure n’y sont pas inclus par défaut. C’est la frontière principale de la slice : le contrôleur et le pipeline MVC restent réels, tandis que les dépendances du contrôleur doivent être gérables dans le test.
Une bonne image mentale pour @WebMvcTest dans notre projet ressemble à ceci :
flowchart TD T["JUnit Jupiter test"] --> M["MockMvc"] M --> D["DispatcherServlet (pipeline MVC)"] D --> C["TaskController"] C --> S["TaskService (mock)"] C --> J["Jackson + HttpMessageConverter"] C --> V["Bean Validation"] D --> A["@ControllerAdvice (ProblemDetail)"] A --> M
Dans ce schéma, il n’y a volontairement pas de dépôt en mémoire ni de stockage de fichiers local. Dans le cadre de @WebMvcTest, nous nous intéressons à une seule chose : le contrôleur transforme correctement une requête en appel de service et en réponse HTTP, en appliquant la validation et la gestion d’erreurs unifiée. Si le test a déjà besoin du disque, des données d’amorçage et de tout le reste de l’environnement d’exécution, c’est un bon signe que vous êtes passé à un autre niveau de vérification.
5. MockMvc : HTTP sans port réel
Le mot MockMvc induit parfois en erreur : on pense qu’il s’agit d’une « simulation factice » à laquelle on ne peut pas se fier. En pratique, MockMvc est un outil très honnête. Il ne démarre pas un vrai serveur (Tomcat/Jetty) et n’effectue pas de requête réseau via TCP, mais il fait passer votre requête à travers la véritable mécanique MVC : mappages, binding, conversion de messages, validation, gestion des exceptions. C’est-à-dire que tout ce qui concerne la couche web fonctionne « pour de vrai ».
En conséquence, vous obtenez une situation presque idéale pour l’apprentissage et pour la protection du contrat : le test est rapide, prévisible, ne dépend pas d’un port libre sur votre machine et n’exige pas de « tout démarrer, tout configurer ». Et vous vérifiez exactement ce que le client voudrait voir : statut, en-têtes, type de contenu, JSON.
Le test minimal ressemble généralement à ceci : vous envoyez une requête et vérifiez le statut. Oui, ce n’est pas encore une vérification complète du contrat (nous aborderons plus tard les en-têtes et la forme JSON), mais c’est un excellent premier pas pour commencer à penser « comme un client HTTP ».
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void listTasks_returns200() throws Exception {
// mockMvc est généralement injecté depuis le contexte @WebMvcTest — c’est notre « client HTTP virtuel ».
mockMvc.perform(get("/api/v1/tasks"))
// On fixe une partie du contrat : l’endpoint doit répondre 200 OK.
.andExpect(status().isOk());
}
Il est important de noter le style : nous n’appelons pas la méthode du contrôleur directement comme une fonction Java ordinaire. Nous modelisons réellement une entrée depuis l’extérieur. C’est la raison pour laquelle ces tests sont utiles : ils protègent l’API contre une « évolution accidentelle » et nous obligent à penser en termes de contrat.
Et oui, MockMvc est exactement l’endroit où il est pratique de vérifier les petits détails que l’on oublie constamment lors d’une vérification manuelle. Par exemple, Location pour 201 Created ou Content-Disposition pour un téléchargement. Dans Postman, vous le verrez, mais vous ne penserez pas toujours à revérifier. Dans un test, vous le fixez une fois — et vous vivez plus sereinement ensuite.
6. Squelette du test de slice TaskController
Pour que @WebMvcTest ne reste pas une abstraction « quelque part là-bas » mais devienne un outil concret dans le projet, il est utile de voir le squelette minimal. Il montre déjà l’essentiel : le contrôleur et le pipeline MVC sont démarrés pour de vrai, tandis que la dépendance de service est remplacée par un bean contrôlé.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import com.example.tasktracker.api.controller.TaskController;
import com.example.tasktracker.domain.service.TaskService;
// Démarre la slice MVC uniquement pour TaskController.
@WebMvcTest(TaskController.class)
class TaskControllerWebMvcTest {
// L'outil principal pour des « requêtes HTTP » sans port réel.
@Autowired
MockMvc mockMvc;
// On remplace la dépendance du contrôleur par un mock pour que le test soit déterministe.
@MockitoBean
TaskService taskService;
}
Ce squelette suffit déjà pour voir la mécanique : MockMvc envoie des requêtes à travers le vrai pipeline MVC, tandis que la couche service est remplacée par un bean contrôlé. C’est sur ce squelette que s’appuient ensuite les thenReturn/thenThrow, le travail avec le corps JSON via ObjectMapper et toutes les assertions de contrat.
7. Limite de @WebMvcTest
Cet outil a une limite très claire. Il est destiné à protéger le contrat HTTP externe, et non à prouver d’un seul coup la correction de toute l’application.
Ici, il convient de vérifier les statuts, les en-têtes, le type de contenu, la forme JSON, la validation et le ProblemDetail unifié.
Ici, il ne faut pas démontrer comment le dépôt en mémoire trie les données, comment le stockage écrit un fichier sur le disque ou comment les données d’amorçage sont initialisées.
Si le test a déjà besoin du disque réel, d’un ensemble complet de services et d’infrastructure, vous êtes passé à un autre niveau de vérification. @WebMvcTest n’en devient pas « faible » — il garde simplement le focus là où le contrat est réellement visible par le client.
8. Erreurs typiques au démarrage avec @WebMvcTest
Quand un développeur commence à écrire des tests de slice MVC, il rencontre souvent le sentiment que Spring « fait des caprices » : tantôt le contexte ne se monte pas, tantôt une dépendance est introuvable, tantôt le test échoue étrangement sur une requête pourtant simple. C’est une phase normale d’adaptation, et elle se résume presque toujours à quelques erreurs récurrentes.
Erreur n°1 : utiliser @SpringBootTest « par habitude », puis s’étonner que tout soit lent et bruyant.
Si l’objectif du test est de vérifier que le contrôleur renvoie le bon statut/en-tête/forme JSON, le contexte complet est souvent plus gênant qu’utile. Vous commencez à réparer la configuration de toute l’application alors que vous vouliez vérifier une seule porte. En conséquence, les tests deviennent lents et les échecs sont peu évidents : le problème peut ne pas être du tout dans le contrôleur, mais dans un bean d’infrastructure dont vous n’avez même pas besoin dans ce test.
Erreur n°2 : essayer de vérifier à la fois le travail du service, du dépôt et du stockage de fichiers via @WebMvcTest.
C’est un piège psychologique très courant : on pense que « plus j’en vérifie d’un coup, mieux c’est ». En pratique, vous obtenez un test qui localise mal le problème. Le test échoue — et on ne sait pas ce qui a cassé : le routage, le mapping JSON, la validation, la logique métier ou le travail du système de fichiers. Le but du test de slice est justement de garder le focus sur la couche web et de ne pas mélanger les niveaux de responsabilité.
Erreur n°3 : appeler directement les méthodes du contrôleur comme des méthodes Java ordinaires et considérer cela comme un « test d’API ».
Si vous appelez taskController.listTasks() directement, vous passez à côté de la moitié de ce que fait Spring MVC : le binding des paramètres, la lecture du @RequestBody, le travail du validateur, la conversion des messages, @ControllerAdvice. Un tel test peut être utile comme test unitaire du contrôleur (bien que les contrôleurs soient fins chez nous), mais il ne vérifie pas le contrat externe tel que le voit un client HTTP. Dans le cadre de ce cours, nous voulons justement le « point de vue client », donc MockMvc est l’outil principal.
Erreur n°4 : ne vérifier dans les tests que le statut, en ignorant les en-têtes et la forme JSON.
Vérifier status().isOk() est un bon début, mais si on s’arrête là, les tests seront trop faibles. Le contrat se brise le plus souvent précisément dans les détails : on a changé Location, oublié Content-Type, modifié la forme de la réponse, cassé les champs de ProblemDetail. Ces erreurs ne sont « pas visibles » avec un seul code de statut. C’est pourquoi les tests de la couche web sont précieux précisément parce qu’ils peuvent fixer la forme de la réponse.
GO TO FULL VERSION