1. Limites des surcharges de propriétés
Les surcharges de propriétés, c'est comme ajuster le bouton du volume : pratique, rapide, sûr. Mais elles ont une limite : les propriétés peuvent modifier des nombres, des chaînes et des drapeaux, mais ne peuvent pas remplacer le « câblage » interne de l'application. Par exemple, vous pouvez mettre contenthub.notifications.enabled=false, mais si votre service utilise toujours le vrai PublicationNotificationSender, le test aura un effet secondaire. Ou vous pouvez définir le chemin contenthub.storage.root, mais si l'implémentation du stockage accède quand même au répertoire « de production », — surprise. Lorsqu'il faut modifier le comportement (l'heure, l'utilisateur courant, les adaptateurs externes), nous passons au test-only wiring : nous ajoutons ou remplaçons soigneusement des beans uniquement dans le contexte de test.
Un tableau un peu brut mais utile pour résumer (à garder en tête comme « antisèche ») :
| Ce que vous voulez changer dans le test | Les propriétés suffisent-elles ? | Besoin d’un bean de test ? | Exemple tiré de ContentHub |
|---|---|---|---|
| Valeur simple (limite, drapeau, URL) | Oui | Parfois | contenthub.attachments.max-count=1 |
| Valeur « dynamique » (répertoire temporaire, port aléatoire) | Parfois | Parfois | storage root « à la volée » |
| Source de temps | Non | Oui | fixer Clock |
| Utilisateur courant | Non (généralement) | Oui | CurrentUserProvider retourne un nom stable |
| Adaptateur externe / effet secondaire | Parfois | Oui | fake sender, fake moderation client |
2. Test-only wiring : quand et comment
Test-only wiring, c’est une situation où, pour un test, vous assemblez un contexte ApplicationContext légèrement différent de celui de la production : non pas parce que « c’est plus simple », mais parce que le test doit être déterministe, sûr et rapide. C’est comme au cinéma quand on tourne une scène de pluie : dehors il peut faire beau, mais sur le plateau on allume un arroseur parce que le scénario demande de la pluie. Dans le monde réel, la pluie est imprévisible. Dans les tests aussi : le temps, le système de fichiers, les URL externes et « l'utilisateur courant » sont des sources de chaos, alors qu'on a besoin d’un chaos maîtrisé (c’est-à-dire son absence).
Il est utile de voir les choses ainsi : le code de production reste le même, seul « l’environnement » autour de lui change dans le contexte de test.
flowchart TD A["Production ApplicationContext"] --> B["ArticleWorkflowService"] A --> C["Clock (réel)"] A --> D["CurrentUserProvider (réel)"] A --> E["PublicationNotificationSender (réel)"]
flowchart TD T["Test ApplicationContext"] --> TB["ArticleWorkflowService"] T --> TC["Clock (fixé)"] T --> TD["CurrentUserProvider (test)"] T --> TE["PublicationNotificationSender (enregistrement / no-op)"]
La discipline principale ici est que le test-only wiring ne se transforme pas en « application alternative ». Nous ne voulons pas vivre dans deux mondes où tout fonctionne différemment. Nous voulons simplement fixer ce qui autrement serait aléatoire, bruyant ou dangereux.
3. @TestConfiguration pour les beans de test
@TestConfiguration est un moyen pratique de déclarer un ensemble de @Bean qui ne sont destinés qu’aux tests. L’idée est que vous ne polluez pas src/main/java avec des béquilles de test, que vous n’ajoutez pas de « si test, alors… » dans la configuration de production et que vous ne lancez pas un débat éternel « peut-on mettre des classes de test à côté du code de production ? ». Oui, on peut : mais uniquement dans src/test/java, et via des mécanismes spéciaux qui disent explicitement à Spring Boot : « ceci est nécessaire pour les tests ».
Un détail important : la configuration de test doit être petite et spécifique. Elle doit répondre à la question « qu’est-ce qu’on rend déterministe ? ». Si vous vous surprenez à penser « on va remonter la moitié de l’application dans les tests parce que ça ne démarre pas », ce n’est plus du test-only wiring, mais une tentative de fuir un problème d’architecture.
Exemple minimal de configuration de test avec un seul bean :
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class FixedClockTestConfig {
@Bean
@Primary
Clock fixedClock() {
// Si le contexte principal a déjà un production Clock,
// ce test bean doit explicitement l'emporter par type.
// Le temps lui-même est fixé pour éliminer les aléas dus aux horloges et fuseaux horaires.
return Clock.fixed(Instant.parse("2026-01-10T10:15:30Z"), ZoneOffset.UTC);
}
}
Si le contexte principal contient déjà un Clock de production, le bean de test du même type doit l’emporter explicitement. C’est pourquoi il est plus sûr de le marquer immédiatement comme @Primary et de laisser le nom de la méthode neutre — sans jouer avec le bean overriding global.
Remarquez le côté « terre-à-terre » : une classe, un @Bean, aucune logique métier. @TestConfiguration n’est pas l’endroit où « faire le malin », c’est l’endroit où on désactive le hasard.
4. @Import pour un branchement ciblé
L’erreur d’ingénierie la plus fréquente chez les débutants est de créer une seule « configuration de test de base » et de l’importer partout, puis de s’étonner que différents tests s’influencent mutuellement. @Import permet de garder l’impact local : vous branchez la configuration de test exactement dans le test qui en a besoin. Ainsi, en lisant le test, vous comprenez immédiatement pourquoi il fonctionne en mode spécial — car c’est visible dans les annotations.
En pratique, c’est très simple : on prend @SpringBootTest (ou un autre mode de test), on ajoute @Import(...) et on obtient le bean souhaité.
import java.time.Clock;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
@Import(FixedClockTestConfig.class) // On branche exactement la config de test nécessaire à ce test.
class FixedClockWiringTest {
@Autowired
Clock clock; // On récupère le Clock du contexte : on vérifie que la substitution a bien fonctionné.
@Test
void usesFixedClock() {
// On vérifie que « l'heure courante » ne dépend pas de l'horloge réelle de la machine/CI.
assertThat(clock.instant())
.isEqualTo(Instant.parse("2026-01-10T10:15:30Z"));
}
}
Si vous avez besoin de plusieurs configurations de test, vous pouvez les importer sous forme de tableau. Cela se lit presque comme une « recette du contexte » : voici le test, voici le profil, voici les beans supplémentaires.
5. Clock fixé dans les tests
Le temps dans les tests, c’est ce personnage qui arrive toujours sans invitation et gâche la fête. Aujourd’hui le test est vert, demain rouge, à cause du fuseau horaire, du passage à l’heure d’été, d’un lancement à 23:59:59, ou parce que quelqu’un a fait un Instant.now() en plein milieu d’une méthode. C’est pourquoi dans ContentHub nous gardons une « sortie vers le temps courant » via Clock, afin que le temps puisse être remplacé dans les tests sans astuces.
Exemple d’un morceau de logique de production (simplifié) où le temps est obtenu via clock :
import java.time.Clock;
import java.time.Instant;
public class ArticleWorkflowService {
private final Clock clock;
public ArticleWorkflowService(Clock clock) {
// On injecte explicitement la dépendance « temps courant » via Clock,
// pour pouvoir la remplacer par une horloge fixée dans les tests.
this.clock = clock;
}
public Instant now() {
// Important : ne pas utiliser Instant.now(), mais clock.instant().
// Ainsi le temps devient contrôlable via le wiring.
return clock.instant();
}
}
Oui, la méthode now() a l’air simplette. Mais elle joue un rôle important : elle rend la dépendance au temps explicite, donc testable. Si à la place vous écrivez Instant.now() directement dans le code métier, les tests se transforment en boule de cristal : « pourquoi notre createdAt diffère de 2 millisecondes ? »
Dans les tests, nous fixons le temps via @TestConfiguration, comme nous l’avons déjà fait, et nous obtenons du déterminisme. Le point clé n’est pas l’annotation elle-même, mais la discipline : le temps provient toujours d’un bean, donc le test a toujours un levier de contrôle.
6. CurrentUserProvider de test
L’utilisateur courant est la deuxième source classique de chaos. Si le code de production lit l’utilisateur directement depuis SecurityContextHolder, les tests unitaires deviennent « charger Spring en entier pour obtenir un username ». Dans ContentHub, nous essayons de garder la dépendance à l’utilisateur via une petite interface fournisseur : la logique métier n’a pas besoin de savoir d’où vient l’utilisateur, elle a seulement besoin de savoir « qui agit en ce moment ».
Imaginons un contrat minimal :
public interface CurrentUserProvider {
// Contrat très simple : la logique métier n’a besoin que du username.
// D’où il vient (security context, header, mock) — ce n’est pas son problème.
String username();
}
En production, il y aura une implantation qui récupère le nom depuis le contexte de sécurité ou un autre mécanisme. Et dans le test, nous voulons que « l’utilisateur courant » soit stable, comme une chaise de cuisine : toujours le même, sans surprises.
Voici un test-only wiring qui fixe l’utilisateur à « editor-test » :
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class TestUserConfig {
@Bean
@Primary
CurrentUserProvider currentUserProvider() {
// Implantation de test : retourne toujours le même nom,
// pour que les scénarios ne dépendent pas des réglages de sécurité ni du contexte.
return () -> "editor-test";
}
}
Remarquez le @Primary. C’est une nuance pratique importante : si l’application principale a déjà un CurrentUserProvider, le contexte contiendra deux beans du même type. Spring dira honnêtement : « je ne sais pas lequel choisir ». @Primary est une manière polie de dire : « dans les tests, choisis celui-ci ».
Et son branchement dans le test :
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
@Import(TestUserConfig.class) // On remplace uniquement le fournisseur d'utilisateur.
class CurrentUserProviderWiringTest {
@Test
void testContextUsesStableUsername(CurrentUserProvider currentUserProvider) {
// Spring peut injecter le bean directement en paramètre de méthode : pratique pour les tests courts.
assertThat(currentUserProvider.username()).isEqualTo("editor-test");
}
}
Nous utilisons ici une autre petite commodité de Spring : on peut ne pas @Autowired un champ, mais accepter la dépendance en paramètre de la méthode de test. Cela rend le test plus court et un peu moins « lourd en annotations ».
7. PublicationNotificationSender enregistreur
Les effets secondaires, c’est tout ce qui « part vers l’extérieur » : envoi de notifications, appel à un service de modération externe, écriture de fichiers, publication d’événements. Dans les tests, ils sont particulièrement dangereux car ils peuvent dépendre du réseau, des threads, de la vitesse de la machine et de la phase lunaire (je n’affirme pas, mais parfois on le croirait). C’est pourquoi une approche raisonnable : dans la plupart des tests, il faut soit désactiver l’effet secondaire, soit le rendre observable et sûr.
Prenons un port de notification :
public interface PublicationNotificationSender {
// Port pour un effet secondaire : en production ce peut être email/HTTP/file d'attente,
// et dans les tests — enregistrement de l'appel ou no-op.
void sendArticlePublished(long articleId);
}
Dans les tests, il est pratique d’avoir une implantation « enregistreuse » qui n’envoie rien mais conserve la trace de l’appel :
import java.util.ArrayList;
import java.util.List;
public class RecordingNotificationSender implements PublicationNotificationSender {
// On mémorise « ce qui a été publié » pour une vérification ultérieure dans le test.
private final List<Long> published = new ArrayList<>();
@Override
public void sendArticlePublished(long articleId) {
// Au lieu d'un véritable envoi, on enregistre l'appel.
published.add(articleId);
}
public List<Long> publishedArticleIds() {
// On retourne une copie pour que les tests ne puissent pas altérer l'état interne.
return List.copyOf(published);
}
}
Maintenant, branchons cela comme un test-only wiring de sorte que le code de production voie toujours PublicationNotificationSender, mais qu’on puisse dans le test récupérer l’objet enregistreur et voir ce qui s’est passé :
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class RecordingNotificationsConfig {
@Bean
RecordingNotificationSender recordingNotificationSender() {
// On enregistre la classe concrète en tant que bean séparé pour pouvoir la récupérer par type dans le test.
return new RecordingNotificationSender();
}
@Bean
@Primary
PublicationNotificationSender publicationNotificationSender(RecordingNotificationSender recording) {
// Et vers l'extérieur (en tant que port), on renvoie le même objet,
// afin que tous les composants de production utilisent ce double de test.
return recording;
}
}
Et un tout petit test de vérification du wiring (sans partir dans des scénarios complexes) :
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Import(RecordingNotificationsConfig.class) // On branche l'implantation enregistreuse à la place de l'envoi réel.
class NotificationSenderWiringTest {
@Test
void allowsObservingSideEffect(RecordingNotificationSender sender) {
// On simule un effet secondaire : en production ce serait « send », en test c'est l'enregistrement de l'appel.
sender.sendArticlePublished(42L);
// On vérifie que la « notification » a bien eu lieu — de manière sûre et déterministe.
assertThat(sender.publishedArticleIds()).containsExactly(42L);
}
}
Cela semble « trop simple », mais c’est l’objectif : nous apprenons maintenant à contrôler l’environnement. Une fois l’environnement contrôlable, les tests des scénarios métier deviennent beaucoup plus faciles, car vous ne luttez pas contre l’infrastructure.
8. Environnement fichier sécurisé
Le système de fichiers dans les tests, c’est comme un chat : il a l’air apprivoisé, mais il fait ce qu’il veut. Si vous ne fixez pas de règles, vous obtiendrez des fichiers à des endroits inattendus, des conflits entre tests, des problèmes sur Windows à cause des chemins et l’éternelle énigme « pourquoi ça marche chez moi et pas sur le CI ». Le minimum pour un projet comme ContentHub : le profil de test doit avoir un storage root sûr, et dans certains tests, la possibilité d’isoler le répertoire encore plus fortement.
Une option de base très pratique : mettre dans application-test.yml quelque chose comme :
contenthub:
storage:
root: "build/test-storage"
Cela garantit au moins que les tests n’écriront pas dans le répertoire personnel du développeur ni dans un chemin « de production ». Mais il y a des situations où vous voulez une isolation absolue : un test, un répertoire temporaire. L’annotation JUnit @TempDir fonctionne bien alors. Elle crée un répertoire et garantit qu’il sera unique pour le test (et généralement nettoyé après).
Exemple : nous avons un adaptateur de stockage fichier qui a besoin d’un Path root. Le schéma peut être le suivant : la valeur contenthub.storage.root est liée dans StorageProperties, puis utilisée lors de la création du bean FileStorageService. Si nous devons rendre le root dynamique, nous pouvons le faire soit via une propriété dynamique (nous avons déjà vu cette idée), soit via un bean de test.
Du point de vue du sujet de cette leçon, il est plus pratique de montrer une substitution de bean : on ne touche pas du tout au système de fichiers réel, on fournit un stockage en mémoire.
Un port fictif :
public interface FileStorageService {
// On sauvegarde des octets sous une « clé » (en prod — dans un fichier, en test — en mémoire).
void save(String key, byte[] content);
// On charge des octets par clé.
byte[] load(String key);
}
Implantation de test :
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class InMemoryFileStorageService implements FileStorageService {
// Structure thread-safe pour les tests/ appels parallèles.
private final Map<String, byte[]> files = new ConcurrentHashMap<>();
@Override
public void save(String key, byte[] content) {
// On simule la sauvegarde : on met simplement les octets dans la Map.
files.put(key, content);
}
@Override
public byte[] load(String key) {
// On simule la lecture : on récupère les octets de la Map.
// Note : si la clé n'existe pas, cela retournera null — important à prendre en compte dans les tests/contrats.
return files.get(key);
}
}
Et le test-only wiring :
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
@TestConfiguration
public class InMemoryStorageConfig {
@Bean
InMemoryFileStorageService inMemoryFileStorageService() {
// On enregistre l'implantation concrète pour pouvoir y accéder directement dans les tests si besoin.
return new InMemoryFileStorageService();
}
@Bean
@Primary
FileStorageService fileStorageService(InMemoryFileStorageService impl) {
// Comme port principal du stockage fichier, on renvoie la version en mémoire.
return impl;
}
}
Oui, ce n’est « pas un vrai disque ». Mais c’est normal si le test ne porte pas sur le système de fichiers, mais sur la logique métier autour des pièces jointes. Nous ne remplaçons pas le contrat : le service continue de « sauvegarder et lire des octets par clé ». Nous supprimons simplement les E/S et rendons les tests plus stables.
9. Carte des décisions pour l’environnement
Un environnement déterministe, ce n’est ni une annotation ni un fichier unique. C’est une combinaison de niveaux que vous choisissez consciemment : ce qui vit dans application-test.yml, ce qui est redéfini localement par des propriétés, ce qui est défini dynamiquement, et ce qui est fait via des beans de test. Et c’est agréable que vous ayez déjà les outils des leçons précédentes : maintenant vous ajoutez simplement un levier supplémentaire — @TestConfiguration + @Import.
Une « carte » utile sous forme de petit arbre de décision :
flowchart TD
A["Besoin de modifier le comportement du test"] --> B{"Est-ce une simple valeur ?"}
B -->|Oui| C["application-test.yml / properties / @TestPropertySource"]
B -->|Non| D{"Est-ce une valeur runtime ?"}
D -->|Oui| E["@DynamicPropertySource"]
D -->|Non| F{"Faut-il remplacer une dépendance (bean) ?"}
F -->|Oui| G["@TestConfiguration + @Import (+ @Primary en cas de conflit)"]
F -->|Non| H["Vérifiez la conception : la dépendance est peut-être cachée et doit être extraite"]
Remarquez la dernière branche. Si vous ne pouvez pas rendre l’environnement déterministe ni par les propriétés, ni par la substitution d’un bean, cela signifie généralement l’une des deux choses suivantes : soit vous essayez de tester une partie trop grande du système (et alors il faut réduire la frontière du test), soit il y a dans le code de production une dépendance globale cachée qu’il faut rendre explicite. Dans ContentHub, nous nous efforçons justement de garder les dépendances comme le temps, l’utilisateur, le stockage et les adaptateurs externes sous forme de ports/beans explicites.
Et encore une nuance très pratique : plus vous multipliez les ensembles uniques de @Import(...), plus vous aurez de contextes de test différents lancés. Ce n’est pas « mal », c’est juste un coût. C’est pourquoi une bonne pratique consiste à avoir de petites configurations réutilisables (par exemple FixedClockTestConfig, TestUserConfig), et à les importer ponctuellement là où elles sont réellement nécessaires, et non partout « au cas où ».
10. Erreurs typiques du test-only wiring
Erreur n°1 : mettre une configuration de test dans src/main/java en espérant qu'« elle ne gêne personne ».
Dès qu’une configuration de test atterrit dans le code de production, elle commence à vivre sa propre vie : elle tombe par hasard dans le component scan, est importée accidentellement, activée par un profil, ou parfois « s’échappe » en production. La frontière correcte est simple : tout ce qui est test est dans src/test/java, et de préférence via @TestConfiguration pour que l’intention soit évidente.
Erreur n°2 : essayer de « surcharger » un bean de production en activant le bean overriding globalement.
Parfois, on veut aller vite : activer spring.main.allow-bean-definition-overriding=true et déclarer simplement un bean avec le même nom. Cela rend presque toujours les tests moins lisibles et augmente le risque de conflits cachés. Il est bien plus serein de déclarer un second bean et de le choisir via @Primary, ou mieux, de remplacer la dépendance à un niveau plus élevé (port/adaptateur) sans jouer à « qui surcharge qui ».
Erreur n°3 : le bean de test viole le contrat et offre un comportement « trop beau pour être vrai ».
Par exemple, un ModerationClient de test retourne toujours OK et ne lance jamais d’exception, et la logique métier ne voit donc pas les vraies branches. Ou un stockage en mémoire accepte n’importe quelles clés alors que le vrai système de fichiers se plaindra des caractères invalides. Un double de test doit simplifier l’infrastructure, mais pas changer le sens du contrat. Sinon, vous obtiendrez des tests verts qui prouvent seulement que vos bouchons de test… fonctionnent.
Erreur n°4 : un @TestConfiguration trop gros, qui se transforme en « seconde application ».
Quand la configuration de test contient des dizaines de @Bean, des branches conditionnelles, des calculs complexes, des lectures de fichiers et un peu de philosophie, vous cessez de comprendre le contexte de test. À un moment donné, les tests échouent à cause d’erreurs dans la configuration de test, et non à cause du code testé. Gardez la configuration de test petite : une source de non-déterminisme — une substitution locale.
Erreur n°5 : état statique et partagé dans les beans de test sans contrôle.
Les objets enregistreurs, les stockages en mémoire et toutes les collections à l’intérieur des beans de test sont dangereux s’ils survivent accidentellement à plusieurs tests dans un même contexte et « se souviennent de leur vie antérieure ». Si vous stockez un état, assurez-vous qu’il est réinitialisé ou recréé là où il faut. Sinon, vous obtiendrez un test qui dépend de l’ordre d’exécution — et ça frôle déjà la « mystique », dans le mauvais sens.
GO TO FULL VERSION