Chez Pleenk, comme sur la plupart des projets oĂč jâai travaillĂ©, le nombre de tests finit toujours par exploser. Et avec lui, le temps d'exĂ©cution. RĂ©sultat : les feedback loops sâallongent, les dĂ©veloppeurs deviennent moins efficaces et finissent par se dĂ©tourner des tests. C'est Ă ce moment-lĂ que les problĂšmes apparaissent.
Une idĂ©e trĂšs rĂ©pandue pour obtenir un feedback rapide consiste Ă favoriser les tests unitaires. Ce sont eux qui permettent de tester chaque composant de maniĂšre isolĂ©e et dâexplorer des cas extrĂȘmes. Mais ils ne suffisent pas. Rien ne me donne autant de confiance quâun test dâintĂ©gration, qui garantit que plusieurs composants fonctionnent bien ensemble, dans leur environnement rĂ©el.
Voici donc lâhistoire dâun dĂ©veloppeur qui a voulu concilier confiance et rapiditĂ©, Ă©crire des tests dâintĂ©gration efficaces, et soulager une CI en surchauffe.
⥠Le mythe du contexte minimal
Tout a commencé par une intuition :
"Si je réduis la taille du contexte Spring dans mes tests, je vais les accélérer."
Jâai donc créé un test avec un contexte trĂšs limitĂ©, ne chargeant que la configuration et les beans strictement nĂ©cessaires. RĂ©sultat : je suis passĂ© de plusieurs dizaines de secondes, voire minutes de bootstrap, Ă quelques secondes. Victoire.
MotivĂ© par ce succĂšs, je dĂ©cide de gĂ©nĂ©raliser lâapproche. Mais lorsque jâai tentĂ© dâappliquer cette stratĂ©gie Ă lâensemble des tests, les rĂ©sultats ont Ă©tĂ© tout autres. Et câest lĂ que tout sâĂ©croule.
En exĂ©cutant toute la suite de tests, le temps global explose. Plus lent quâavant. Douche froide.
Le test lancé seul s'exécute plus vite, mais c'est l'effet inverse lorsque plusieurs tests sont lancés ensemble. Comment expliquer ce phénomÚne ?
đ PlongĂ©e dans le cache de contexte Spring
Ce phénomÚne vient du cache de contexte Spring dans les tests, que je ne connaissais alors que de loin.
AprĂšs quelques recherches, je dĂ©couvre que le framework propose un mĂ©canisme pour rĂ©duire le temps d'exĂ©cution en mutualisant les contextes entre les classes de test. Si le contexte a dĂ©jĂ Ă©tĂ© construit, il peut ĂȘtre rĂ©utilisĂ© â sinon, il est reconstruit, ce qui coĂ»te cher.
Spring utilise une clĂ© de cache composĂ©e de nombreux Ă©lĂ©ments pour dĂ©terminer si un contexte peut ĂȘtre rĂ©utilisĂ© :
-
Context Loader / Initializers : via
@ContextConfiguration -
Test-specific customizers :
@DynamicPropertySource -
Context hierarchy : via
@ContextHierarchy -
Active profiles : via
@ActiveProfiles("test") -
Test properties : via
@TestPropertySource
Si la clĂ© change (ou si le contexte est marquĂ© @DirtiesContext), Spring considĂšre quâil doit reconstruire entiĂšrement le contexte.
đ Lancer un test isolĂ© avec un contexte minimal est trĂšs rapide.
đ Mais lancer toute la suite avec des dizaines de contextes lĂ©gĂšrement diffĂ©rents coĂ»te trĂšs cher.
MoralitĂ© : en essayant dâoptimiser les tests isolĂ©s, j'avais en rĂ©alitĂ© dĂ©truit le partage du contexte entre eux.
đ§Ș Lâillusion du @MockBean
Face à ça, une autre idée paraßt séduisante :
construire un contexte Spring suffisamment gĂ©nĂ©ral pour ĂȘtre partagĂ© entre tous les tests.
Mais on se heurte vite Ă un mur :
- il faut tout initialiser,
- gérer des interactions inutiles pour le test,
- injecter des jeux de données dans chaque couche,
- comprendre des comportements qui ne concernent mĂȘme pas le testâŠ
Les tests deviennent alors longs, fragiles, illisibles.
On en vient alors à une pratique trÚs répandue : utiliser @MockBean à outrance.
Et en surface, câest parfait :
- on simule les dépendances non pertinentes,
- on garde un test ciblé,
- le code reste clair.
Mais cette simplicité a un coût caché : chaque @MockBean modifie la définition du contexte et casse la clé de cache.
Ce qui peut sembler anodin Ă lâĂ©chelle dâun test devient, Ă lâĂ©chelle dâune suite complĂšte, un vĂ©ritable multiplicateur de lenteur :
Spring reconstruit un nouveau contexte Ă chaque test, mĂȘme si un seul mock change.
@MockBean apparaĂźt donc comme un outil puissant⊠mais extrĂȘmement dangereux si l'on cherche Ă optimiser les temps dâexĂ©cution. Et pourtant, câest une approche trĂšs rĂ©pandue : je lâai retrouvĂ©e dans la majoritĂ© des projets, des formations, et des tutoriels que jâai croisĂ©s.
đ§± Une architecture modulitique pour structurer les tests
Chez Pleenk, notre application est un monolithe structurĂ© en modules mĂ©tiers. Chaque module est isolĂ© selon les principes de lâarchitecture hexagonale :
- séparation stricte entre métier et infrastructure,
- pas dâappel direct entre modules mĂ©tier,
- communication uniquement via ports, gateways ou événements,
- dépendances bien maßtrisées et explicites.
Nous appelons ça une architecture modulitique :
Une approche qui combine la simplicitĂ© de dĂ©ploiement dâun monolithe avec la modularitĂ© et la maintenabilitĂ© dâune architecture orientĂ©e microservices.
Je ne vais pas entrer dans les dĂ©tails ici â cette architecture mĂ©riterait son propre article â mais il faut juste comprendre quâelle nous permet de limiter les interactions entre modules.
Et cĂŽtĂ© tests, on observe que chaque test dâintĂ©gration couvre un ou deux modules maximum, jamais toute lâapplication.
Dans ce cas, pourquoi ne pas configurer uniquement les modules concernés⊠et simuler les autres ?
đ ïž La solution ?
LâidĂ©e : tester une partie de lâapplication (un ou deux modules), tout en isolant intelligemment les autres, sans casser le cache Spring.
đŻ Objectifs
- Déclarer dynamiquement les modules à inclure,
- Moquer les beans des autres modules,
- Conserver une clé de cache stable pour mutualiser les contextes entre les tests.
AprĂšs de longues recherches (et quelques dĂ©sillusions), je nâai trouvĂ© aucun outil clĂ© en main pour faire ça.
Mais en explorant les entrailles de Spring, jâai dĂ©couvert une API peu connue : ContextCustomizerFactory.
1. ContextCustomizerFactory sur-mesure
ContextCustomizerFactory est une interface qui permet de personnaliser la crĂ©ation du contexte dâapplication pour les tests.
Notre implémentation :
- récupÚre la liste des modules à booter,
- identifie les beans des autres modules,
- les remplace par des mocks, des implémentations factices ou spécifiques,
- définit une clé de cache basée uniquement sur les modules inclus.
đ Tous les tests ciblant les mĂȘmes modules partagent alors le mĂȘme contexte Spring, mĂȘme sâils diffĂšrent sur dâautres points.
class ModuleTestContextCustomizerFactory : ContextCustomizerFactory {
override fun createContextCustomizer(
testClass: Class<*>,
configAttributes: List<ContextConfigurationAttributes>
): ContextCustomizer? {
// On vĂ©rifie que lâannotation @ModuleTest est prĂ©sente sur la classe de test
return testClass.getAnnotation(ModuleTest::class.java)?.let { annotation ->
ModuleTestContextCustomizer(annotation.value)
}
}
// Customizer principal, qui injecte les mocks au moment de la création du contexte
private class ModuleTestContextCustomizer(
private val modules: Array<PleenkModule>
) : ContextCustomizer {
override fun customizeContext(
context: ConfigurableApplicationContext,
mergedConfig: MergedContextConfiguration
) {
// Ajout dâun post-processor pour intervenir sur les beans avant leur crĂ©ation
context.addBeanFactoryPostProcessor(configureBeanFactory())
}
private fun configureBeanFactory(): BeanFactoryPostProcessor = BeanFactoryPostProcessor { factory ->
factory.beanDefinitionNames.forEach { beanName ->
val beanDefinition = factory.getBeanDefinition(beanName)
val beanClassName = beanDefinition.beanClassName
// Si le bean appartient à un module externe non testé, on le remplace par un mock
if (shouldMockBean(beanClassName)) {
mockBean(beanName, beanClassName!!, factory)
}
}
}
// UtilisĂ© par le cache de Spring pour dĂ©terminer si le contexte peut ĂȘtre rĂ©utilisĂ©
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ModuleTestContextCustomizer) return false
return modules.contentEquals(other.modules)
}
override fun hashCode(): Int = modules.contentHashCode()
// VĂ©rifie si un bean doit ĂȘtre mockĂ© : il doit faire partie dâun module "externe"
private fun shouldMockBean(beanClassName: String?): Boolean {
if (beanClassName == null || modules.isEmpty()) return false
if (!isExternalModuleBean(beanClassName)) return false
// Si le bean appartient à un des modules testés, on ne le mocke pas
val isFromTestedModule = modules.any { module ->
beanClassName.contains(".${module.packageName}.")
}
return !isFromTestedModule
}
// Remplace un bean par un mock Mockito
private fun mockBean(
beanName: String,
beanClassName: String,
factory: ConfigurableListableBeanFactory
) {
val beanClass = Class.forName(beanClassName)
val mock = Mockito.mock(beanClass)
factory.registerSingleton(beanName, mock)
}
// Filtre les beans qui viennent bien dâun module applicatif Pleenk
private fun isExternalModuleBean(className: String): Boolean {
return className.matches("""com\.pleenk\.backend\.modules\..*""".toRegex())
}
}
}
2. Une annotation explicite : @ModuleTest
Pour transmettre la liste des modules ciblés à notre ContextCustomizerFactory, nous avons créé une annotation maison :
@ModuleTest([Module.NOTIFICATION, Module.PROFILE])
class NotificationIntegrationTest {
...
}
Et voici son implémentation (en Kotlin dans notre cas) :
@Target(AnnotationTarget.TYPE, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@SpringBootTest
@Transactional
@AutoConfigureMockMvc
@ActiveProfiles("test")
@ContextCustomizerFactories(ModuleTestContextCustomizerFactory::class)
annotation class ModuleTest(
val value: Array<Module> = [],
)
Elle simplifie aussi le setup des tests : plus besoin de multiplier les annotations, tout est encapsulé dans @ModuleTest.
3. Une injection explicite, lisible et familiĂšre
Dans le test, il ne nous reste plus quâĂ injecter les beans rĂ©els ou mockĂ©s, comme on le ferait avec @MockBean.
Pour améliorer la lisibilité des tests, nous avons aussi introduit une annotation @AutowiredMock.
Elle ne fait rien de plus quâun @Autowired, mais permet dâexprimer clairement lâintention et d'aider Ă la relecture : "ce bean est injectĂ© sous forme mockĂ©e dans le contexte."
Voici Ă quoi ressemble un test typique aujourdâhui :
@ModuleTest([Module.NOTIFICATION, Module.PROFILE])
class NotificationIntegrationTest {
@AutowiredMock
private lateinit var kycApi: KycApi
...
}
Ce petit détail aide beaucoup à rendre les tests plus lisibles, en distinguant clairement ce qui est "métier" de ce qui est "simulé".
đ RĂ©sultats
Avec cette stratégie :
- â Temps dâexĂ©cution divisĂ© par deux pour nos tests dâintĂ©gration,
- â Forte rĂ©duction du nombre de contextes créés,
- â Meilleure homogĂ©nĂ©itĂ© dans la façon dâĂ©crire les tests.
Les développeurs écrivent des tests plus ciblés, plus lisibles, plus rapides.
La CI est plus stable. Le feedback est plus rapide. La confiance revient.
đ Pour aller plus loin
Cette stratĂ©gie est encore jeune, et nous continuons de lâamĂ©liorer.
Voici quelques pistes que nous explorons ou utilisons en parallĂšle :
- Activer
org.springframework.test.context.cacheen DEBUG pour surveiller la crĂ©ation, la rĂ©utilisation et lâinvalidation des contextes. - Ăviter de charger certains beans internes inutiles lorsque les tests nâinteragissent quâavec la couche dâexposition.
- Documenter la stratĂ©gie dans un guide dâĂ©quipe pour harmoniser les pratiques de test.
- DĂ©velopper un outil dâanalyse des contextes Spring utilisĂ©s en test : Lâobjectif serait dâidentifier plus facilement quels tests partagent un mĂȘme contexte, quels tests en crĂ©ent un nouveau, et quelles variations brisent la mutualisation. Ce type dâoutil nous aiderait Ă rationaliser les contextes existants, mieux comprendre les zones dâoptimisation, et favoriser la convergence vers une architecture de test plus homogĂšne.
Partagez vos idĂ©es ou retours dâexpĂ©rience en commentaire ou contactez-moi directement â je suis toujours curieux de dĂ©couvrir dâautres approches !
đ§ Conclusion
En combinant une architecture modulitique, un contrÎle précis du contexte Spring, et quelques outils bien choisis, nous avons trouvé un compromis durable entre vitesse, confiance et maintenabilité.
Si vous faites face Ă :
- des temps de build trop longs,
- des tests lents ou instables,
- ou un manque dâuniformitĂ© dans les pratiques de testâŠ
JâespĂšre que ce retour dâexpĂ©rience vous inspirera.
Il nây a pas de magie, mais une stratĂ©gie rĂ©flĂ©chie peut vraiment faire la diffĂ©rence dans la qualitĂ© de vos tests â et de votre quotidien de dev.
Top comments (2)
Super article, j'adhere a mort dans un applicatif mu par Springboot. Mais est-ce qu'Ă la fin on ne se dit pas quand mĂȘme "Tout ça pour ça ?"
Depuis que j'ai dĂ©ployĂ© Micronaut et Quarkus, ou les TI sont trĂšs peu coĂ»teux, je regarde SpringBoot diffĂ©remment. Ăvidemment si SB est le choix du client alors ton article est plein d'enseignements pour la commu.
Article au top Martin!
Merci d'avoir creusé le sujet. J'ai appris des choses.