[.NET] Comment réaliser des tests unitaires sur une couche d’accès aux données qui utilise une base de données ?

Lorsque l’on développe sa couche d’accès aux données (à la main, via Entity Framework ou tout autre ORM) il est nécessaire comme tout code de le tester ! Beaucoup de développeurs ne testent pas leur couche d’accès aux données car ils considèrent que « cela ne se fait pas ». Or, pour moi, tout code qui est pondu par un développeur doit être testé sans exception ! Un code non testé est un code dont le développeur ne peut garantir son fonctionnement…

Cet article a pour vocation de vous montrer différentes stratégies qui existent et qui sont utilisés (que j’ai déjà rencontré à travers différentes missions) avec leurs inconvénients. A la fin de cet article, j’expliquerai une stratégie simple que j’applique systématiquement depuis 8 ans pour tester les couches d’accès aux données via des tests unitaires.

Que doit-on tester dans une couche d’accès aux données ?

La couche d’accès aux données contient des méthodes qui réalisent des opérations techniques pour aller récupérer ou mettre à jour des données (par exemple ouvrir une connexion à SQL Server, exécuter la requête, lire le résultat de la requête,…). Voici un exemple d’une méthode qui effectue ce genre d’opération :

public Produit GetProduit(int id)
{
    using (SqlConnection connexion = new SqlConnection())
    {
        connexion.ConnectionString = ConfigurationManager.ConnectionStrings["GestionProduits"].ConnectionString;

        using (SqlCommand command = new SqlCommand())
        {
            command.Connection = connexion;
            command.CommandText = "SELECT Référence, Désignation FROM Produit WHERE Id = @id";

            command.Parameters.AddWithValue("id", id);

            connexion.Open();

            using (SqlDataReader reader = command.ExecuteReader())
            {
                if (reader.Read() == true)
                {
                    Produit produit;
                    produit = new Produit()
                    {
                        Id = id,
                        Référence = (string)reader["Référence"],
                        Désignation = (string)reader["Désignation"]
                    };

                    return produit;
                }
                else
                {
                    return null;
                }
            }
        }
    }
}

Comme tout code, il est nécessaire de le tester et si possible d’automatiser le test ! J »attire votre attention sur le fait que durant vos tests vous devez tester aussi votre base de données… En effet, la couche d’accès aux données dépend du fournisseur de données mais aussi de la structure de la base de données sur lequel repose votre application.

Dans la majorité des cas (et malheureusement…) ce sont les développeurs qui conçoivent la base de données, vous devez donc vous assurer que les contraintes que vous mettez en place sont correctes (par exemple une colonne nullable ou non nullable qui aurait été mal définie, une contrainte de suppression en cascade manquante,…). Tester votre couche d’accès aux données, c’est donc aussi tester le schéma de votre base de données ! Attention, il ne s’agit pas de tester les performances de la base de données, mais bien le schéma de la base de données !

Je suis souvent choqué d’entendre dire de la part des développeurs (mais surtout aussi des soit-disant expert ou architecte) qu’il n’est pas nécessaire de tester le code qui se trouve dans une couche d’accès aux données… Lorsque l’on me dit cette affirmation, je leur pose la question « Pourquoi ? » et j’obtiens comme réponse :  Ce code sera appelé indirectement et testé par les testeurs ou les responsables métiers de l’application… (Bah voyons ! Ces gens-là vont-ils réellement tester toutes vos méthodes de votre couche d’accès aux données ? Avec tous les cas possibles ? Dans un gros projet, c’est quasiment impossible ou trop difficile… Certaines méthodes seront testées partiellement ou pas involontairement…

Vous devez donc tester au maximum votre code ! Un développeur qui pond du code et qui ne le test pas, c’est un développeur qui ne maîtrise pas ses développements et qui doit se reconvertir dans un autre métier avec moins de responsabilité ! (Comme être responsable du rayon slip d’une grande surface…)

Nous allons voir maintenant les différentes possibilités pour tester notre classe d’accès aux données.

« Mock-er » la base de données

C’est souvent cette option que j’ai vu dans beaucoup de projets, cette solution à l’avantage de ne pas nécessité l’utilisation d’un serveur de base de données… L’inconvénient de cette solution c’est qu’elle nécessite de :

  • Découpler votre code afin de pouvoir moquer des objets techniques d’ADO .NET, Entity Framework,…. Ce qui n’est en soit pas très facile…
  • Mock-er la base de données ne teste pas réellement le comportement de votre base de données (et donc son schéma).
  • Mock-er consiste à coder le comportement de votre base de données ce qui peut nécessiter beaucoup de temps durant la réalisation de vos tests.

« mock-er » la base de données n’est donc pas une bonne solution pour tester sa couche d’accès aux données, en revanche je recommande de mock-er la couche d’accès aux données lorsque vous voulez tester les couches supérieures (par exemple la couche métier).

Utilisation d’une base de données pour tester la couche d’accès aux données

L’autre solution pour tester notre couche d’accès aux données est d’utiliser tout simplement une base de données… Il suffit donc de créer une base de données de la remplir avec un jeu d’essai et le tour est joué ! Mais il y a un petit problème… Lorsque vous allez exécuter vos tests unitares, certains vont supprimer des données, d’autres les modifier,…. Cela peut donc engendrer des problèmes pour les tests unitaires suivants.

Pour pallier à ce problème, certains développeurs créent au démarrage de leurs tests unitaires, une transaction et déclenchent un ROLLBACK à la fin. Ainsi votre base de données de test se retrouve systématiquement dans son état original. L’inconvénient de cette stratégie c’est qu’elle crée une transaction ce qui peut provoquer des « perturbations » dans votre code lorsque vous voulez tester un code qui réalise des transactions sur votre base de données. Un autre inconvénient de cette solution est que si vous créez une nouvelle connexion à la base de données, il est probable que l’accès à certaines table sera bloqué (suite à des mises à jour) par la connexion que vous avez utilisez au moment de la création de votre transaction au démarrage de votre test unitaire.

Mon idée : Recréer systématiquement la base de données pour chaque test unitaire.

Je dis « mon idée » car je suis surpris de voir que personne n’a pensé à utiliser cette stratégie qui parait pour moi simple et évidente… Je devrais d’ailleurs peut-être déposer un brevet…

Mon idée est donc de recréer la base de données (schéma + jeu d’essai) à chaque exécution d’un test unitaire !

Il y a 8 ans, je me rappelle que j’avais créé une base de données avec son schéma, j’avais rempli un jeu d’essai et j’avais tout sauvegardé dans un « .bak ». A chaque exécution du test unitaire, j’avais juste à exécuter l’opération de restauration de la base de données… Cela fonctionnait très bien, mais au bout de 3 à 4 mois de développement, je me suis rendu compte que la mise à jour de la base de données et sa consultation n’était pas pratique… En effet, je n’avais pas une vision complète de mon jeu de données et du schéma de la base de données (il fallait alors explorer la base de données via SQL Server Management Studio, table par table,…). Les mises à jour impliquaient de sauvegarder la base de données, de « l’injecter » dans mon projet de test, de relancer les tests, de corriger la base de données à nouveau, de sauvegarder,…. Au niveau agilité ce n’était pas génial !

J’ai donc eu l’idée de créer ma base de données de A à Z via des scripts ! Ainsi les mises à jour et le suivi de l’évolution de la base de données de test s’en trouve simplifié ! Il suffit alors d’exécuter ce script avant chaque test unitaire. Le principe de l’utilisation du script se décompose en 3 parties (d’ailleurs je vous recommande de séparer ces 3 parties dans 3 scripts différents pour des raisons de maintenance). Voici donc ces 3 parties :

  1. Supprimer la base de données si elle existe (et faire un ROLLBACK de toutes les transactions précédentes en cas de défaillance du test unitaire précédent) et la créer à nouveau.
  2. Création du schéma de base de données
  3. Insertion d’un jeu d’essai

Ce n’est pas plus compliqué que çà !

Voici un exemple de ce script (vous pouvez vous inspirer surtout pour la partie qui se charge de supprimer et de recréer la base de données).

-- Supprimer la base de données GestionProduitsTest si existante
USE master
GO
IF EXISTS(SELECT * FROM sys.databases WHERE name = 'GestionProduitsTest')
	ALTER DATABASE GestionProduitsTest SET  SINGLE_USER WITH ROLLBACK IMMEDIATE
GO
IF EXISTS(SELECT * FROM sys.databases WHERE name = 'GestionProduitsTest')
	ALTER DATABASE GestionProduitsTest SET  SINGLE_USER
GO
IF EXISTS(SELECT * FROM sys.databases WHERE name = 'GestionProduitsTest')
	DROP DATABASE GestionProduitsTest
GO

-- Créer la base de données GestionProduitsTest
CREATE DATABASE GestionProduitsTest
GO

-- Utiliser la nouvelle base de données
USE GestionProduitsTest

-- Créer les tables de la base de données
CREATE TABLE Produit
(
	Id			INT		NOT NULL IDENTITY(1,1),
	Référence		VARCHAR(50)     NOT NULL,
	Désignation		VARCHAR(50)     NOT NULL
)
GO

-- Insérer un jeu d'essai
SET IDENTITY_INSERT Produit ON

INSERT INTO Produit (Id, Référence, Désignation)
	VALUES (1, 'Référence 1', 'Désignation 1'),
		   (2, 'Référence 2', 'Désignation 2'),
		   (3, 'Référence 3', 'Désignation 3')

SET IDENTITY_INSERT Produit OFF

Maintenant que nous avons notre script, il faut l’exécuter au début du démarrage de notre test unitaire. Pour cela il suffit tout simplement de lancer l’utilitaire sqlcmd de SQL Server qui permet d’exécuter un script SQL. On utilisera donc la méthode Process.Start() du .NET Framework afin de démarrer sqlcmd, si l’utilitaire retourne une valeur différente de 0 (c’est à dire qu’une erreur s’est produite), on récupérera la sortie standard et on déclenchera une exception afin de faire échouer notre test unitaire. Voici une classe SqlServerUtilitaires contenant une méthode statique ExécuterScript() permettant d’exécuter un script passé en paramètre via sqlcmd :

public class SqlServerUtilitaires
{
    public static void ExécuterScript(string nomChaîneConnexion, string nomScript)
    {
        // Récupérer la chaîne de connexion dans le fichier de configuration
        SqlConnectionStringBuilder chaîneConnexionBuilder;
        chaîneConnexionBuilder = new SqlConnectionStringBuilder(ConfigurationManager.ConnectionStrings[nomChaîneConnexion].ConnectionString);

        // Créer l'objet ProcessStartInfo et rediriger la sortie standard
        ProcessStartInfo psi;

        psi = new ProcessStartInfo("sqlcmd", "-b -E -i " + nomScript);
        psi.RedirectStandardOutput = true;
        psi.CreateNoWindow = true;
        psi.UseShellExecute = false;
        psi.StandardOutputEncoding = Encoding.GetEncoding("ibm850");

        // Lancer sqlcmd
        using (Process processus = Process.Start(psi))
        {
            // Attendre la fin de l'exécution de sqlcmd
            processus.WaitForExit();

            // Si une erreur est détecté, déclencher une exception avec le contenu de la sortie standard
            // dans le message.
            if (processus.ExitCode > 0)
            {
                StringBuilder sb;
                sb = new StringBuilder();

                sb.AppendFormat("Code d'erreur retourné par sqlcmd : {0}", processus.ExitCode);
                sb.AppendLine();
                sb.AppendLine("***** Sortie standard ******");
                sb.AppendLine(processus.StandardOutput.ReadToEnd());

                throw new InvalidOperationException(sb.ToString());
            }
        }
    }
}

Maintenant au niveau de notre test unitaire il suffit de déployer notre script via l’attribut DeployItem dans nos tests unitaires. Il ne faut pas oublier de copier automatiquement le script dans le répertoire de sortie où se trouve l’assembly de test. Pour cela il faut faire clic-droit sur notre script et dans la fenêtre des propriétés, choisir l’option : « Copier si plus récent » dans « Copier dans le répertoire de sortie ».

Option "Copier dans le répertoire de sortie" : Copier si plus récent

[TestClass]
[DeploymentItem("GestionProduitsBaseDeDonnéesTest.sql")]
public class SqlServerProduitAccèsDonnéesTest
{
    [TestMethod]
    public void GetProduitTest()
    {
        SqlServerUtilitaires.ExécuterScript("GestionProduits", "GestionProduitsBaseDeDonnéesTest.sql");

        SqlServerProduitRepository accèsDonnées;
        accèsDonnées = new SqlServerProduitRepository();

        Produit produit;
        produit = accèsDonnées.GetProduit(2);

        Assert.AreEqual(2, produit.Id);
        Assert.AreEqual("Référence 2", produit.Référence);
        Assert.AreEqual("Désignation 2", produit.Désignation);
    }

    [TestMethod]
    public void GetProduit_ExistePas_Test()
    {
        SqlServerUtilitaires.ExécuterScript("GestionProduits", "GestionProduitsBaseDeDonnéesTest.sql");

        SqlServerProduitRepository accèsDonnées;
        accèsDonnées = new SqlServerProduitRepository();

        Produit produit;
        produit = accèsDonnées.GetProduit(1664);

        Assert.IsNull(produit);
    }
}

L’attribut DeployItem a été appliqué sur la classe pour déployer le fichier lors de l’exécution d’un test unitaire de la classe. Il est bien évidemment possible de spécifier l’attribut uniquement pour certaines méthodes de test si certains tests n’en n’ont pas besoin.

Il ne faut pas oublier de créer un fichier de configuration pour notre test unitaire. En effet, notre classe d’accès aux données récupère la chaîne de connexion dans le fichier de configuration. Avec le moteur de test MSTest de Microsoft, il est possible de spécifier un fichier de configuration pour notre assembly de test qui sera utilisé lors de l’exécution des tests unitaires.

Vous pouvez constater qu’à la fin des tests unitaires je ne supprime pas la base de données… Pourquoi ? Tout simplement parce que en cas d’erreur dans le test unitaire, on a la possibilité de consulter le contenu de la base de données à des fins de diagnostics. Je vous conseille donc de laisser la base de données à la fin du test unitaire et de laisser le test unitaire suivant de se charger de la suppression de la base de données si elle est existante.

Et au niveau des builds d’intégration continue ?

L’un des avantages majeur des builds d’intégration continue est de pouvoir relancer en permanence les tests unitaires afin de détecter les régressions à chaque nouvel archivage de code. Dans notre cas, si on veut exécuter notre test unitaire sur un serveur de build, il est donc nécessaire d’installer un serveur de base de données.

Dans le cas de SQL Server, il n’est pas nécessaire de dépenser des milliards d’euros pour installer une édition Enterprise de SQL Server. Une simple édition Express de SQL Server suffit (je rappelle que c’est une édition qui est gratuite !). Au niveau compte utilisateur et des droits, je vous recommande d’utiliser l’authentification Windows (cela évite de se balader avec des mots de passe partout dans le code…) et il ne faut pas oublier de donner le rôle « sysadmin » au compte utilisateur sous lequel tourne votre service de build (c’est à dire l’application qui exécute les tests unitaires sur votre serveur de build). Il est nécessaire de donner ce rôle à votre service de build afin que celui-ci puisse supprimer et créer à nouveau la base de données.

Je déconseille fortement d’utiliser une instance de SQL Server où se trouve des bases de données en production !! Je rappelle que votre service de build tourne avec le rôle sysadmin, il peut donc accidentellement modifier ou supprimer une base de données de production. Je recommande donc d’installer juste une instance de SQL Server Express sur le serveur de build…

Autre point important à noter : Si vous utilisez Team Foundation Server, vous avez la possibilité de créer plusieurs agents builds sur un ou plusieurs serveurs, cela veut dire qu’il est possible d’exécuter plusieurs builds en parallèle… Cela peut donc poser problème lorsque 2 tests unitaires s’exécutent en même temps sur 2 builds différents avec la base de données. Dans ce cas, je recommande :

  • De ne pas installer plusieurs agents sur un serveur de build (afin d’empêcher que plusieurs builds s’exécutent en parallèle sur le même serveur).
  • D’installer une instance de SQL Server Express par serveur de build. En effet, il ne faut pas que les serveurs de build partagent le même serveur de base de données.

Problèmes de performance des tests unitaires avec les bases de données

Certains développeurs ne souhaitent pas faire des tests unitaires avec des bases de données, sous pretexte qu’ils sont lent à exécuter ! Je suis d’accord au niveau qu’en moyenne que les tests unitaires prennent entre 1 à 3 secondes pour s’exécuter au lieu de quelques milli-secondes pour des tests unitaires simples… Mais comme tout développeur sur un gros projet, vous ne relancez pas vos 5000 tests unitaires toutes les 5 minutes !!! Le plus souvent vous relancez les tests unitaires sur le partie que vous développez ou modifiez… Ensuite c’est au niveau serveur de build que tous les tests sont relancés et vérifiés… Avec les serveurs de build, on n’est pas à 2 ou 3 heures près pour réaliser un build d’intégration continue… Si vous avez beaucoup trop de build d’intégration continue qui se déclenchent, essayez de les regrouper, c’est à dire déclencher un build d’intégration continue tous les 10 archivages par exemple, ou alors d’augmenter le nombre de serveur de builds… Vous n’avez donc pas d’excuse « bidon » pour ne pas faire les tests unitaires avec des bases de données.

Conseils et astuces sur l’écriture et la maintenant du script de création de la base de données

La réalisation du script de la base de données peut prendre pas mal de temps. Je vais vous démontrer que ce n’est pas le cas…

Tout d’abord, rappelez-vous  que le script se décompose en 3 parties :

  • La première partie consiste à supprimer et à recréer la base de données. Cette partie de script ne change normalement jamais…
  • La deuxième partie consiste à créer le schéma de la base de données. Cette partie, aussi bizarre que cela puisse paraître, est très simple à réaliser avec un peu d’astuce. En effet, beaucoup de développeurs disposent sur leur poste de développement, une base de données de l’application avec sa bonne structure afin de réaliser des tests d’intégration (le plus souvent pour tester l’IHM…). Il est alors très simple de faire travailler SQL Server Management Studio à votre en place en lui disant de scripter tout le contenu de vos tables de cette base de données… Pour ce faire, sélectionnez vos tables dans l’explorateur d’objets, faites un clic droit, choisissez l’option « Générer un script de la table en tant que » / « CREATE To » / « Nouvelle fenêtre de l’éditeur de requête ». Il est alors très simple de faire un copier/coller de la structure des tables que nous avons « scripté ».
  • Génération de script SQL depuis SQL Server Management Studio permettant de créer une tableScript SQL généré par SQL Server Management StudioLa troisième partie consiste à créer un jeu d’essai qui alimentera notre base de données. Ici il n’y a pas de miracle, il faudra le saisir à la main. Cependant il n’est pas nécessaire de créer un jeu d’essai trop complexe (ou un volume de données trop important), je vous rappelle que l’on cherche à tester fonctionnellement notre couche d’accès aux données et non les performances de celle-ci. Votre jeu d’essai doit donc se limiter entre 5 à 10 lignes au maximum.

Au niveau de la maintenance, il ne faut surtout pas faire plusieurs scripts par tests unitaires car la mise à jour du schéma (ne serai-ce juste l’ajout d’une colonne) va engendrer la mise à jour de tous vos scripts. Utilisez donc de préférence un seul script pour tous vos tests unitaires. Vous avez cependant la possibilité de créer quelques scripts spécifiques pour certains tests unitaires, mais attention à la maintenance.

Contrairement aux idées reçus la maintenance et l’évolution de votre script n’est pas une tâche fastidieuse… Si vous le pensez, questionnez-vous sur combien de fois par jour vous changez la structure de votre base de données ???

Certaines personnes me demandent pourquoi j’utilise l’instruction « SET IDENTITY_INSERT … ON/OFF » lors de la création de mon jeu d’essai… Tout simplement pour imposer un identifiant et éviter lorsque j’insère une ligne (suite à une évolution de mon jeu d’essai), de changer indirectement les identifiants auto-incrémentés de mes lignes. Et ainsi éviter les impacts sur les tests unitaires existants…

Les tests unitaires avec une base de données : Uniquement pour tester la couche d’accès aux données !

Après avoir lu cet article, j’espère que beaucoup de développeurs essayeront de mettre en oeuvre cette technique afin de tester leur couche d’accès aux données… Mais il faut cependant faire attention de ne pas tomber dans l’excès en réalisant tous les tests unitaires (métier, IHM,…) sur une base de données. Les tests unitaires qui utilisent la base de données portent uniquement sur la couche basse de votre application qui représente la couche d’accès aux données. Si vous devez tester la couche métier ou la couche IHM de votre application il faut absolument découpler la couche d’accès aux données afin de simplifier au maximum le développement et la maintenance de vos tests !

Conclusion

Cet article vous a présenté une stratégie afin de mettre en oeuvre très simplement des tests unitaires pour tester sa couche d’accès aux données. J’ai mis en place et j’utilise cette stratégie avec mes clients avec succès depuis plus de 8 ans. Rappelez vous que c’est une stratégie de test relativement simple et évolutive à mettre en oeuvre. Attention cependant à ne pas l’utiliser pour tester les couches supérieures de votre application !

Exemple de tests unitaires avec une base de données

Je vous mets à disposition en téléchargement une solution Visual Studio 2012 qui reprend les exemples de code de cet article et illustre l’utilisation d’une base de données avec les tests unitaires. Elle requiert l’installation d’un SQL Server sur votre poste.

Un commentaire

  1. Michel Bruyère dit :

    Dans la majorité des cas (et malheureusement…) ce sont les développeurs qui conçoivent la base de données

    J’adore votre texte entre parenthèses car je pense la même chose.

    À noter que je suis développeur et aussi administrateur de base de données (côté développement).

Leave a comment