Pour qu’un test soit rejouable à l’infini (on dit qu'il est idempotent), il ne doit pas modifier l’état du système (c'est ce qu'on appelle l'immutabilité). La base de données est l’archétype de la mutabilité car elle est amenée à changer d'état en permanence, elle est donc incompatible avec l’exécution de tests automatiques.
Pourtant, il est important de pouvoir tester l’ensemble d’un système, en effectuant notamment des tests d’intégration avec la base de données.
La première solution pourrait être de ne pas faire de tests d’intégration avec la base de données. C’est un choix, et il peut se justifier par moments. Si vous avez prévu de faire des tests d’interface ou des tests manuels qui vont vérifier les différents scénarios impliquant la base de données, alors cela peut avoir un sens de ne pas faire de tests d’intégration automatiques avec la base de données.
Il pourrait donc être envisageable de créer une fausse classe bouchon, un repository en mémoire, par exemple, pour simuler tous les accès à la base de données.
Les autres solutions impliquent d’avoir une moyen de maîtriser l’état de la base de données afin qu’elle soit parfaitement connue et que cet état n’évolue pas sous l’action des tests automatiques au fil du temps.
Voici un petit panorama des techniques que j’ai vues au cours de ma carrière, ainsi que leurs avantages et leurs inconvénients. Chaque entreprise et chaque projet est unique, avec ses problématiques et son contexte particulier. Choisir une solution implique de faire des concessions par rapport à ce contexte et de trouver la meilleure solution possible.
Scriptez complètement la création de la base de données
Cette solution consiste à avoir une base de données vierge, à construire son schéma, et à remplir la base de données avant l’exécution de chaque test ; puis à la vider après chaque exécution.
Concrètement, il va s’agir d’écrire un script SQL qui va se charger de créer les tables, les vues, les procédures stockées, les index, les contraintes d’intégrité et les données de la base de données. Il est inutile de créer l’intégralité de la base de données, on peut se concentrer uniquement sur le cas de test qui nous intéresse.
Avantages :
On maîtrise complètement le schéma et les données.
Le test est idempotent.
Le temps d’exécution du test est peu influencé. En effet, un script s’exécute relativement rapidement, surtout si on le limite au cas de test qui nous intéresse.
Inconvénients :
Il faut maintenir un script d’initialisation de la base de données.
Il est nécessaire de faire évoluer le script avec le temps.
Les inconvénients prennent toute leur importance si la solution évolue très souvent. Une mise à jour du schéma lors d’une évolution a un fort impact sur les tests qui ont déjà été écrits. Il peut y avoir un coût de maintenance important du ou des scripts d’initialisation.
Il faudra également mettre à jour les données pour les évolutions ou les nouveaux cas de tests.
Il y a également un risque non négligeable, lorsque l’on travaille à plusieurs, qu’une donnée soit modifiée par un développeur pour son cas de test et que cela impacte un autre test. Il va falloir alors communiquer avec l’autre développeur pour trouver une bonne solution, avec le risque que le développeur (paresseux par nature) abandonne son cas de test ou laisse l’autre en échec en espérant que quelqu’un le répare un jour. Cela nécessite une grande rigueur de la part des développeurs pour s’imposer de ne jamais modifier de données existantes.
Utilisez une transaction
Le principe est de faire en sorte que son test n’écrive pas vraiment dans la base de données et que toutes les données prêtes à modifier l’état de la base soient annulées à la fin de la transaction. On parle de rollback.
Avantages :
Ce principe peut être utilisé sur une base non dédiée aux tests automatiques.
Il peut être utilisé au niveau du code ou au niveau du système (si l'on utilise ODBC, par exemple).
Inconvénients :
La transaction système n’est pas toujours compatible.
Cela n’exécute pas vraiment le code sur la base de données donc on peut rater certains éléments (trigger, contraintes d’intégrité, etc.).
Il faut écrire le code pour intégrer un mécanisme de transaction pour chaque requête, alors qu’il n’est pas forcément utile pour l’application.
Cette solution semble intéressante théoriquement, mais en pratique, elle est assez complexe à mettre en place. Je ne la recommande pas spécialement, sauf en cas de contraintes particulières sur les transactions.
Utilisez un backup de la base de données
Le principe de cette solution est de monter une base de données (ou de récupérer une base de données à un instant t), d’en créer un backup et de la restaurer avant chaque test. Ainsi, chaque test repartira sur une base de données connue sur laquelle on pourra faire ce que l’on voudra, vu que nous la restaurerons pour le test suivant.
Avantages :
Nous maîtrisons complètement le schéma et les données.
Le test est idempotent.
Inconvénients :
Le temps d’exécution d’un test est démultiplié. Restaurer une base de données est très long et nécessite plusieurs minutes en fonction de la taille de la base.
Il est difficile de faire évoluer le backup lors de modifications ou d’ajouts d’un jeu de tests.
Cette solution est assez séduisante, mais il y a certaines limitations à prendre en compte. Elle n’est applicable que lorsque le temps d’exécution des tests n’a pas trop d’importance (par exemple lorsqu’ils sont lancés la nuit). En conséquence, le feedback de l'exécution d’un test est très long et peut impliquer que l’on ne découvre un impact sur son développement que le lendemain, voire plus tard. Ainsi, pas mal de temps est perdu et il faut se replonger dans une correction, alors que nous n’en avons peut-être plus le temps.
De la même façon, lors des évolutions de schémas ou de données, il y a un processus à mettre en place. Il faudra restaurer le backup, faire les modifications et créer un nouveau backup. Cela implique que quelqu’un soit garant de l’intégrité de ce backup, ce qui parfois peut s’avérer compliqué. Il faut aussi avoir un historique des backups pour pouvoir revenir à une ancienne version si jamais le backup est corrompu.
Donc, c'est du travail supplémentaire... 🙂
Utilisez du code dans le test pour nettoyer la base de données après l’exécution du test
Cette solution aussi est intéressante. Elle consiste à faire en sorte que ce qui a été modifié lors d’un test soit nettoyé à la fin. Par exemple, si mon test crée un nouveau client, alors je le supprime à la fin du test.
Avantages :
La mise en place est simple.
Inconvénients :
Il y a un risque de corruption de la base de données si le test échoue ou si le nettoyage échoue.
Il devient compliqué de faire évoluer le schéma en étant certain que le nettoyage continue d’être pertinent.
Avec cette solution, on constate qu’il y a souvent un couplage entre le script de nettoyage et les détails d’implémentation de la base de données. Cela fait que, si une évolution est faite sur la façon dont sont stockées les données, alors il faudra modifier le script de nettoyage. C'est un coût de maintenance qu’il faut prendre également en compte.
Cette solution est intéressante quand la base de données n’est pas très complexe et que les liaisons entre les tables ne sont pas trop nombreuses.
Utilisez un framework de test
Il existe des frameworks de test, comme NDbUnit, dont le but est de simplifier toutes ces phases de maîtrise d’état. Globalement, le principe est d’avoir un fichier XML qui décrit les données que l’on veut avoir pour un test.
Avantages :
Nous ne partons pas de rien.
Inconvénients :
Maintenir les fichiers XML est encore plus compliqué qu’un script d’initialisation de la base de données.
Bien que séduisante sur le papier, cette solution est complexe à mettre en œuvre. Le principe de description des données à utiliser dans un fichier XML est assez verbeux et compliqué. Cela devient d’autant plus compliqué lorsque plusieurs personnes interviennent sur les fichiers XML. Les fusions de code deviennent très chronophages et risquées.
Passons à un exemple d’implémentation !
Je vous propose d’utiliser la solution où nous allons implémenter un script de création et d’alimentation de la base de données pour tester la récupération de la météo.
Je vous invite à suivre les étapes du chapitre précédent, nommez la base de données TestAuto. Et c’est tout ce que nous allons faire pour l’instant.
Il faut maintenant créer un projet de test d’intégration. Au niveau de Visual Studio, il faut toujours créer un projet de type « Projet de test unitaire ». Pour bien en différencier l’intention, nous allons l’appeler Jeu6.IntegrationTests
.
Reprenons notre précédent test et déplaçons-le dans ce nouveau projet de test d’intégration :
[TestClass]
public class FournisseurMeteoTests
{
private const string _connectionstring = @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=TestAuto;Integrated Security=True;";
[TestMethod]
public void QuelTempsFaitIl_AvecDuSoleil_RetourneDuSoleil()
{
// arrange
IFournisseurMeteo fournisseurMeteo = new MeteoRepository(_connectionstring);
// act
var temps = fournisseurMeteo.QuelTempsFaitIl(DateTime.Now);
// assert
temps.Should().Be(Meteo.Soleil);
}
}
Attention à la ligne 4, le Catalog dans la chaîne de connexion a changé, on utilise maintenant la base de données TestAuto. De même, à la ligne 13, nous passons en paramètre la date du jour.
Vous pouvez démarrer le test, il ne passe pas. À juste titre, car il n’y a rien dans la base de données ; ni tables ni données.
Nous allons commencer par créer une méthode qui permet de créer la table. Cette méthode devant être créée avant chaque test, nous la décorons de l’attribut TestInitialize
:
[TestInitialize]
public void CreationTables()
{
using (var connection = new SqlConnection(_connectionstring))
{
connection.Execute(@"CREATE TABLE [dbo].InfosMeteo
(
[Valeur] VARCHAR(10) NOT NULL,
[Date] Datetime NOT NULL
)");
}
}
C’est très simple, nous exécutons la commande SQL qui crée la table.
De la même façon, nous allons devoir supprimer la table à la fin d’un test. Créons une nouvelle méthode décorée cette fois-ci de l’attribut TestCleanup
:
[TestCleanup]
public void SuppressionTables()
{
using (var connection = new SqlConnection(_connectionstring))
{
connection.Execute(@"DROP TABLE [dbo].InfosMeteo");
}
}
Nous retrouvons l’instruction pour supprimer la table.
Il ne reste plus qu’à faire notre insertion dans la partie Act du test :
[TestMethod]
public void QuelTempsFaitIl_AvecDuSoleil_RetourneDuSoleil()
{
// arrange
Insertion("Soleil");
IFournisseurMeteo fournisseurMeteo = new MeteoRepository(_connectionstring);
// act
var temps = fournisseurMeteo.QuelTempsFaitIl(DateTime.Now);
// assert
temps.Should().Be(Meteo.Soleil);
}
private void Insertion(string temps)
{
using (var connection = new SqlConnection(_connectionstring))
{
connection.Execute(@"INSERT INTO dbo.InfosMeteo values (@temps, Convert(date, getdate()))", new { temps });
}
}
Et le tour est joué, le test passe !
Il devient donc très simple de vérifier que nous obtenons tous les types de temps possibles :
[TestMethod]
public void QuelTempsFaitIl_AvecDeLaPluie_RetourneDeLaPluie()
{
// arrange
Insertion("Pluie");
IFournisseurMeteo fournisseurMeteo = new MeteoRepository(_connectionstring);
// act
var temps = fournisseurMeteo.QuelTempsFaitIl(DateTime.Now);
// assert
temps.Should().Be(Meteo.Pluie);
}
[TestMethod]
public void QuelTempsFaitIl_AvecDeLaTempete_RetourneDeLaTempete()
{
// arrange
Insertion("Tempete");
IFournisseurMeteo fournisseurMeteo = new MeteoRepository(_connectionstring);
// act
var temps = fournisseurMeteo.QuelTempsFaitIl(DateTime.Now);
// assert
temps.Should().Be(Meteo.Tempete);
}
Vous remarquerez que le temps d’exécution des tests a légèrement augmenté. Cela reste négligeable ici, car nous faisons nos tests automatiques sur une base de données locale et nous avons peu d’instructions. Prenez quand même garde au temps d’exécution que pourrait prendre un script qui crée et alimente une grosse base de données. Vous aurez probablement intérêt à séparer les créations de tables en fonction des tests, afin de garder un temps d’exécution raisonnable permettant d’obtenir un feedback rapidement sur une éventuelle régression.
Et concrètement ?
Au cours de ma carrière, j’ai essayé l’intégralité des techniques que je vous ai présentées. Chacune a ses avantages et ses inconvénients.
Ce que je remarque, d’une manière générale, c’est qu’il est très fréquent qu’une évolution de l’application engendre une évolution au sein du schéma de la base de données.
Cela implique souvent pas mal de maintenance de tests, car il faut modifier les scripts d’initialisation ou modifier les backups. Bref, faire évoluer l'état initial de la base de données.
Une base de tests va forcément se dégrader avec le temps. Des tests ne passeront plus du jour au lendemain, et il va falloir analyser pour comprendre le pourquoi et surtout réparer le test (ou le code). Ce que j’ai constaté, c’est que beaucoup de développeurs n’aiment pas ça, surtout s’ils ne se sentent pas responsables du code.
Oh, ça, je le laisse cassé, c’est untel qui s’en occupera.
Sauf que le untel pense globalement la même chose et personne ne corrigera le test.
C’est d’autant plus vrai si le test ne passe pas depuis un moment. Si on laisse un test en erreur plusieurs jours, alors vous pouvez parier que le test sera supprimé de la base de code très vite afin de faire en sorte que les tests repassent à nouveau.
Si le test a été écrit et que vous suivez mes conseils sur la valeur d’un test, alors c’est que le test a été écrit pour de bonnes raisons. Il ne faut donc pas le supprimer, mais le corriger.
Oui, mais j’ai des délais à respecter, je n’ai pas le temps, blablabla, on verra ça plus tard.
Oui, je sais, comme tout le monde. « On verra plus tard » veut dire « on ne le fera pas ». Le temps à allouer n’est pas négligeable pour l’écriture et la maintenance des tests, mais il ne peut être que bénéfique.
Du coup, la solution est de respecter un bon ratio vis-à-vis de la pyramide des tests. Si vous passez trop de temps à maintenir vos tests d’intégration, c’est peut-être parce que vous en avez trop écrit ? (et sans doute que certains ont peu de valeur)
Tout ceci peut s’améliorer si l'on s’impose un versonning de base de données. En effet, cela permettra que d'éventuelles modifications ne soient pas oubliées sur la base de tests automatiques.
Prenez garde aux limitations
Certains frameworks de tests exécutent les tests en parallèle. Avec ces techniques de création/restauration, ce n’est plus possible. Il vous faudra soit désactiver cette exécution en parallèle, soit écrire du code dans votre série de tests qui verrouille les autres tests pendant qu’un test est en cours d’exécution.
Ce verrouillage implique également un risque de deadlock et nécessite de déterminer un timeout sur l’exécution d’un test.
Notes sur la BDD de tests
Si c’est possible, n’hésitez pas à utiliser une version conteneurisée du serveur de base de données. Vous pouvez même créer des couches différentes avec des données particulières ou instaurer l’utilisation de volumes différents en fonction du test.
Cela a notamment un intérêt lors de l’écriture d’un test (ou pour jouer les tests sur un environnement de développement) afin de ne pas impacter la base de test automatique.
Suivant les SGBD ou les ORM utilisés, il peut arriver que l’on ait à disposition une version in-memory de la base de données. N’hésitez pas à utiliser ce genre de pratique, car cela peut accélérer grandement l’exécution des tests et la mise en place des stratégies de création ou de restauration de base de données. Il y a un risque de passer à côté d’une fonctionnalité du SGBD, mais ce risque est tout à fait acceptable pour un environnement de développement.