Partager via


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

Dans cette page, nous abordons les 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 d'aperçu des tests . Notez que le test sur une base de données différente de ce qui est utilisé en production (par exemple, Sqlite) n’est pas abordé ici, car la base de données différente est utilisée comme double de test ; cette approche est abordée dans Testing sans votre système de base de données de production.

Le principal obstacle avec les tests qui implique une base de données réelle consiste à garantir une isolation de test appropriée, afin que les tests s’exécutant en parallèle (ou même en série) n’interfèrent pas entre eux. L’exemple de code complet pour ce qui suit peut être consulté ici.

Conseil / Astuce

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, à la fois dans les environnements CI et sur les machines de développement. Bien qu’il soit fréquemment facile d’installer la base de données via le mécanisme d’installation standard, les images Docker prêtes à l’emploi sont disponibles pour la plupart des bases de données principales et peuvent rendre l’installation particulièrement facile dans CI. Les bibliothèques telles que Testcontainers peuvent simplifier davantage ce processus en gérant automatiquement les instances de base de données conteneurisées pendant les tests. Pour l’environnement de développement, GitHub Workspaces, Dev Container peuvent configurer tous les services et dépendances nécessaires, y compris la base de données. Bien que cela nécessite un investissement initial dans la configuration, une fois cela fait, vous disposez d'un environnement de test opérationnel et pouvez vous concentrer sur des choses plus importantes.

Dans certains cas, les bases de données ont une édition spéciale ou une version qui peut être utile pour les tests. Lors de l’utilisation de SQL Server, localDB peut être utilisé pour exécuter des tests localement sans configuration du tout, en épinglant l’instance de base de données à la demande et en économisant éventuellement des ressources sur des machines de développement moins puissantes. Toutefois, LocalDB n’est pas sans ses problèmes :

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

Nous vous recommandons généralement d’installer SQL Server Developer Edition plutôt que LocalDB, car il fournit l’ensemble complet de fonctionnalités SQL Server et est généralement très facile à faire.

Lorsque vous utilisez une base de données cloud, il est généralement approprié de tester par rapport à une version locale de la base de données, à la fois pour améliorer la vitesse et réduire les coûts. Par exemple, lors de l’utilisation de SQL Azure en production, vous pouvez effectuer des tests sur un serveur SQL Server installé localement . Les deux sont extrêmement similaires (bien qu’il soit toujours judicieux d’exécuter des 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 pour 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 dispose d'une base de données unique, partagée par plusieurs tests issus de différentes classes de test. Nous avons donc besoin d’une logique pour s’assurer que la base de données est créée et amorcée exactement une seule fois par session de test.

Lors de l’utilisation de Xunit, cela peut être effectué via un fixture de classe , qui représente la base de données et est partagé 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 d'une exécution précédente), puis EnsureCreated() pour la créer avec votre dernière configuration de modèle (voir la documentation 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 vaut la peine de réfléchir à vos données initiales, car la modification ultérieure d’un nouveau test peut entraîner l’échec des tests existants.

Pour utiliser le dispositif dans une classe de test, implémentez simplement IClassFixture sur votre type de dispositif, 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 entièrement fonctionnelle :

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

    var blog = (await 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 le dispositif est utilisé uniquement dans une classe de test unique, il est garanti d'être instancié exactement une fois par xUnit ; mais il est courant d'utiliser le même dispositif 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 un appareil de classe xUnit, nous prenons un verrou simple autour de la création et de l’amorçage de la base de données, et utilisons un indicateur statique pour vous assurer que nous n’avons jamais à le faire deux fois.

Tests qui modifient les données

L’exemple ci-dessus a montré un test en lecture seule, qui est le cas facile du point de vue de l’isolation des tests : étant donné que rien n’est modifié, l’interférence des 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. Une technique courante pour isoler l’écriture de tests consiste à encapsuler le test dans une transaction et à faire en sorte que cette transaction soit restaurée à la fin du test. Étant donné que rien n’est réellement validé dans la base de données, d’autres tests ne voient aucune modification et l'interférence est évitée.

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

[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    await _context.SaveChangesAsync();

    return Ok();
}

Nous pouvons tester cette méthode avec les éléments suivants :

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

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

    context.ChangeTracker.Clear();

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

}

Quelques notes sur le code de test ci-dessus :

  • Nous commençons 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. Étant donné que la transaction n’est 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-après. Nous pourrions utiliser deux instances de contexte à la place, mais nous devons nous assurer que la même transaction est utilisée par les deux instances.
  • 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 peut aider à empêcher les cas où la transaction est accidentellement oubliée, ce qui entraîne un test d’interférence qui peut être difficile à déboguer. Vous pouvez également séparer les tests en lecture seule et les tests d'é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 gèrent également explicitement les transactions. Étant donné que les bases de données ne prennent généralement pas en charge les transactions imbriquées, il n’est pas possible d’utiliser des transactions pour l’isolation comme ci-dessus, car elles doivent être utilisées par le code de produit réel. Bien que ces tests soient plus rares, il est nécessaire de les gérer de manière spéciale : vous devez nettoyer votre base de données à 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 async Task<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.
    await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);

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

    blog.Url = url;
    await _context.SaveChangesAsync();

    await transaction.CommitAsync();
    return Ok();
}

Supposons que pour une raison quelconque, la méthode nécessite une transaction sérialisable à utiliser (ce n’est généralement pas le cas). Par conséquent, nous ne pouvons pas utiliser une transaction pour garantir l’isolation des tests. Étant donné que le test valide réellement les modifications apportées à la base de données, nous allons définir un autre élément avec sa propre base de données distincte, pour vous assurer que nous n’interfèrez pas 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();
    }
}

Ce dispositif est similaire à celui utilisé ci-dessus, mais contient notamment une méthode Cleanup ; Nous allons appeler ceci après chaque test pour vous assurer que la base de données est réinitialisée à son état de départ.

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

Tout d’abord, nous définissons une collection de tests , qui fait référence à notre appareil et qui sera utilisée par toutes les classes de test transactionnelles qui le nécessitent :

[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, étant donné que xUnit n’instancie qu’une seule fois le dispositif de collection, il n’est pas nécessaire d’utiliser le verrouillage autour de la création et de l’amorçage de la base de données comme nous l’avons fait ci-dessus.

Le code complet pour l'exemple ci-dessus peut être consulté ici.

Conseil / Astuce

Si vous avez plusieurs classes de test avec des tests qui modifient la base de données, vous pouvez toujours les exécuter en parallèle en ayant des éléments différents, chacun 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 qu’il 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 dans certaines bases de données, ce qui peut poser problème lorsque vous itérez sur les modifications de code et réexécutez des 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 est 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 vous recommandons de le faire temporairement pendant le cycle de développement.

Nettoyage efficace de la base de données

Nous avons vu ci-dessus que lorsque les modifications sont réellement validées dans la base de données, nous devons nettoyer la base de données entre chaque test pour éviter les interférences. Dans l’exemple de test transactionnel ci-dessus, nous l’avons fait à l’aide des 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();

Cela n’est généralement pas le moyen le plus efficace de vider une table. Si la vitesse de test est un problème, vous pouvez utiliser sql brut pour supprimer la table à la place :

DELETE FROM [Blogs];

Vous pouvez également envisager d’utiliser le respawn package, ce qui efface 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 les tables sont ajoutées à votre modèle.

Résumé

  • Lorsque vous effectuez des tests sur une base de données réelle, il vaut la peine 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 à vous 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 la désactivation de 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 à vos données de test initialisées et essayez d'écrire vos tests de manière à ce qu'ils ne se cassent pas trop souvent si ces données initiales 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 initiales.
  • Si la vitesse de test est un problème, vous pouvez examiner des techniques plus efficaces pour créer votre base de données de test et pour nettoyer ses données entre les exécutions.
  • Gardez toujours à l’esprit la parallélisation et l’isolation des tests.