La gestion des contextes dans Spring TestContext Framework

Le framework Spring fournit toute l’infrastructure pour tester une application dans un contexte proche du déploiement en production : le framework TestContext.

S’il semble très simple à utiliser de prime abord, il est en fait très riche. Mais il est nécessaire de maîtriser certaines subtilités de son utilisation en particulier la gestion de la mise en cache des contextes Spring, si on veut pouvoir maîtriser une suite de tests non triviale.

Le framework Spring TestContext

Depuis sa version 2.5, Spring propose le framework TestContext pour faciliter les tests d’intégration.

Par opposition aux purs tests unitaires qui par définition ne nécessitent normalement pas la mise en oeuvre d’un contexte Spring, ces “tests d’intégration” ont pour but de valider l’assemblage des composants (par le conteneur Spring) et le comportement de leur collaboration dans le cadre du contexte d’exécution qu’apporte le conteneur : aspect transactionnel, translation d’exception, configuration du framework JPA, etc., pour ne citer que les plus classiques.

Souvent, ce type de test utilise une base embarquée comme H2, mais chaque projet/environnement aura ses propres contraintes/besoins.  

Le framework TestContext a en particulier pour rôle :

  • la création d’un contexte Spring suivant la définition fournie,
  • l’injection des beans Spring nécessaires dans la classe de test.  

Voici un exemple simple de test JUnit utilisant TestContext :

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { MyApplicationConfig.class})
@ActiveProfiles("test")
public class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Test
    public void testOrderService() {
        // test the orderService
    }

}

Dans l’exemple précédent, la configuration de l’applicationContext est une classe @Configuration fournie en paramètre de l’annotation @ContextConfiguration.
On a choisi ici d’utiliser un profile spécifique “testProfile” (via @ActiveProfiles) pour se positionner dans un cadre convenable aux tests (qui aura par exemple pour effet de configurer une base de données embarquée).

Le framework TestContext est assez riche et propose un grand choix d’options pour définir ou paramétrer le contexte Spring qui sera utilisé. On pourra en particulier utiliser :

  • l’annotation @TestPropertySource pour paramétrer d’une manière spécifique le contexte,
  • une ou plusieurs classes @Configuration dédiées aux tests couplées par exemple avec une annotation @ComponentScan excluant certains Bean (attention dans ce cas à bien prendre en compte tous les @ComponentScan qui seront activées : les exclusions ne s’appliquent qu’à l’annotation qui les porte, pas aux autres).

La mise en cache des Contextes Spring

La création d’un contexte Spring est assez coûteux, en particulier du fait qu’il implique l’initialisation d’autres frameworks : on peut citer par exemple l’initialisation d’Hibernate, qui est souvent un gros contributeur sur le temps de création. Pour éviter de payer ce coût à chaque test, le framework TestContext met en cache les contextes Spring : ainsi lorsque deux classes de tests utilisent exactement la même définition de contexte, elles partagent le même contexte Spring. Souvent, une seule définition de contexte Spring est réutilisée pour l’ensemble des tests d’une application : le coût de création ne sera alors payé qu’une seule fois pour tout l’ensemble.

Il arrivent parfois que d’autres configurations spécifiques de Spring soient nécessaires pour tester un aspect particulier du système. Dans ce cas, TestContext s’occupe d’identifier les différents contextes et les crée chacun à leur tour au besoin.

TestContext prend en compte tout un ensemble de paramètres pour identifier la configuration du contexte de test voulue (voir le paragraphe Context caching de la documentation ), en particulier :

  • la liste des définitions des beans (fichiers de conf XML ou classes @Configuration),
  • les profiles activés,
  • les surcharges de propriétés.

Les écueils de la mise en cache des contextes

Si la mise en cache des contextes Spring est un apport important du framework TestContext, elle n’en est toutefois pas dénuée de conséquences.

1ère conséquence : les contextes ne s’arrêtent qu’à la fin de tous les tests

Cette mise en cache implique que, par défaut, les contextes Spring ne sont clos qu’à la fin de l’exécution de l’ensemble des tests.

En particulier, tout traitement en tâche de fond géré par un bean Spring d’un des contextes restera actif jusqu’à la fin de TOUS les tests. C’est à dire que tout scheduler, tout job spring batch, tout consumer jms, … continuera à travailler jusqu’à la fin… Ce n’est généralement pas un comportement voulu et il pose des problèmes de concurrence d’exécution et des effets de bord non prévus qui mettent à mal la reproductibilité des tests (le plus souvent, on voudra donc probablement désactiver ces mécanismes pour tous les tests).

Un moyen d’arrêter un contexte Spring plus tôt est d’utiliser l’annotation @DirtiesContext.

2ème conséquence : la mise en commun de l’état du/des contextes

Avec cette mise en cache, il faut être conscient que tout effet de bord d’un test sur le contexte Spring peut impacter un autre test :

  • la même base de données embarquée sera utilisée (elle ne sera potentiellement pas vide),
  • les caches et autres états internes des beans Spring ou des services utilisés (pool de connexion, etc.) seront partagés,
  • et surtout toute modification des dépendances d’un Bean Spring ou de ses paramètres de fonctionnement ne sera pas locale à un seul test.

À l’exception du dernier point : ces spécificités sont gérables à condition de prendre en compte leur existence.

Par contre, le dernier point par contre pose problème : le contexte Spring a été “corrompu” par le test et le comportement des autres tests peut en être affecté (c’est d’ailleurs pour cela qu’il faut, en premier lieu, chercher à éviter de le faire). Lorsque cette pratique est nécessaire, il convient d’indiquer à TestContext que le contexte Spring ne peut pas être réutilisé : l’annotation @DirtiesContext a été introduite pour cela. Lorsque @DirtiesContext est utilisé, TestContext invalide le contexte Spring : il l’arrête et le retire du cache (à noter : depuis Spring 4.2, @DirtiesContext peut aussi être utilisé pour s’assurer au débutd’un test que le contexte vient d’être créé).

On notera que l’utilisation de @DirtiesContext a une conséquence immédiate : le coût de recréation du contexte au prochain test qui en aura besoin !

3ème conséquence : les problèmes de cohabitation

Le framework Spring est très bien fait. Faire cohabiter plusieurs instances de contextes Spring ne pose normalement pas de problème en soi. Ce n’est toutefois pas forcément le cas des frameworks qu’il met en oeuvre (caches statiques, par exemple) ou des effets de bords de ces contextes Spring sur un environnement commun.

Exemple : traitements lancés en tâche de fond

J’ai déjà parlé des problèmes potentiels sur un contexte Spring qui héberge des traitements en tâche de fond. Je le répète toutefois ici, car c’est un point important qui peut mettre à mal la stabilité d’une TestSuite.

Exemple : le drop de la base embarquée

Une base embarquée initialisée par Hibernate avec l’option hibernate.hbm2ddl.auto=create-drop (création des tables à l’initialisation puis suppression à la fermeture de l’entityManagerFactory) peut poser problème si plusieurs contextes Spring utilisent la même base (on notera que c’est l’option par défaut de Spring Boot).

Dans les cas nominaux, les contextes Spring sont créés au fur et à mesure et ne sont arrêtés qu’à la fin des tests. Cela ne posera alors pas de soucis. Mais si :

  • un des contextes échoue à s’initialiser (un problème d’injection par exemple),
  • on a besoin d’utiliser @DirtiesContext,

l’extinction du contexte Spring aura pour conséquence un drop des tables de la base embarquée. Ce drop fera à coup sûr échouer les autres tests interagissant avec la base de données.  

On peut imaginer deux moyens pour éviter ce problème :

  • configurer Hibernate en “create” uniquement (le plus simple à mon sens), ainsi une extinction d’un contexte (voulu ou non) ne supprimera pas les tables,
  • utiliser une base embarquée différente pour chaque configuration de test différente.

Exemple : CacheManager Ehcache

Lorsqu’on utilise le support du caching par SpringBoot avec Ehcache, un CacheManager non nommé est créé automatiquement (cf. EhCacheCacheConfiguration.ehCacheCacheManager() et EhCacheManagerUtils.buildCacheManager()). Deux contextes SpringBoot différents vont chercher à créer le même CacheManager non nommé, ce qui n’est pas supporté par Ehcache qui renvoie une erreur.

Dans ce cas, une solution est de surcharger la création du CacheManager, en lui donner un nom unique à chaque contexte (un UUID par exemple).

Sur la cohabitation

Ce ne sont ici que quelques exemples, le point important est de bien prendre en compte le fait que l’utilisation de plusieurs contextes Spring peut avoir des conséquences inattendues qu’il faut être prêt à identifier et corriger. Il pourra parfois être nécessaire de définir plusieurs TestSuite et de les exécuter les unes après les autres de manière indépendante pour éliminer ce type de problème (les utilisateurs de Maven pourront par exemple définir plusieurs du plugin surefire et/ou utiliser failsafe en plus de surefire).

Conclusion et éléments de choix

Il appartient à chacun d’évaluer et de challenger son besoin d’utiliser plusieurs contextes Spring au sein de sa suite de tests :

  • quel est le but recherché ?
  • y a-t-il un autre moyen de le faire plus proprement ?
  • de quelle manière les faire cohabiter au mieux ?

On notera que d’un point de vue du temps d’exécution global, il n’est pas grave voire plutôt intéressant d’utiliser un unique “gros” contexte Spring de test : il répond aux besoins de tous les tests et son coût de création n’est payé qu’une fois pour toute l’exécution de la suite de test (s’il n’est jamais marqué “dirty”). En contrepartie, l’exécution d’un test spécifique dans une suite paiera ce coût plus important de création à chaque exécution ce qui peut potentiellement être gênant lors du développement.

Author image
Fort de plus de 20 ans d'expérience autour de l'écosystème JavaEE, Fabien accompagne ses clients dans la conception et le cadrage de leurs projets.