Vos tests se disputent la base de données? Remettez les compteurs à zéro!

Simon B
WhozApp
Published in
5 min readJun 12, 2018

TL;DR

Deux solutions sont proposées pour éviter les interférences entre les tests chargeant un contexte Spring, quand ceux-ci travaillent sur un même dataset (ou uniquement un référentiel) stocké en base.

  • 1ère option: Rollback de transaction - si une seule transaction engagée.
  • 2ème option: Faire un reset de la base de données entre les tests - si plusieurs transactions (traitement dans un thread différent, commits intermédiaires, etc.)

D’où vient la fuite?

Si vous avez choisi d’implémenter des tests partageant un contexte spring + base in-memory OU une base rémanente, vous êtes concernés.

Prenons l’exemple d’une application Spring qui provisionne la DB, au démarrage du contexte, avec un ensemble de données constituant un référentiel de base: l’ensemble des paramétrages métier, la liste des pays, les devises, les agences du client, etc.

L’application permet d’administrer ces données. Certains tests vont valider les fonctionnalités d’admnistration en supprimant, modifiant ou ajoutant des entrées dans le référentiel. D’autres vont tester les portions de code qui utilisent ou du moins référencent ces données. En partageant le contexte Spring et par conséquent la base (en mémoire ou non), il se peut que les tests se téléscopent.

Lorsque vous avez des centaines voir des milliers de tests, la recherche du test incriminé devient un enfer. Car évidemment, votre nouveau test passe bien lorsqu’il est lancé seul. Mais lancé conjointement aux autres tests, il tombe en échec. Ou pire, le résultat varie selon l’ordre d’exécution des tests. C’est à dire qu’il peut passer chez vous, mais pas chez le collègue / CI. Qui vous amène directement à la case:

Chez moi, ça passe.

Quelles solutions pouvons-nous imaginer?

  1. Ordonner les tests et faire en sorte que les tests d’administration soient joués en dernier,
  2. Rollback applicatif - code spécifique rattrapant les modifications,
  3. Ajouter une clé sur toutes les données en base. Un peu comme ce que l’on fait pour le multi-tenant,
  4. Rollback de transaction - applicable lorsque le test et le traitement sont inscrits dans la même transaction,
  5. Utilisation du @DirtiesContext sur les tests sales pour forcer la fermeture du contexte (et le démarrage d’un nouveau) et donc de la base,
  6. Remise à zéro de la base après chaque test.

Ordonner les tests

C’est une fausse bonne idée. Nous devons assurer l’isolation des tests. Les ordonner pourrait masquer le problème. Un test salissant le référentiel de données peut ne pas être inquiété jusqu’au jour où un autre test utilisera (le plus souvent par rebond et non pas en référence directe) la donnée souillée et tombera en échec. Que ce dernier soit propre ou non, d’ailleurs.

Le test passera s’il est joué avant le mécréant. Mais il échouera dès qu’il sera joué après la crapule.

Une enquête fastidieuse sera alors menée pour retrouver le fautif. Avec plusieurs centaines ou milliers de tests, l’enquête sera longue et le criminel sévèrement puni. #baillisDuLimousin

Rollback applicatif

Commençons par un exemple:

Notez que le webEnvironment = RANDOM_PORT . Cela signifie qu’un EmbeddedWebApplicationContext a été chargé ainsi qu’un serveur web réel. Nous sommes sur un test “côté client”. Par conséquent, l’invocation dans le test ne partage pas la transaction avec le controller, service, repository. Nous ne pouvons pas nous contenter d’un rollback de la transaction au niveau du test. Il n’y aurait aucune incidence sur la base.

Il en aurait été de même avec un webEnvironment = MOCK (serveur web mocké) et un service asynchrone, c’est à dire dans un thread différent. Là encore, la transaction n’aurait pas été partagée entre le test et le traitement.

La solution ici est de supprimer ce qui a été inséré en base, après le test. cf. le block cleanup:

Segrégation des données

Il s’agit de rajouter un attribut sur chacune des tables. Le code de l’application doit considérer ce champs dans chacun de ses accès et isoler les données ne partageant pas la même valeur pour cet attribut.

Ensuite, nous renseignons ce champs avec une valeur unique par test ou suite de tests: ça implique de désactiver le provisioning des données au démarrage de l’app et de le jouer spécifiquement avant chaque test.

L’impact sur le modèle et l’application en elle-même paraît overkill, comparé aux autres options.

Rollback de la transaction

La solution incroyablement simple. Il vous suffit de déclarer la classe de test comme étant @Transactional !

Attention! Il faut impérativement que le test et le code testé partagent la même transaction. Si pour x raison, ça n’est pas le cas, il faudra choisir une autre solution.

“C’est sale!”

L’autre solution pourrait venir de Spring, évidemment. Nous est proposée une annotation très pratique: @DirtiesContext

Test annotation which indicates that the ApplicationContext associated with a test is dirty and should therefore be closed and removed from the context cache.Use this annotation if a test has modified the context - for example, by modifying the state of a singleton bean, modifying the state of an embedded database, etc. Subsequent tests that request the same context will be supplied a new context.

C’est une solution acceptable. L’isolation est assurée. Malheureusement, ça n’est pas des plus rapides ; ça implique de recharger le contexte, démarrer la base (ok, c’est pas le plus lent) et la provisionner.

L’idéal serait de ne s’occuper que de la base!

Reset de la base

Tada! Contentons nous de remettre à zéro la base avant chaque test. Voici une solution simple:

C’est un trait Groovy qui dump la base avant le test, si ça n’a pas déjà été fait. Le FileUtils.tempDirectoryPath pointe sur un répertoire temporaire propre à la JVM du/des tests partageant le même contexte Spring.

Le fait de dumper la base aussi tardivement permet de profiter du provisioning qui aurait pu être fait par un Liquibase ou par un traitement custom joué juste après le démarrage du contexte (cf. CommandLineRunner).

Quoiqu’il arrive, un chargement du fichier dump stocké localement permet de remettre à zéro la base de données pour le test suivant.

Pour le mettre en pratique, il suffit d’implémenter le trait:

Je recommande donc de privilégier le rollback de transaction dès que possible et de basculer sur la remise à zéro de la base sinon.

Si vous avez imaginé d’autres solutions, je serai très intéressé de les connaître. N’hésitez pas à laisser un commentaire.

--

--