DEV Community

Martin Choraine
Martin Choraine

Posted on

🧪 Réduire le temps d'exécution des tests d’intégration Spring : retour d’expérience sur le cache de contexte

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())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {
    ...
}
Enter fullscreen mode Exit fullscreen mode

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> = [],
)
Enter fullscreen mode Exit fullscreen mode

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

    ...
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
fabien_mars profile image
Fabien Marsaud

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.

Collapse
 
flyingtof profile image
Christophe TARET

Article au top Martin!
Merci d'avoir creusé le sujet. J'ai appris des choses.