Comprendre et optimiser le cache de contexte dans Spring Test

Comprendre le cache de contexte de Spring Test, les leviers pour rationaliser ses contextes, et les considérations sur une suite d'intégration à grande échelle.

Ceci est une note personnelle que j’ai décidé de partager. Elle reflète ma compréhension d’un sujet et peut contenir des erreurs et des approximations. N’hésitez pas à contribuer en me contactant ici. Toute aide sera créditée !


Il y a quelques mois, nous avons décidé de travailler sur les performances de nos suites de tests, plus de 10 000 tests d’intégrations réparties sur une quarantaine de services/microservices. L’objectif était de faire des économies de coûts de CI et d’améliorer la boucle de feedbacks pour livrer plus et plus vite. Nous avons réduit les temps de build avec tests d’environ 40-60% sur l’ensemble des services avec un record sur un microservice d’environ 90%. Les économies de coûts étant proportionnelles, nous avons diminué la facture de presque moitié.

Pendant des mois, nous nous sommes concentrés sur l’implémentation de feature et l’amélioration produit, en nous basant sur un tooling interne pour nos tests : des méthodes utilitaires, des annotations custom pour lancer des MongoDB, des S3, des Kafka en testcontainers mais aussi des configurations prêtes à l’emploi. Pour aller vite, nous avions volontairement délaissé certaines considérations de performances, en particulier sur le mécanisme de cache de contexte dans le framework de test de Spring.

Dans ce post, je souhaite partager quelques considérations pour conduire ce type d’optimisation.

Merci à Mickael LAMASUTA pour sa contribution à ces travaux, en particulier sur l’implémentation de stratégies d’optimisation ainsi que sur les tests de performance associés.

Le mécanisme du cache de contexte

La documentation de Spring sur ce sujet est très bien faite 1. Je ne citerai ici que quelques éléments que je juge importants.

Dans les suites de tests qui utilisent des contextes Spring, comme c’est le cas si vous utilisez @SpringBootTest, Spring met en cache les contextes pour les réutiliser d’un test à l’autre, d’une classe de test à l’autre. Cela évite de créer des contextes de zéro, ce qui est coûteux en temps et en ressources.

Pour cela, Spring utilise un cache de 32 contextes par défaut et une gestion de type LRU (least recently used). On peut forcer l’éviction d’un contexte dans le cache via l’annotation @DirtiesContext sur une classe ou une méthode.

public class DefaultContextCache implements ContextCache {
	/**  
	 * Map of context keys to Spring {@code ApplicationContext} instances.  
	 */
	 private final Map<MergedContextConfiguration, ApplicationContext> contextMap =  
	       Collections.synchronizedMap(new LruCache(32, 0.75f));
	       
    ...
}

La clé d’identification d’un contexte dans le cache est une instance de MergedContextConfiguration. Cet objet agit comme descripteur du contexte et permet donc de savoir si deux contextes Spring sont identiques. En regardant le hashcode de la classe, on comprend rapidement ce qui décrit un contexte.

@Override  
public int hashCode() {  
    int result = Arrays.hashCode(this.locations);  
    result = 31 * result + Arrays.hashCode(this.classes);  
    result = 31 * result + this.contextInitializerClasses.hashCode();  
    result = 31 * result + Arrays.hashCode(this.activeProfiles);  
    result = 31 * result + this.propertySourceDescriptors.hashCode();  
    result = 31 * result + Arrays.hashCode(this.propertySourceProperties);  
    result = 31 * result + this.contextCustomizers.hashCode();  
    result = 31 * result + (this.parent != null ? this.parent.hashCode() : 0);  
    result = 31 * result + nullSafeClassName(this.contextLoader).hashCode();  
    return result;  
}
  • Parent Context : La configuration parente dans une hiérarchie de contextes déclarée avec @ContextHierarchy.
  • Configuration Sources : Les définitions du contexte en fichiers XML principalement, via @ContextConfiguration(locations = ...), et/ou classes @Configuration via @ContextConfiguration(classes = ...). (Ce sont les champs locations et classes.)
  • Context Loader Type : Le ContextLoader chargé de construire le contexte, résolu notamment via @ContextConfiguration(loader = ...).
  • Context Initializers : Les implémentations d’ApplicationContextInitializer enregistrées via @ContextConfiguration(initializers = ...).
  • Context Customizers : Les modifications appliquées au contexte, utilisées par des fonctionnalités comme @DynamicPropertySource, @MockitoBean, ou @MockitoSpyBean.
  • Active Profiles : Les profils Spring activés avec @ActiveProfiles(...).
  • Test Properties : Les propriétés et les emplacements de fichiers de propriétés issus de @TestPropertySource.
  • Resource Path : Le chemin racine de la ressource web fourni par @WebAppConfiguration (dans le cadre de WebMergedContextConfiguration).

On comprend rapidement les points d’actions les plus simples et directs sur lesquels agir pour maximiser la réutilisation de contextes mis en cache :

  • Les profiles actifs et l’ordre dans lequel ils sont activés ({profileA, profileB} et {profileB, profileA} donneront deux contextes différents dans le cache).
  • Les mock et les spy déclarés. Avec les annotations @MockitoBean et @MockitoSpyBean, la création de mock est ultrasimplifiée. Si on ne fait pas attention, ces mock et spy génèrent de nouveaux contextes et dégradent les performances des suites de tests. Une façon de les rationaliser est de regrouper mocks et configurations dans des classes de test abstraites communes; un recours à l’héritage que certains déconseillent 2, mais qui limite efficacement la prolifération de contextes.
  • Unifier la gestion des sources de propriétés et des ressources.
  • Éviter l’utilisation de l’annotation @DirtiesContext, qui évince le contexte du cache et impose une recréation coûteuse au prochain test qui en a besoin.

A noter que la taille du cache peut être ajustée via un fichier spring.properties, ce qui est pratique si les tests sont exécutés dans des environnements contraints et pour éviter un trop grand nombre de services tiers lancés en parallèle.

spring.test.context.cache.maxSize=4

Pour comprendre l’utilisation du cache dans sa suite de tests, il est possible de logger un certain nombre d’informations en changeant le log level (voir la doc 1).

Ces premiers points d’action apportent des gains de performances directs, si les contextes sont rationalisés et leur nombre réduit. Mais lorsque les suites de tests se complexifient, avec l’utilisation de testcontainers et/ou de dépendances tierces embarquées, d’autres considérations entrent en jeu.

Considérations

Bien souvent, les suites de tests, en particulier des tests d’intégration ou end-to-end, nécessitent un certain nombre de dépendances tierces : une base de données, un système d’authentification, un message broker, un service cloud… Historiquement, les développeurs utilisaient souvent des versions “embedded”, plus ou moins complètes, de ces services (de.flapdoodle.embed.mongo, @EmbeddedKafka…). Depuis quelques années, les testcontainers les ont remplacés, plus portables, plus stables et avec une meilleure reproductibilité.

Peu importe ce qu’on utilise, il est important de comprendre le cycle de vie de ces services et la manière dont ils s’intègrent aux suites de tests.

Prenons l’exemple de testcontainers, il est possible de les utiliser via des JUnit Extension, qui sont indépendantes du contexte Spring (@Testcontainers) ou de les utiliser dans le contexte Spring (@ContextConfiguration(initializers = …), @ServiceConnection, @DynamicPropertySource…). Si votre testcontainer MongoDB est configuré dans le contexte Spring et que la taille du cache est de 32, attendez-vous à avoir 32 testcontainers MongoDB en parallèle (attention aux ressources nécessaires). Pour une base de données, il est envisageable de réutiliser le testcontainer tout au long de la suite de tests avec un nettoyage des données bien configuré.

Ce qui est vrai pour une base de données ne l’est pas forcément pour un message broker. Si vous avez un Kafka en testcontainers et que vous souhaitez le réutiliser tout au long de votre suite de tests, ça amène de nombreux challenges. Le nettoyage des données n’est pas direct, les consumer groups restent actifs dans les contextes mis en cache. Il peut donc y avoir des overlaps, des messages consommés dans plusieurs contextes et autres désagréments. On peut donc préférer avoir un testcontainers Kafka par contexte Spring, mais attention au nombre de contextes en cache.

Nous nous sommes heurtés à cette problématique. Nous partions d’un Kafka par contexte, ponctué de @DirtiesContext réguliers. Nous avons tenté une instance commune, comme pour MongoDB : sans succès, l’impact sur les contextes en cache était trop fort. Nous avons finalement gardé un Kafka par contexte, mais supprimé les @DirtiesContext en remplaçant nos assertions de production/consommation de messages par des assertions de delta d’offset, plus tolérantes à l’enchaînement de tests dans un même contexte. Pour éviter de trop nombreuses instances Kafka, nous avons volontairement limité la taille du cache à quatre contextes maximum, sans dégrader les performances sur nos CI.

On voit ici avec ces deux exemples qu’il faut avoir une compréhension fine de la configuration de ses tests, mais aussi des services tiers que l’on utilise pour construire une suite de tests performante, sans @DirtiesContext, avec une utilisation de cache maximale sans surconsommation de ressources.

Conclusion

Il n’est pas simple de trouver les bons compromis entre les performances, les coûts d’implémentation, les coûts d’exécution, les ressources nécessaires et ce que l’on souhaite tester. Pour aller au plus vite, nous sommes souvent tentés de faire un setup personnalisé pour les tests que nous sommes en train d’écrire. Il est aisé de rajouter un mock, de modifier des propriétés spécifiques… Progressivement, cela multiplie les contextes et réduit la pertinence du cache. Il est donc intéressant de rationaliser la définition des contextes, d’industrialiser la production de tests avec des mécanismes communs dans l’organisation et de comprendre ce qui se passe dans le framework et les dépendances tierces.

À l’ère du développement par IA, les boucles de feedbacks sont importantes et leur rapidité cruciale pour décupler les capacités de production. Pour conserver une bonne gouvernance et éviter des tests générés trop disparates, peu performants, il peut être intéressant de comprendre les enjeux abordés dans cet article.

Références

— · —