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.