1. Choix : sans contexte ou avec
Lorsque vous commencez à écrire des tests dans un projet Spring, il est très facile de tomber dans un piège : « Puisque nous avons Spring, testons tout via Spring — c’est plus sérieux ». C’est un peu comme aller chercher du pain avec une dépanneuse : en théorie, ça arrive, mais il y a peu de plaisir, des coûts étranges et un stationnement pénible. Le contexte Spring a un coût : temps de démarrage, complexité de diagnostic, beaucoup d’infrastructure autour et des piles d’appels immenses qui savent distraire du sujet mieux que n’importe quelle notification sur votre téléphone.
À ce stade, ContextFlow sait déjà faire plus qu’un simple ensemble de POJO avec quelques new : il contient des profils, des propriétés, des événements, de l’AOP et même une petite queue XML legacy. Il est donc maintenant nécessaire de séparer deux vérifications différentes : ce qui est simplement la logique de la classe, et ce qui n’existe que parce que le conteneur a assemblé l’application d’une manière particulière.
L’idée principale de la leçon d’aujourd’hui n’est pas « tests unitaires contre tests contextuels » comme une guerre religieuse, mais une simple règle d’ingénierie : avant d’écrire un test, formulez la question principale à laquelle il répond. Si la question porte sur la logique de la classe, vous n’avez pas besoin du conteneur. Si la question porte sur l’assemblage de l’application par le conteneur (câblage, profils, propriétés, événements, XML legacy), le test sans contexte perd son sens.
Pour rendre cela plus concret, résumons la différence dans un petit tableau — non pas comme une « antisèche pour l’examen », mais comme une carte du terrain :
| Ce que vous voulez vérifier | Généralement adapté | Pourquoi |
|---|---|---|
| Algorithme / règle / calcul (logique pure) | Test unitaire sans Spring | Vous créez un objet avec new, vous donnez aux dépendances de simples substituts, et l’échec du test pointe directement vers le code de la règle |
| Que la classe appelle le « voisin » requis (délégation) | Test unitaire sans Spring + stub | Le conteneur n’ajoute pas de valeur : vous vérifiez l’interaction des objets, pas la construction du graphe |
| Que le contexte démarre et s’assemble | Test contextuel | C’est une vérification de la configuration et du câblage — sans contexte, vous ne saurez pas que l’application ne démarre pas réellement |
| Que dans le profil test les bons beans sont injectés | Test contextuel | Les profils sont une mécanique de conteneur ; assembler « comme si test » à la main n’est pas la même chose |
| Que @EventListener se déclenche réellement | Test contextuel | Les écouteurs sont enregistrés par le conteneur ; sans contexte, vous ne testez pas les événements Spring, mais de simples appels de méthodes |
Si vous voulez un « arbre de décision » très court, il ressemble à ceci :
flowchart TD
A["J'écris un test"] --> B["Ma question concerne la logique de la classe ?"]
B -->|Oui| C["Test unitaire (new + stub)"]
B -->|Non| D["Ma question concerne l'assemblage/comportement Spring ?"]
D -->|Oui| E["Test contextuel (ApplicationContext)"]
D -->|Non| F["Très probablement la question est formulée de manière floue — préciser l'objectif du test"]
Dans ContextFlow, cela est particulièrement important, car nous avons déjà accumulé de « savoureuses » fonctionnalités de conteneur : profiles, properties, conversion, events, AOP, legacy XML. Tout cela peut facilement être cassé par la configuration — et il est difficile à diagnostiquer manuellement sans tests.
2. Test unitaire sans Spring
Un test unitaire est ce moment précis où l’on peut poliment demander à Spring de rester à l’écart. Vous prenez une classe, vous la créez avec un simple new, vous injectez les dépendances à la main et vous vérifiez le comportement. Pour un débutant, c’est même psychologiquement utile : vous ne vous cachez pas derrière le conteneur, vous voyez quels objets participent réellement au travail. Et oui, cela s’inscrit parfaitement dans la philosophie Spring concernant les POJO : « vos services sont des classes ordinaires, Spring aide simplement à les assembler ».
Pour ne pas parler de manière abstraite, prenons un petit extrait de notre domaine : un service qui envoie des notifications via le port NotificationSender. Nous n’avons pas besoin de démarrer le contexte pour vérifier que le service délègue réellement l’envoi.
D’abord l’interface (port) qui existe déjà dans notre projet dans domain.ports :
public interface NotificationSender {
// Contrat du port : le service ne se soucie pas de COMMENT envoyer le message — pourvu qu'il soit envoyé
void send(String message);
}
Ensuite — un stub (simple substitut de test) qui se souvient du dernier message :
public class StubNotificationSender implements NotificationSender {
// Dans le stub, nous enregistrons le fait de l'interaction (ce qui a été envoyé),
// sans essayer d'« envoyer réellement » par SMS/email/file/queue.
String lastMessage;
@Override
public void send(String message) {
// On mémorise l'argument — le test vérifiera ensuite que le service a transmis la bonne valeur
this.lastMessage = message;
}
}
Maintenant, le service que nous voulons tester :
public class NotificationDispatchService {
// La dépendance est exprimée via l'interface (port) — c’est ce qui rend le test unitaire simple
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
this.sender = sender;
}
public void dispatch(String message) {
// Toute la logique ici est la délégation : on vérifie que l'appel va bien dans sender
sender.send(message);
}
}
Et le test lui-même avec JUnit Jupiter (sans Spring, sans contexte, sans magie et sans « où sont les annotations ? ») :
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NotificationDispatchServiceTest {
@Test void dispatchDelegatesToSender() {
// Arrange : substitut de dépendance
var sender = new StubNotificationSender();
// Arrange : service testé — créé à la main, sans conteneur
var service = new NotificationDispatchService(sender);
// Act : exécution de l’action
service.dispatch("order-created");
// Assert : vérification que le message est bien arrivé à la dépendance
assertEquals("order-created", sender.lastMessage);
}
}
Ce qui est important dans ce test, c’est qu’il répond exactement à une question : « Le service envoie-t-il réellement le message via la dépendance ? ». S’il échoue, la raison sera soit dans cette classe, soit dans le stub — c’est-à-dire à un endroit que vous avez sous les yeux. Pas de « le contexte n’a pas démarré parce qu’il n’a pas trouvé PropertySource » alors que vous vouliez simplement vérifier une méthode.
Pour montrer que les tests unitaires ne servent pas seulement à la « délégation », prenons un exemple plus proche de la logique métier : la remise. On peut et on doit la vérifier sans Spring, car une remise est une règle, pas une fonctionnalité de conteneur.
import java.math.BigDecimal;
public interface DiscountPolicy {
// Politique de remise : en entrée un montant, en sortie le montant après application de la règle
BigDecimal apply(BigDecimal original);
}
import java.math.BigDecimal;
public class NoDiscountPolicy implements DiscountPolicy {
@Override
public BigDecimal apply(BigDecimal original) {
// « Remise nulle » — on retourne le montant tel quel
return original;
}
}
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class NoDiscountPolicyTest {
@Test void keepsOriginalAmount() {
// Important : il est préférable de créer BigDecimal à partir d'une chaîne pour éviter les surprises de précision binaire
var policy = new NoDiscountPolicy();
assertEquals(new BigDecimal("100.00"), policy.apply(new BigDecimal("100.00")));
}
}
Oui, c’est un exemple simple. Mais il illustre le principe : la logique n’a pas à « vivre dans Spring » pour faire partie d’une application Spring. Au contraire : plus vous pouvez vérifier de logique sans conteneur, plus vous vivez sereinement.
3. Objets stub
Quand les gens entendent parler de « substitutions » dans les tests pour la première fois, ils pensent souvent immédiatement aux frameworks de mock et commencent à rêver de Mockito comme d’un super-pouvoir : « je vais tout mocker ici, et le monde deviendra prévisible ». Parfois oui. Mais à notre niveau (et dans le cadre du cours), un stub nous suffit le plus souvent : une petite classe qui implémente l’interface et se comporte de manière extrêmement simple. C’est comme une doublure d’acteur : il n’est pas obligé de jouer génialement, sa tâche est de passer la scène en toute sécurité.
Un stub aide le test unitaire à rester petit et honnête. Au lieu d’un véritable expéditeur de SMS ou d’une écriture dans un fichier, nous utilisons un objet qui se contente de mémoriser ce qu’on lui a passé. Ainsi, nous vérifions le comportement de la classe testée, et non les effets secondaires de l’infrastructure.
Par exemple, dans ContextFlow, l’audit est un port séparé AuditWriter. Dans les tests unitaires, nous ne voulons généralement pas écrire dans la console ou dans un fichier (bruyant, lent, parfois cassé sur le CI). Nous voulons comprendre : « l’audit a-t-il été appelé ? ». Un stub pour cela pourrait ressembler à ceci :
import java.util.ArrayList;
import java.util.List;
public class StubAuditWriter implements AuditWriter {
// On stocke tous les messages pour que le test puisse vérifier :
// 1) que l’audit a été appelé
// 2) combien de fois il a été appelé
// 3) quels messages exactement ont été envoyés
final List<String> messages = new ArrayList<>();
@Override
public void write(String message) {
// On « enregistre le fait de l’appel » sans I/O, sans fichiers, sans console
messages.add(message);
}
}
Il y a un détail important ici : le stub doit être primitif, sinon il se transforme en « deuxième implémentation du système » que vous commencez à tester à la place de l’originale. Si vous vous surprenez à ce que le stub contienne des branchements, de la configuration, une logique complexe et la moitié du modèle de domaine, vous avez construit un petit framework, félicitations… mais les tests n’en deviennent généralement pas plus heureux.
Dans nos leçons et dans le projet ContextFlow, nous avons fait à l’avance une chose importante : nous avons extrait les interactions avec le monde extérieur dans des ports (domain.ports). Cela signifie que pour les tests unitaires, écrire des substituts est très facile : implémentez l’interface et c’est tout.
4. Test contextuel dans Spring
Un test contextuel est le cas où vous ne testez pas une « classe dans le vide », mais le travail de Spring en tant qu’assembleur de votre application. Et ici, il est très important de ne pas se leurrer : si vous créez toutes les dépendances avec new et assemblez « comme si » l’application à la main, vous testez… votre assemblage manuel, pas Spring. C’est parfois utile (par exemple pour les tests unitaires), mais cela ne répond pas à la question « le conteneur va-t-il l’assembler de la même manière ? ».
Les tests contextuels sont nécessaires là où votre comportement n’existe que grâce au conteneur. Dans ContextFlow, ces endroits sont nombreux : profils actifs, chargement de properties, conversion, listeners via @EventListener, legacy XML bridge, post-processeurs, proxy/AOP. Tout cela n’est pas du « simple code Java ». C’est de la mécanique de conteneur, et si vous voulez avoir confiance en elle, vous démarrez le contexte.
Démarrer le contexte à la main est utile une fois : on voit mieux ce que fait exactement le conteneur. Mais c’est délibérément une option intermédiaire. Dès qu’il y a plus de tests de ce type, on préfère confier le bootstrap et la fermeture du contexte à Spring Test plutôt que de le faire manuellement dans chaque méthode.
Dans cette leçon, nous n’utilisons pas l’intégration Spring Test (nous la verrons dans la leçon suivante), mais nous montrons simplement le sens minimal : on peut démarrer le contexte directement dans le test à la main pour voir la différence.
Configuration pour le test :
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class TestConfig {
// Bean très simple pour que le test puisse vérifier « le contexte a démarré et sait fournir des beans »
@Bean
String appName() {
return "contextflow-test";
}
}
Et le test qui vérifie précisément l’enregistrement par le conteneur :
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ContextPresenceTest {
@Test void readsBeanFromContext() {
// Important : le contexte est une ressource, il faut le fermer.
// try-with-resources garantit la fermeture même en cas d’échec d’un assert.
try (var ctx = new AnnotationConfigApplicationContext(TestConfig.class)) {
// On vérifie que le bean est réellement enregistré par le conteneur et résolu depuis le contexte
assertEquals("contextflow-test", ctx.getBean(String.class));
}
}
}
Ce n’est pas un « test métier utile » (et il ne devrait pas l’être). Son sens est autre : montrer que la question du test concerne le conteneur. Si le @Bean disparaît, si la classe de configuration ne s’enregistre pas, si le contexte ne démarre pas, le test échoue. Et c’est exactement ce que nous voulons attraper quand nous parlons de tests contextuels.
Dans ContextFlow, les tests contextuels répondent généralement à des questions comme : « dans le profil test, utilisons-nous vraiment DeterministicOrderIdGenerator ? », « le MessageSource est-il configuré ? », « le fragment XML legacy est-il attaché ? », « les écouteurs d’événements sont-ils réellement enregistrés ? ». Ces questions ne peuvent pas être honnêtement vérifiées avec un simple new.
5. Frontière dans ContextFlow
Maintenant, ancrons tout cela dans notre projet, car une philosophie abstraite des tests sans lien avec le code sonne bien, mais protège des bugs à peu près comme une citation inspirante sur une tasse. Dans ContextFlow, nous avons délibérément séparé les couches : le domaine et les services d’application doivent être relativement « compatibles POJO », tandis que la magie du conteneur vit dans config/support et l’infrastructure. Ce n’est pas un hasard — cela rend les tests plus faciles.
Voici une carte pratique indiquant où se situe le plus souvent la frontière. Ce n’est pas une loi de la nature, mais un repère pour ne pas rester bloqué 30 minutes sur chaque classe à choisir « unitaire ou contextuel ».
| Partie de ContextFlow | Exemple de mécanique/classe | Ce qu’il est raisonnable de vérifier | Type de test |
|---|---|---|---|
| domain.model | invariants de OrderItem, statuts | que des objets « absurdes » ne sont pas créés, transitions correctes | unitaire |
| application.service | OrderPricingService | calculs, règles, réactions aux données d’entrée | unitaire (ports stubés) |
| application.service | OrderPlacementService | orchestration comme séquence d’étapes (partiellement) | unitaire ou scénario avec contexte (selon l’objectif) |
| infrastructure.reporting | TextReportFormatter | formatage de chaîne, structure du rapport | unitaire |
| support.conversion | convertisseur String -> NotificationChannel | réaction à email, sms, valeurs invalides | unitaire (sur le convertisseur) |
| config.profiles | branches @Profile | que dans test/demo les bonnes implémentations sont choisies | contextuel |
| domain.events + listeners | OrderCreatedEvent | que l’événement est publié et que les écouteurs se déclenchent | contextuel |
| config.legacybridge | @ImportResource | que le XML est bien attaché et fournit des beans | contextuel |
| support.aop | aspect et proxy | que les services sont devenus des proxies et que l’advice est appliqué | contextuel (si on teste) |
Notez une idée intéressante : une même classe peut parfois être testée de différentes manières. Par exemple, OrderPlacementService en tant que « séquence d’actions » peut être couvert par un test unitaire avec des stubs (rapide, local). Mais si votre objectif est de vérifier le flux d’événements et les écouteurs, vous ferez alors un test contextuel scénaristique, car autrement vous ne testez pas les événements Spring, mais un « appel manuel de méthodes ».
6. Un test, une question
Le problème le plus fréquent chez un débutant en test de projet Spring est un test qui essaie de vérifier « absolument tout ». Il démarre le contexte, crée une commande, vérifie la remise, vérifie que l’écouteur a écrit l’audit, vérifie que le rapport a été sauvegardé dans un fichier… et ensuite il échoue parce que quelqu’un a changé une clé de propriété. Résultat : vous vous asseyez et vous pensez : « Bon, je testais la remise ou la lecture des properties ? ».
Mélanger les objectifs rend presque toujours le diagnostic plus difficile. Un test unitaire est bon car il échoue pour une raison liée à la classe testée. Un test contextuel est bon car il échoue pour une raison liée au conteneur et à la configuration. Si vous mélangez, vous obtenez « échoue pour n’importe quelle raison » — et c’est exactement le cas où les tests commencent à agacer et où l’on a envie de les désactiver (et ensuite, par accident, de désactiver la prod).
Une règle pratique qui fonctionne vraiment : essayez de terminer mentalement la phrase « Ce test est nécessaire pour s’assurer que… ». Si vous ne pouvez pas la terminer en une seule phrase, cela signifie que le test a plusieurs objectifs et qu’il faut le diviser. Dans ContextFlow, cela ressemblera à ceci : un test unitaire séparé vérifie que la remise est appliquée correctement ; un test contextuel de type smoke test séparé vérifie que DiscountPolicy est résolu et n’entre pas en conflit avec d’autres implémentations ; un test de flux d’événements séparé vérifie qu’après OrderCreatedEvent, l’écouteur s’est déclenché.
Oui, il y aura plus de tests. Mais chacun sera plus simple, plus rapide et plus honnête. Et, agréablement, vous n’aurez pas l’impression que « Spring se teste par des incantations ».
7. Erreurs typiques dans le choix entre test unitaire et test contextuel
Erreur n°1 : démarrer le contexte Spring « pour le sérieux » là où un simple new suffit.
Cela ressemble généralement à ceci : vous voulez vérifier 5 lignes de logique, mais vous démarrez le contexte, vous traînez la configuration, et le test échoue à cause du câblage. Cela ne rend pas le test « plus intégration », cela le rend moins compréhensible. Si votre question ne concerne pas Spring, Spring dans le test n’est que du bruit inutile.
Erreur n°2 : essayer de vérifier la mécanique du conteneur par un assemblage manuel d’objets.
Par exemple, vous « vérifiez les profils », mais dans le test vous créez vous-même un new DeterministicOrderIdGenerator et vous vous réjouissez. Cela vérifie que vous savez écrire new, pas que @Profile("test") fonctionne réellement. Les mécaniques du conteneur se testent avec le contexte — sinon vous contournez simplement l’objet de la vérification.
Erreur n°3 : mélanger dans un même test la vérification de la logique métier et la vérification du câblage.
Au début, cela semble pratique : « un test par scénario ». En pratique, vous obtenez un test qui se casse à chaque modification, et la cause de l’échec n’est pas évidente. Il vaut mieux séparer : les tests unitaires attrapent les erreurs de logique, et les tests contextuels attrapent les erreurs d’assemblage de l’application.
Erreur n°4 : ne pas utiliser de stub là où la dépendance réelle crée des effets de bord bruyants.
Dans les tests unitaires, les écritures réelles dans des fichiers, la console et tout ce qui « dépend de l’environnement » sont particulièrement dangereux. Même si le test passe chez vous, il peut commencer à clignoter sur le CI ou sur une autre machine. Le stub donne le contrôle : vous enregistrez le fait de l’interaction, sans espérer que « bon, le fichier se crée normalement ».
Erreur n°5 : rendre le stub plus « intelligent » que la classe testée.
Si le stub se transforme en un objet complexe avec de la logique et de l’état, vous commencez insensiblement à tester le stub. Résultat : les tests passent, mais le système réel se casse parce que le stub était trop « idéal ». Le stub doit être simple et prévisible — comme un vrai fer à repasser : on l’allume, il chauffe, pas de surprises.
GO TO FULL VERSION