Test sur votre système de base de données de production

Dans cette page, nous discutons de techniques d’écriture de tests automatisés qui impliquent le système de base de données sur lequel l’application s’exécute en production. D’autres approches de test existent, où le système de base de données de production est remplacé par des doubles de test. Pour plus d’informations, consultez la page de la vue d’ensemble des tests. Notez que le test sur une base de données différente de celle utilisée en production (par exemple, Sqlite) n’est pas abordé ici, car la base de données différente est utilisée comme un double de test. Cette approche est abordée dans Test sans votre système de base de données de production.

Garantir une isolation de test appropriée pour que les tests s’exécutant en parallèle (ou même en série) n’interfèrent pas entre eux constitue le principal obstacle à des tests impliquant une base de données réelle. L’exemple de code complet donné dans l’exemple ci-dessous peut être consulté ici.

Conseil

Cette page présente des techniques xUnit, mais des concepts similaires existent dans d’autres infrastructures de test, notamment NUnit.

Configuration de votre système de base de données

La plupart des systèmes de base de données peuvent aujourd’hui être facilement installés, dans des environnements CI et sur des machines de développement. Bien qu’il soit souvent assez facile d’installer la base de données via le mécanisme d’installation standard, des images Docker prêtes à l’emploi sont disponibles pour la plupart des principales bases de données et peuvent rendre l’installation particulièrement facile dans CI. Des espaces de travail GitHub, Dev Container peuvent configurer tous les services et dépendances nécessaires à l’environnement de développement, y compris la base de données. Bien que la configuration nécessite un investissement initial, une fois faite, vous disposez d’un environnement de travail pour les tests et vous pouvez vous concentrer sur des choses plus importantes.

Dans certains cas, les bases de données sont publiées dans une édition ou une version spéciale, ce qui peut être utile aux tests. Lors de l’utilisation de SQL Server, une base de données locale peut être utilisée pour exécuter des tests localement avec pratiquement aucune configuration, en épinglant à la demande l’instance de base de données et en enregistrant éventuellement des ressources sur des machines de développement moins puissantes. Toutefois, une base de données locale n’est pas sans poser problème :

  • Elle ne prend pas en charge tout ce que SQL Server Developer Edition prend en charge
  • Il est uniquement disponible sur Windows.
  • Elle peut entraîner un décalage lors de la première série de tests quand le service est lancé

Nous recommandons généralement d’installer l’édition SQL Server Développeur plutôt qu’une base de données locale, car elle fournit l’intégralité des fonctionnalités SQL Server et est généralement très facile à installer.

Lorsque vous utilisez une base de données cloud, il convient en général d’exécuter les tests sur une version locale de la base de données, pour améliorer la vitesse et réduire les coûts. Par exemple, lors de l’utilisation de SQL Azure en production, vous pouvez exécuter les tests sur un SQL Server installé localement, les deux étant extrêmement similaires (bien qu’il soit toujours judicieux d’exécuter les tests sur SQL Azure lui-même avant de passer en production). Lorsque vous utilisez Azure Cosmos DB, l’émulateur Azure Cosmos DB est un outil utile pour développer localement et exécuter des tests.

Création, amorçage et gestion d’une base de données de test

Une fois votre base de données installée, vous êtes prêt à l’utiliser dans vos tests. Dans la plupart des cas simples, votre suite de tests utilise une base de données unique partagée entre plusieurs tests sur plusieurs classes de test. Nous avons donc besoin d’une logique pour nous assurer que la base de données est créée et amorcée exactement une fois pendant la durée de vie de l’exécution du test.

Lors de l’utilisation de Xunit, cela peut être effectué via une fixture de classe, qui représente la base de données et qui est partagée entre plusieurs exécutions de test :

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    private static readonly object _lock = new();
    private static bool _databaseInitialized;

    public TestDatabaseFixture()
    {
        lock (_lock)
        {
            if (!_databaseInitialized)
            {
                using (var context = CreateContext())
                {
                    context.Database.EnsureDeleted();
                    context.Database.EnsureCreated();

                    context.AddRange(
                        new Blog { Name = "Blog1", Url = "http://blog1.com" },
                        new Blog { Name = "Blog2", Url = "http://blog2.com" });
                    context.SaveChanges();
                }

                _databaseInitialized = true;
            }
        }
    }

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);
}

Lorsque la fixture ci-dessus est instanciée, elle utilise EnsureDeleted() pour supprimer la base de données (au cas où elle existe depuis une exécution précédente), puis EnsureCreated() pour la créer avec votre dernière configuration de modèle (consultez les documents de ces API). Une fois la base de données créée, la fixture l’amorce avec certaines données que nos tests peuvent utiliser. Il est intéressant de prendre le temps de réfléchir à vos données d’amorçage, car leur modification ultérieure pour un nouveau test peut entraîner l’échec des tests existants.

Pour utiliser la fixture dans une classe de test, implémentez simplement IClassFixture sur votre type de fixture, et xUnit l’injectera dans votre constructeur :

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

Votre classe de test dispose désormais d’une propriété Fixture qui peut être utilisée par les tests pour créer une instance de contexte complètement fonctionnelle :

[Fact]
public void GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = controller.GetBlog("Blog2").Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

Enfin, vous avez peut-être remarqué certains verrouillages dans la logique de création de la fixture ci-dessus. Si la fixture est uniquement utilisée dans une seule classe de test, elle est assurée d’être instanciée exactement une fois par xUnit ; mais il est courant d’utiliser la même fixture de base de données dans plusieurs classes de test. xUnit fournit des fixtures de collection, mais ce mécanisme empêche vos classes de test de s’exécuter en parallèle, ce qui est important pour la performance des tests. Pour gérer cela en toute sécurité avec une fixture de classe xUnit, nous prenons un simple verrouillage autour de la création et l’amorçage de la base de données, et utilisons un indicateur statique pour nous assurer de ne jamais avoir à le faire deux fois.

Tests qui modifient des données

L’exemple ci-dessus présente un test en lecture seule, qui est un cas facile d’un point de vue isolation des tests. Rien n’étant modifié, l’interférence entre les tests n’est pas possible. En revanche, les tests qui modifient les données sont plus problématiques, car ils peuvent interférer entre eux. Inclure le test dans une transaction et restaurer cette transaction à la fin du test est une technique courante permettant d’isoler des tests en écriture. Rien n’étant réellement validé dans la base de données, les autres tests ne voient aucune modification et tout interférence est évitée.

Voici une méthode de contrôleur qui ajoute un blog à notre base de données :

[HttpPost]
public ActionResult AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    _context.SaveChanges();

    return Ok();
}

Nous pouvons tester cette méthode avec le code suivant :

[Fact]
public void AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = context.Blogs.Single(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

Remarques sur le code de test ci-dessus :

  • Nous démarrons une transaction pour nous assurer que les modifications ci-dessous ne sont pas validées dans la base de données et n’interfèrent pas avec d’autres tests. La transaction n’étant jamais validée, elle est implicitement restaurée à la fin du test lorsque l’instance de contexte est supprimée.
  • Après avoir effectué les mises à jour souhaitées, nous effaçons le suivi des modifications de l’instance de contexte avec ChangeTracker.Clear pour nous assurer que nous chargeons réellement le blog à partir de la base de données ci-dessous. Nous pourrions utiliser à la place deux instances de contexte, mais nous devrions alors nous assurer que ces deux instances utilisent la même transaction.
  • Vous pouvez même démarrer la transaction dans le CreateContext de la fixture, afin que les tests reçoivent une instance de contexte déjà dans une transaction et prête à être mise à jour. Cela permet d’empêcher les cas où la transaction est accidentellement oubliée, entraînant une interférence de test qui peut être difficile à déboguer. Vous pouvez également séparer les tests en lecture seule et les tests en écriture dans différentes classes de test.

Tests qui gèrent explicitement les transactions

Il existe une catégorie finale de tests qui présente une difficulté supplémentaire : les tests qui modifient les données et qui gèrent aussi explicitement les transactions. Les bases de données ne prenant en général pas en charge les transactions imbriquées, il n’est pas possible d’utiliser des transactions pour l’isolation comme indiqué ci-dessus, car elles doivent être utilisées par un code de produit réel. Bien que ces tests ont tendance à être plus rares, il est nécessaire de les gérer de manière spéciale. Vous devez nettoyer votre base de données pour la ramener à son état d’origine après chaque test, et la parallélisation doit être désactivée afin que ces tests n’interfèrent pas entre eux.

Examinons la méthode de contrôleur suivante comme exemple :

[HttpPost]
public ActionResult UpdateBlogUrl(string name, string url)
{
    // Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
    using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);

    var blog = _context.Blogs.FirstOrDefault(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    _context.SaveChanges();

    transaction.Commit();
    return Ok();
}

Supposons que pour une raison quelconque, la méthode nécessite d’utiliser une transaction sérialisable (ce n’est en général pas le cas). Par conséquent, nous ne pouvons pas utiliser une transaction pour assurer l’isolation des tests. Le test validant réellement les modifications apportées à la base de données, nous définirons une autre fixture avec sa propre base de données distincte, pour nous assurer de ne pas interférer avec les autres tests déjà indiqués ci-dessus :

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True;ConnectRetryCount=0";

    public BloggingContext CreateContext()
        => new BloggingContext(
            new DbContextOptionsBuilder<BloggingContext>()
                .UseSqlServer(ConnectionString)
                .Options);

    public TransactionalTestDatabaseFixture()
    {
        using var context = CreateContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();

        Cleanup();
    }

    public void Cleanup()
    {
        using var context = CreateContext();

        context.Blogs.RemoveRange(context.Blogs);

        context.AddRange(
            new Blog { Name = "Blog1", Url = "http://blog1.com" },
            new Blog { Name = "Blog2", Url = "http://blog2.com" });
        context.SaveChanges();
    }
}

Cette fixture est similaire à celle utilisée ci-dessus, mais contient notamment une méthode Cleanup. Nous l’appellerons après chaque test pour nous assurer que la base de données est réinitialisée à son état de départ.

Si cette fixture n’est utilisée que par une seule classe de test, nous pouvons la référencer en tant que fixture de classe comme ci-dessus. xUnit ne parallélise pas les tests dans la même classe (pour en savoir plus sur les collections de tests et la parallélisation, consultez les documents xUnit). Toutefois, si nous voulons partager cette fixture entre plusieurs classes, nous devons nous assurer que ces classes ne s’exécutent pas en parallèle pour éviter toute interférence. Pour cela, nous l’utiliserons comme fixture de collection xUnit plutôt que comme fixture de classe.

Tout d’abord, nous définissons une collection de tests, qui fait référence à notre fixture et qui sera utilisée par toutes les classes de test transactionnelles qui en ont besoin :

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

Nous référençons maintenant la collection de tests dans notre classe de test et acceptons la fixture dans le constructeur comme précédemment :

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

Enfin, nous libérons notre classe de test, en organisant l’appel à la méthode Cleanup de la fixture après chaque test :

public void Dispose()
    => Fixture.Cleanup();

Notez que, xUnit n’instanciant qu’une seule fois la fixture de collection, il n’est pas nécessaire d’utiliser le verrouillage autour de la création et l’amorçage de la base de données comme nous l’avons fait ci-dessus.

L’exemple de code complet donné dans l’exemple ci-dessus peut être consulté ici.

Conseil

Si vous avez plusieurs classes de test contenant des tests qui modifient la base de données, vous pouvez les exécuter en parallèle en utilisant des fixtures différentes, chacune référençant sa propre base de données. La création et l’utilisation de nombreuses bases de données de test ne sont pas problématiques et doivent être effectuées chaque fois que cela est utile.

Création efficace d’une base de données

Dans les exemples ci-dessus, nous avons utilisé EnsureDeleted() et EnsureCreated() avant d’exécuter les tests, pour nous assurer de disposer d’une base de données de test à jour. Ces opérations peuvent être un peu lentes sur certaines bases de données, ce qui peut être un problème, car vous itérer sur les modifications de code et réexécuter les tests encore et encore. Si c’est le cas, vous commenterez peut-être temporairement EnsureDeleted dans le constructeur de votre fixture pour réutiliser la même base de données au cours des exécutions de test.

L’inconvénient de cette approche réside dans le fait que si vous modifiez votre modèle EF Core, votre schéma de base de données ne sera pas à jour et les tests peuvent échouer. Par conséquent, nous recommandons de faire cela temporairement pendant le cycle de développement.

Nettoyage efficace d’une base de données

Nous avons vu ci-dessus que lorsque des modifications sont réellement validées dans la base de données, nous devons la nettoyer entre chaque test pour éviter toute interférence. Dans l’exemple de test transactionnel ci-dessus, nous avons utilisé les API EF Core pour supprimer le contenu de la table :

using var context = CreateContext();

context.Blogs.RemoveRange(context.Blogs);

context.AddRange(
    new Blog { Name = "Blog1", Url = "http://blog1.com" },
    new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();

Ce qui n’est en général pas le moyen le plus efficace de nettoyer une table. Si la vitesse de test est un problème, utilisez à la place une commande brute SQL pour supprimer la table :

DELETE FROM [Blogs];

Vous pouvez également envisager d’utiliser le package respawn, qui nettoie efficacement une base de données. En outre, il ne vous oblige pas à spécifier les tables à effacer. Par conséquent, votre code de nettoyage n’a pas besoin d’être mis à jour à mesure que des tables sont ajoutées à votre modèle.

Résumé

  • Lors de tests sur une base de données réelle, il est intéressant de distinguer les catégories de test suivantes :
    • Les tests en lecture seule sont relativement simples et peuvent toujours s’exécuter en parallèle sur la même base de données sans avoir à se soucier de l’isolation.
    • Les tests en écriture sont plus problématiques, mais des transactions peuvent être utilisées pour s’assurer qu’ils sont correctement isolés.
    • Les tests transactionnels sont les plus problématiques, nécessitant une logique pour réinitialiser la base de données à son état d’origine, ainsi que pour désactiver la parallélisation.
  • La séparation de ces catégories de test en classes distinctes peut éviter toute confusion et interférence accidentelle entre les tests.
  • Réfléchissez dès le départ à vos données de test amorcées et essayez d’écrire vos tests de manière à ne pas les interrompre trop souvent si ces données d’amorçage changent.
  • Utilisez plusieurs bases de données pour paralléliser les tests qui modifient la base de données, et éventuellement pour autoriser différentes configurations de données d’amorçage.
  • Si la vitesse de test est un problème, vous pouvez examiner des techniques plus efficaces de création de votre base de données de test et de nettoyage de ses données entre les exécutions.
  • Gardez toujours à l’esprit la parallélisation et l’isolation des tests.