Tests d’application Spring: Stop aux rechargements intempestifs

Simon B
WhozApp
Published in
6 min readJun 20, 2018

TL;DR

Nous utilisons souvent différentes configuration Spring pour les tests. Tantôt pour tester la sécurité, tantôt pour se concentrer sur la couche Web ou la couche d’accès aux données.

Le changement de config implique la fermeture du premier contexte pour le lancement du nouveau. Le démarrage prend plusieurs secondes voir dizaines de secondes. Or nous aimons que nos tests soient rapides.

L’objet de cet article est de décrire rapidement le “chargement partiel” de contexte puis de proposer une solution pour minimiser le nombre de rechargements.

Contextes Spring

Admettons que vous développiez une API avec Spring Boot : Une couche Web, un ensemble de services ainsi qu’un stockage des données dans une base. Rien de bien exotique. Au moment de tester, que ce soit avant ou après le développement de la fonctionnalité désirée, il est fort probable que vous débutiez par des tests unitaires. Bien!

Pour le service trainingSessionService, vous allez ajouter un trainingSessionServiceUnitTest dans lequel vous testerez les méthodes une à une, dans des scénarios nominaux puis d’autres aux limites. L’avantage des tests unitaires est qu’ils couvrent un périmètre restreint et sont censés s’exécuter rapidement. Ce sont les meilleurs candidats pour augmenter la couverture de tests, au passage.

Ensuite viendra le temps des tests d’intégration. Dans le cas d’une application Spring, nous utilisons un contexte chargé au démarrage. Tous nos éléments, que ce soient des controllers, services ou autres repositories, sont instanciés par le framework et stockés dans le contexte d’application. Ce contexte est passablement long à charger. Il est préférable de s’en passer dans le cas des tests unitaires. Nous perdrions la rapidité d’exécution ; la portée du test unitaire n’exige de toute façon pas de charger plusieurs composants.

Pour le reste, nous allons devoir charger un contexte Spring dès l’apparition d’un nombre conséquent de composants à instancier et injecter, que ce soit des composants à vous, du framework Spring ou d’autres libs.

Il est à noter qu’un contexte Spring dépend, entre autres, de l’@ActiveProfile et des annotations utilisées pour définir ce que vous souhaitez charger (EmbeddedWebApplicationContext ou simple ApplicationContext, etc.). Ce qui signifie que deux (suites de) tests consécutifs avec des profils différents forceront un nouveau chargement de contexte Spring. A l’inverse, deux tests avec une même configuration seront exécutés dans le même contexte.

Si un test modifie le contexte d’application, en modifiant l’état d’un composant par exemple, il est possible de l’annoter @DirtiesContext. Un rechargement du contexte Spring sera alors effectué pour le test suivant.

D’autre part, il est possible de charger une base en mémoire. H2, par exemple. Cette base est liée au contexte Spring, bien évidemment. Si le contexte redémarre, la base est détruite puis à nouveau chargée. A l’inverse, si plusieurs tests partagent le même contexte, ils partageront également la même base, en l’état.

Une tranche de test

Le test d’intégration valide l’interaction entre plusieurs composants. Le chargement de la totalité du contexte d’application n’est pas forcément nécessaire. Nous pouvons tester séparément la couche web de la couche service ou encore de celle d’accès aux données.

L’annotation WebMvcTest permet de se concentrer sur les composants Spring MVC. N’est appliquée que la configuration nécessaire aux tests des @Controller, @ControllerAdvice, etc. Les @Component , @Service ou @Repository sont ignorés. A noter que l’auto-configuration Spring Security est également activée. Voici un exemple:

Le test est super simple. Mais vous pouvez imaginer tester la gestion des exceptions, les convertisseurs json, etc.

Dans la même veine, l’annotation DataJpaTest facilite les tests portant sur l’accès aux données. En voici un exemple:

Une base in-memory est configurée. Hibernate, Spring Data et une DataSource sont auto-configurés. Enfin, un @EntityScan permet de détecter les classes du domaine marquées @Entity.

Chargement partiel du contexte

Pour ce qui est de la couche Web, il y a encore une distinction à faire. Pour tester vos controllers, vous utilisez l’annotation suivante pour charger tout le contexte d’application web:

@SpringBootTest(webEnvironment = RANDOM_PORT)

Le webEnvironment peut prendre les valeurs suivantes:

  • MOCK: Crée un WebApplicationContext et un mock de serveur Web (HTTP et conteneur de servlets). Il s’agit donc d’un test “côté serveur”,
  • RANDOM_PORT / DEFINED_PORT: Crée un EmbeddedWebApplicationContext ainsi qu’un serveur Web. C’est un test “côté client”,
  • NONE: Crée un ApplicationContext seulement ; et non pas toute la couche Web qui n’est en théorie utile que pour les tests sur les controllers & co. et les tests de bout-en-bout.

Ordonnancement des tests

Pour rappel, l’objet des tests est de déceler les erreurs dans le code ajouté ; que ce soit pour se prémunir des régressions ou pour valider les nouvelles fonctionnalités.

Idéalement, vous aurez mis en place une intégration continue qui lancera le build et l’exécution des tests. Le job pourra aussi poursuivre avec un merge de la feature branch vers une branche commune, suivi du déploiement de l’application sur un environnement de recette. Tout ça pour dire que l’intégration joue, entre autres, un rôle de garde-fou. Les tests seront joués au moins une fois, par la CI. Certains développeurs en profiteront d’ailleurs pour commiter rapidement et attendront le résultat du build plutôt que de jouer tous les tests en local.

Que ce soit sur la CI ou en local, l’objectif est d’obtenir une réponse le plus rapidement possible. Par conséquent, il s’agit de lancer les tests les plus rapides en premier, c’est à dire les tests unitaires. Ensuite vient le tour des tests d’intégration puis les tests de bout-en-bout.

Qu’y a-t-il derrière ces différentes familles? Les tests unitaires portent sur un seul composant de l’application. Il n’y a pas d’intéraction avec les autres composants. Un test d’intégration, quant à lui, permet de valider les interactions entre plusieurs composants. Les tests de bout-en-bout, naturellement, testent l’application comme une boîte noire, c’est à dire de la manière dont un utilisateur (ou application cliente) l’utiliserait.

Dans le cas des tests nécessitant le chargement d’un contexte Spring, il s’agit d’ordonnancer les tests pour minimiser le nombre de rechargements. Je ne parle pas d’ordonner les suites de tests même si JUnit permet de le faire, mais plutôt de regrouper les tests partageant un même contexte - ie. demandant la même config.

L’ordre pourrait masquer d’éventuelles salissures du contexte mais surtout des données en base. L’étanchéité des tests est tout à fait compatible avec un ordre d’éxécution aléatoire. Même s’il s’avère qu’avec le système que je vais décrire, il y aura un ordre d’exécution des catégories de tests.

Catégorisation

Voilà les catégories de tests, selon leur besoin vis à vis du contexte d’application Spring:

  • IntegrationTests: ApplicationContext et base H2
  • IntegrationWebTests: EmbeddedWebApplicationContext et base H2
  • IntegrationSlicedWebTest: Tests annotés @WebMvcTest
  • IntegrationSlicedJpaTest: Tests annotés @DataJpaTest

Il y a plusieurs techniques pour catégoriser les tests avec Gradle. Une des plus élégantes, selon moi, est d’utiliser les catégories JUnit, compatible avec Spock. Voilà quelques catégories:

Il y a volontairement beaucoup de catégories. Vous pouvez également définir des périmètres fonctionnels grâce à ces catégories.

Vous pouvez voir, dans les 2 premiers snippets, comment marquer un test avec une ou plusieurs catégories JUnit: Simplement avec @Category

Configuration Gradle

Maintenant que vos tests sont qualifiés, vous pouvez configurer votre build Gradle pour lancer les suites de tests dans des tâches différentes:

De nombreux articles et la documentation Gradle vous aideront dans cette dernière partie. Prenez le temps pour définir le graphe des tâches afin de diminuer au maximum la durée avant échec, si échec il doit y avoir.

C’est encore plus pertinent si vous êtes adepte du TDD, étant donné que vous coderez un test (en échec, par définition) avant la fonctionnalité.

Nous verrons dans un prochain article comment gérer le partage de la DB entre les tests d’une même catégorie. ie: partageant le même contexte Spring.

--

--