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.cache
en 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.