Aller au contenu principal

2-4 Réinitialisation de la BD de test

Lorsqu'on fait des tests automatisés, il est important que chaque test soit effectué dans un environnement contrôlé et un état connu. De plus, rien d'extérieur ne doit venir influencer l'état des tests.

Le meilleur état connu est un état vide! 😉 C'est pourquoi à chaque test, nous allons réinitialiser la base de données. Si un test requiert des données, ce dernier sera responsable d'en faire l'insertion. Ainsi, chaque test connait son environnement et l'état à partir duquel il est exécuté.

Nous pourrions donner un état de départ identique pour tous les tests, avec des données de test, mais à ce moment si un test ajoute des données pour supporter son besoin, il est possible que cela vienne interférer avec un autre test!

Bref, nous allons réinitialiser les données dans la base de données à chaque exécution de test. Pour cela, nous pourrions faire un MigrateDown() et un MigrateUp(), mais cela vient avec un coût puisque c'est une opération relativement longue. Au fond, nous ne voulons pas nécessairement complètement supprimer les tables et tout recommencer, on veut simplement vider le contenu.

La librairie Respawn fait exactement cela! Elle effectue un truncate de façon intelligente sur toutes les tables afin d'en vider le contenu.

Installer Respawn

  1. Dans le projet Snowfall.Tests -> Manage NuGet Packages
  2. Faire une recherche pour Respawn
  3. Ajouter Respawn au projet

Configurer Respawn via TestDatabaseFixture

Le concept de Fixture est lié à toute classe ou entité qu'un test pourrait avoir besoin. Or, nos tests aurons besoin de réinitialiser la base de données de test via Respawn. Nous allons donc créer une classe TestDatabaseFixture.

  1. Sous Snowfall.Tests, créez une classe TestDatabaseFixture

  2. Faites ensuite hériter la classe de IAsyncLifetime pour qu'elle implémente les méthodes InitializeAsync() (appelée avant chaque test) et DisposeAsync() (appelée après chaque test)

    Snowfall.Tests/TestDatabaseFixture.cs
    public class TestDatabaseFixture : IAsyncLifetime
    {

    public async Task InitializeAsync()
    {
    }

    /// <summary>
    /// Ferme la connexion SQL
    /// </summary>
    public async Task DisposeAsync()
    {
    }
    }
  3. Lors de l'initialisation, nous devons charger la configuration de l'application dans appsettings.Test.json et initialiser Respawn.

    public class TestDatabaseFixture : IAsyncLifetime
    {
    private NpgsqlConnection? _connection;
    private Respawner? _respawner;
    private string? _connectionString;

    public async Task InitializeAsync()
    {
    // Charger la configuration de l'application
    var config = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.Test.json")
    .Build();

    _connectionString = config.GetConnectionString("AppDatabaseConnection")!;

    // Ouvrir une connection SQL pour Respawn
    _connection = new NpgsqlConnection(_connectionString);
    await _connection.OpenAsync();

    // Créer l'instance de Respawn
    _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
    {
    DbAdapter = DbAdapter.Postgres,
    TablesToIgnore = new Table[]
    {
    "VersionInfo",
    "application_users",
    "application_roles",
    "application_roles_users"
    }
    });
    }

    /// <summary>
    /// Ferme la connexion SQL
    /// </summary>
    public async Task DisposeAsync()
    {
    }
    }
    À propos de TablesToIgnore

    Nous fournissons à Respawn une liste de table à ignorer pour réintialiser la base de données:

    • VersionInfo: contient les migrations qui ont été exécutées. Nous ne voulons pas que les migrations soient réexécutée à chaque exécution d'un test, donc on garde cette table intacte
    • application_users, application_roles et application_roles_users: ce sera notre seule exception en lien avec l'état de la base de données qui doit être créée par chaque test. Comme les utilisateurs font partie de données assez fondamentales, nous laisserons ces données dans la BD.
  4. Avant la réinitialisation, on veut s'assurer que les migrations aient bien été exécutées auparavant, au cas où on partirait d'une base de données complètement vide, sans aucune table.

    using FluentMigrator.Runner;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Npgsql;
    using Respawn;
    using Respawn.Graph;
    using Snowfall.Data.Configurations;

    namespace Snowfall.Tests;

    public class TestDatabaseFixture : IAsyncLifetime
    {
    private NpgsqlConnection? _connection;
    private Respawner? _respawner;
    private string? _connectionString;

    public async Task InitializeAsync()
    {
    // Charger la configuration de l'application
    var config = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.Test.json")
    .Build();

    _connectionString = config.GetConnectionString("AppDatabaseConnection")!;

    // Respawn doit avoir un schéma de base de données de prêt, on s'assure que la BD est migrée
    EnsureDatabaseMigrated();

    // Ouvrir une connection SQL pour Respawn
    _connection = new NpgsqlConnection(_connectionString);
    await _connection.OpenAsync();

    // Créer l'instance de Respawn
    _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
    {
    DbAdapter = DbAdapter.Postgres,
    TablesToIgnore = new Table[]
    {
    "VersionInfo",
    "application_users",
    "application_roles",
    "application_roles_users"
    }
    });
    }

    /// <summary>
    /// Exécute les migrations afin d'assurer un schéma de base de données
    /// de départ complet
    /// </summary>
    private void EnsureDatabaseMigrated()
    {
    if (_connectionString is not null)
    {
    var services = new ServiceCollection()
    .AddMigrations(_connectionString);
    var sp = services.BuildServiceProvider();
    sp.MigrateUp();
    }
    }

    /// <summary>
    /// Ferme la connexion SQL
    /// </summary>
    public async Task DisposeAsync()
    {
    }
    }
  5. On peut ensuite implémenter la fonction Dispose pour qu'elle ferme essentiellement la connexion.

    /// <summary>
    /// Ferme la connexion SQL
    /// </summary>
    public async Task DisposeAsync()
    {
    if (_connection is not null)
    {
    await _connection.CloseAsync();
    _connection.Dispose();
    }
    }
  6. Dernière étape, ajouter une fonction publique qu'il sera possible d'appeler à partir de nos tests pour réinitialiser la base de données.

    /// <summary>
    /// Réinitialise la base de données (données seulement).
    /// </summary>
    public async Task ResetDatabaseAsync()
    {
    if (_connection is not null && _respawner is not null)
    {
    await _respawner.ResetAsync(_connection);
    }
    }

Utiliser TestDatabaseFixture dans les tests

  1. Pour utiliser le fixture dans vos tests, vous pouvez faire hériter la classe de IClassFixture<TestDatabaseFixture>

    Snowfall.Tests/Web.Mvc/TestsIntegration/AccueilTests.cs
    public class AccueilTests : IClassFixture<SnowfallMvcApplicationFactory>, IClassFixture<TestDatabaseFixture>
    {
    private readonly SnowfallMvcApplicationFactory _application;
    private readonly TestDatabaseFixture _databaseFixture;

    public AccueilTests(SnowfallMvcApplicationFactory application, TestDatabaseFixture databaseFixture)
    {
    _application = application;
    _databaseFixture = databaseFixture;
    }
  2. Ensuite, pour obtenir la méthode InitializeAsync qui nous permettra d'appeler ResetDatabaseAsync avant chaque test, on peut implémenter IAsyncLifetime.

    public class AccueilTests : IClassFixture<SnowfallMvcApplicationFactory>, IClassFixture<TestDatabaseFixture>, IAsyncLifetime
    {
  3. Vous pouvez ensuite ajouter les deux fonctions requises par IAsyncLifetime

    public async Task InitializeAsync()
    {
    await _databaseFixture.ResetDatabaseAsync();
    }

    public Task DisposeAsync()
    {
    return Task.CompletedTask;
    }
    info

    Lors de l'initialisation, avant chaque test, la base de données est réinitialisée avec ResetDatabaseAsync() de la classe TestDatabaseFixture.

Test

Vous pouvez maintenant essayer d'exécuter le test de base Obtenir_AccueilUtilisateurAnonyme_PageAfficheTexte et le tout devrait être correctement exécuté.

De plus, si vous regardez dans votre base de données, vous remarquerez que vous aurez des données dans vos tables utilisateurs, mais toutes les autres tables devraient être vides!

Utiliser une collection xUnit

Pour que toutes les classes de test puissent utiliser la même connexion SQL et la même instance de Respawn, on peut créer une collection xUnit.

  1. Pour cela, dans votre fichier TestDatabaseSixture, ajoutez une classe qui hérite de votre fixture et qui déclare une collection.
    namespace Snowfall.Tests;

    public class TestDatabaseFixture : IAsyncLifetime
    {
    private NpgsqlConnection? _connection;
    private Respawner? _respawner;
    private string? _connectionString;

    public async Task InitializeAsync()
    {
    // Charger la configuration de l'application
    var config = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.Test.json")
    .Build();

    _connectionString = config.GetConnectionString("AppDatabaseConnection")!;

    // Respawn doit avoir un schéma de base de données de prêt, on s'assure que la BD est migrée
    EnsureDatabaseMigrated();

    // Ouvrir une connection SQL pour Respawn
    _connection = new NpgsqlConnection(_connectionString);
    await _connection.OpenAsync();

    // Créer l'instance de Respawn
    _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
    {
    DbAdapter = DbAdapter.Postgres,
    TablesToIgnore = new Table[]
    {
    "VersionInfo",
    "application_users",
    "application_roles",
    "application_roles_users"
    }
    });
    }

    /// <summary>
    /// Exécute les migrations afin d'assurer un schéma de base de données
    /// de départ complet
    /// </summary>
    private void EnsureDatabaseMigrated()
    {
    if (_connectionString is not null)
    {
    var services = new ServiceCollection()
    .AddMigrations(_connectionString);
    var sp = services.BuildServiceProvider();
    sp.MigrateUp();
    }
    }

    /// <summary>
    /// Ferme la connexion SQL
    /// </summary>
    public async Task DisposeAsync()
    {
    if (_connection is not null)
    {
    await _connection.CloseAsync();
    _connection.Dispose();
    }
    }

    /// <summary>
    /// Réinitialise la base de données (données seulement).
    /// </summary>
    public async Task ResetDatabaseAsync()
    {
    if (_connection is not null && _respawner is not null)
    {
    await _respawner.ResetAsync(_connection);
    }
    }
    }

    [CollectionDefinition("Test Database Collection")]
    public class TestDatabaseCollection : ICollectionFixture<TestDatabaseFixture>
    {
    // Classe utilisée seulement pour la collection xUnit
    }
  2. Finalement, vous n'avez plus à faire hériter la classe de TestDatabaseFixture, vous pouvez ajouter un attribut avec le nom de la collection.
    Snowfall.Tests/Web.Mvc/TestsIntegration/AccueilTests.cs
    [Collection("Test Database Collection")]
    public class AccueilTests : IClassFixture<SnowfallMvcApplicationFactory>, IAsyncLifetime
    {