Aller au contenu principal

2-5 Premier test d'intégration (API)

Faisons un premier test d'intégration d'API, question d'avoir une base autant au niveau MVC que API pour la suite.

Nous allons répéter sensiblement le même processus, mais avec quelques différences.

Créer SnowfallApiApplicationFactory

Tout comme pour les tests du projet MVC, il faudra une classe de type WebApplicationFactory qui permettra de démarrer un serveur de test, mais pour l'API.

Pour ce faire, nous allons premièrement créer un dossier pour les tests liés au projet MVC.

  1. Sous Snowfall.Tests -> Add -> Directory
  2. Nommer le dossier Web.Api

Ensuite,

  1. Sous Snowfall.Tests/Web.Api -> Add -> Class/Interface
  2. Nommer la classe SnowfallApiApplicationFactory
Snowfall.Tests/Web.Api/SnowfallApiApplicationFactory.cs
namespace Snowfall.Tests.Web.Api;

public class SnowfallApiApplicationFactory
{

}

Faire hériter la classe SnowfallApiApplicationFactory de WebApplicationFactory

Maintenant, on doit faire hériter la classe de WebApplicationFactory afin qu'elle hérite des fonctionnalités requises permettant de créer un serveur de développement de test.

Snowfall.Tests/TestsApi/SnowfallApiApplication.cs
using Microsoft.AspNetCore.Mvc.Testing;

namespace Snowfall.Tests.Web.Api;

public class SnowfallApiApplicationFactory : WebApplicationFactory<>
{

}

On doit faire référence ici à Program.cs du projet Api. Tout comme pour MVC, le problème est que Program.cs n'est pas un fichier/classe publique. Donc, on doit faire une manipulation pour rendre ce dernier public.

Modifier la portée de Program.cs

Pour modifier la portée de Program.cs, on peut simplement ajouter ceci dans le bas:

Snowfall.Web.Api/Program.cs
//...

app.Run();

namespace Snowfall.Web.Api
{
public partial class Program { }
}

Faire référence à Program dans SnowfallApiApplicationFactory

On peut maintenant faire référence à Program (IMPORTANT: CELUI DE WEB.API) puisque ce dernier est public.

Snowfall.Tests/TestsApi/SnowfallApiApplication.cs
using Microsoft.AspNetCore.Mvc.Testing;
using Snowfall.Web.Api;

namespace Snowfall.Tests.TestsApi;

public class SnowfallApiApplication : WebApplicationFactory<Program>
{

}

Préciser l'environnement Test

Pour que l'application démarre avec la bonne configuration d'environnement, on peut le spécifier via la fonction CreateHost:

Snowfall.Tests/Web.Api/SnowfallApiApplicationFactory.cs
namespace Snowfall.Tests.TestsApi;

public class SnowfallApiApplication : WebApplicationFactory<Program>
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.UseEnvironment("Test");

return base.CreateHost(builder);
}
}

Créer une première classe de test EvenementsTests

  1. Sous le dossier Tests/Web.Api, créer un dossier TestsIntegration via Add -> Directory
  2. Sous le dossier Tests/Web.Api -> Add -> Class/Interface
  3. Nommer la classe de tests EvenementsTests
  4. Faire hériter la classe de test de IClassFixture<SnowfallApiApplication>, IAsyncLifetime, en plus d'ajouter le nom de la collection xUnit Test Database Collection afin de pouboir réinitialiser la base de données.
    Snowfall.Tests/Web.Api/TestsIntegration/EvenementsTests.cs
    [Collection("Test Database Collection")]
    public class EvenementsTests : IClassFixture<SnowfallApiApplicationFactory>, IAsyncLifetime
    {
    public async Task InitializeAsync()
    {
    throw new NotImplementedException();
    }

    public async Task DisposeAsync()
    {
    throw new NotImplementedException();
    }
    }
  5. Puis, on reçoit via le constructeur l'instance de l'application, ainsi que de TestDatabaseFixture
    Snowfall.Tests/TestsApi/TestsIntegration/EvenementsTests.cs
    public class EvenementsTests : IClassFixture<SnowfallApiApplicationFactory>, IAsyncLifetime
    {
    private readonly SnowfallApiApplicationFactory _application;
    private readonly TestDatabaseFixture _databaseFixture;

    public EvenementsTests(SnowfallApiApplicationFactory application, TestDatabaseFixture databaseFixture)
    {
    _application = application;
    _databaseFixture = databaseFixture;
    }
  6. Finalement, on réinitialise la base de données à chaque test
    Snowfall.Tests/Web.Api/TestsIntegration/EvenementsTests.cs
    public async Task InitializeAsync()
    {
    await _databaseFixture.ResetDatabaseAsync();
    }

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

Créer un premier test

Pour le premier test, on obtiendra la liste d'événements via l'API. Puisque nous n'avons rien configuré encore au niveau du support pour l'authentification, assurez-vous que l'action d'API correspondante n'est pas protégée (utiliser via AllowAnonymous exemple).

Snowfall.Web.Api/Controllers/EvenementsController.cs
// GET /api/evenements
[AllowAnonymous]
[HttpGet]
public async Task<IActionResult> Index()

Ensuite, créons le test pour récupérer la liste des événements en provenance de l'API.

Snowfall.Tests/Web.Api/TestsIntegration/EvenementsTests.cs
[Fact]
public async Task Obtenir_ListeEvenements_RetourneListeSucces()
{
// Arrange
var client = _application.CreateClient();
string url = "api/evenements";

// Act
var evenements = await client.GetFromJsonAsync<List<EvenementDto>?>(url);

// Assert
Assert.NotNull(evenements);
Assert.NotEmpty(evenements);
}
info

Le principe est sensiblement le même que pour le test MVC. On appelle cependant GetFromJsonAsync pour recevoir le contenu en format JSON, converti au type List<EvenementDto>.

Ensuite, on valide que la liste n'est pas null, ni vide, et donc qu'elle contient des éléments.

Premier Test

Exécutez le test d'API tout juste créé et vous devriez obtenir l'erreur suivante ☹️

Xunit.Sdk.NotEmptyException
Assert.NotEmpty() Failure: Collection was empty

Le assert vérifiant que la collection n'est pas vide NotEmpty ne passe pas puisque la collection est vide! En effet, on réinitialise avant chaque test la base de données et elle est ainsi vide!

Il faudra faire en sorte que le test puisse insérer des événements dans la base de données avant le test.

Insertion des données pour le test

Le test est dépendant sur des événements, qui sont eux-mêmes dépendants sur des villes. Nous devrons donc:

  1. Insérer une ou plusieurs villes
  2. Insérer plusieurs événements qui seront associés aux villes créées.

Pour faire les insertions, nous avons déjà des repository qui sont responsables d'effectuer la majeure partie des opérations CRUD sur les modèles, nous pourrions donc réutiliser ces derniers. Nous devrons cependant les rendre disponibles au test via le système d'injection de dépendances.

Insertion d'une ville

  1. Créer une fonction de repo Create pour créer une ville (nous ne l'avons pas!)

    Snowfall.Data/Repositories/VilleRepository.cs
    public async Task<Ville> Create(Ville ville)
    {
    // À compléter!
    }
    attention

    La fonction des à compléter!

  2. Assurez-vous d'avoir l'interface associée

    Snowfall.Data/Repositories/IVilleRepository.cs
    public interface IVilleRepository
    {
    Task<List<Ville>> GetAll();
    Task<Ville> Create(Ville ville);
    }
  3. Dans la section Arrange du test, avant de créer le client et tout le reste, récupérez le repo via l'injection de dépendances.

    Snowfall.Tests/Web.Api/TestsIntegration/EvenementsTests.cs
    public async Task Obtenir_ListeEvenements_RetourneListeSucces()
    {
    // Arrange
    var scope = _application.Services.CreateScope();
    var villeRepository = scope.ServiceProvider.GetRequiredService<IVilleRepository>();

    //...
  4. Créez une ville à insérer

    public async Task Obtenir_ListeEvenements_RetourneListeSucces()
    {
    // Arrange
    var scope = _application.Services.CreateScope();
    var villeRepository = scope.ServiceProvider.GetRequiredService<IVilleRepository>();
    var ville = new Ville()
    {
    Nom = "Paris",
    PaysIso = "fr"
    };
  5. Procédez à l'insertion

    Snowfall.Tests/Web.Api/TestsIntegration/EvenementsTests.cs
    public async Task Obtenir_ListeEvenements_RetourneListeSucces()
    {
    // Arrange
    var scope = _application.Services.CreateScope();
    var villeRepository = scope.ServiceProvider.GetRequiredService<IVilleRepository>();
    var ville = new Ville()
    {
    Nom = "Paris",
    PaysIso = "fr"
    };
    ville = await villeRepository.Create(ville);

Insertion d'une liste d'événements

De la même façon que précédemment, nous pouvons maintenant procéder à l'insertion d'une liste d'événements et les associer à la ville nouvellement créée.

  1. Récupérer le repo d'événements et créer une liste d'événements à insérer.

    public async Task Obtenir_ListeEvenements_RetourneListeSucces()
    {
    // Arrange
    var scope = _application.Services.CreateScope();
    var villeRepository = scope.ServiceProvider.GetRequiredService<IVilleRepository>();
    var ville = new Ville()
    {
    Nom = "Paris",
    PaysIso = "fr"
    };
    ville = await villeRepository.Create(ville);

    var evenementRepository = scope.ServiceProvider.GetRequiredService<IEvenementRepository>();
    List<Evenement> evenementsList = new()
    {
    new Evenement()
    {
    Nom = "Evenement de test",
    Description = "Description de l'événement",
    Capacite = 10,
    Prix = 100,
    Date = DateTime.Now,
    ImagePath = "image.jpg",
    VilleId = ville.Id,
    },
    new Evenement()
    {
    Nom = "Autre evenement de test",
    Description = "Description de l'événement",
    Capacite = 100,
    Prix = 200,
    Date = DateTime.Now,
    ImagePath = "image.jpg",
    VilleId = ville.Id,
    }
    };

    //...
    info

    Remarquez que les événements sont liés à la ville nouvellement créée!

  2. Procédez à l'insertion pour chaque événement de la liste

    //...

    var evenementRepository = scope.ServiceProvider.GetRequiredService<IEvenementRepository>();
    List<Evenement> evenementsList = new()
    {
    new Evenement()
    {
    Nom = "Evenement de test",
    Description = "Description de l'événement",
    Capacite = 10,
    Prix = 100,
    Date = DateTime.Now,
    ImagePath = "image.jpg",
    VilleId = ville.Id,
    },
    new Evenement()
    {
    Nom = "Autre evenement de test",
    Description = "Description de l'événement",
    Capacite = 100,
    Prix = 200,
    Date = DateTime.Now,
    ImagePath = "image.jpg",
    VilleId = ville.Id,
    }
    };
    foreach (var evenement in evenementsList)
    {
    await evenementRepository.Create(evenement);
    }

    //...

Test

Exécutez le test et ce dernier devrait maintenant fonctionner puisqu'une liste d'événements est présente en BD! 🎉

Nettoyage

À partir d'ici, il existe plusieurs approches pour nettoyer le test. Les éléments que nous sommes susceptibles de répéter sont les suivants:

  1. La création de scope et récupération des repositories
  2. Le client HTTP
  3. Les fonctions Dispose et d'initialisation pour réinitialiser la BD
  4. Optionnellement, les insertions en BD.

Nous allons créer une classe de base sur laquelle toutes les classes de test d'intégration pourront s'appuyer.

  1. Sous le dossier Snowfall.Tests/Web.Api/TestsIntegration, créez une nouvelle classe appelée WebApiIntegrationTestBase.
  2. Déplacez les éléments en lien avec l'injection de dépendance, la BD et le client HTTP dans cette classe de base.
    Snowfall.Tests/Web.Api/TestsIntegration/WebApiIntegrationTestBase.cs
    using Microsoft.Extensions.DependencyInjection;
    using Snowfall.Data.Repositories;

    namespace Snowfall.Tests.Web.Api.TestsIntegration;

    /// <summary>
    /// Classe de base pour les tests d'intégration d'API.
    /// Gère l'injection de dépendance, la réinitialisation de la BD et le client HTTP.
    /// </summary>
    [Collection("Test Database Collection")]
    public abstract class WebApiIntegrationTestBase :
    IClassFixture<SnowfallApiApplicationFactory>,
    IAsyncLifetime,
    IDisposable
    {
    protected readonly SnowfallApiApplicationFactory Application;
    protected readonly TestDatabaseFixture Database;
    protected HttpClient Client { get; private set; } = null!;

    protected readonly IVilleRepository VilleRepository;
    protected readonly IEvenementRepository EvenementRepository;

    private readonly IServiceScope _scope;

    protected WebApiIntegrationTestBase(
    SnowfallApiApplicationFactory application,
    TestDatabaseFixture database)
    {
    Application = application;
    Database = database;

    _scope = Application.Services.CreateScope();
    VilleRepository = _scope.ServiceProvider.GetRequiredService<IVilleRepository>();
    EvenementRepository = _scope.ServiceProvider.GetRequiredService<IEvenementRepository>();
    }

    /// <summary>
    /// Avant chaque test, crée un nouveau client et réinitialise la BD.
    /// </summary>
    public async Task InitializeAsync()
    {
    await Database.ResetDatabaseAsync();
    Client = Application.CreateClient();
    }

    public Task DisposeAsync() => Task.CompletedTask;

    public void Dispose() => _scope.Dispose();
    }
  3. Faites en sorte que la classe EvenementsTests hérite de la classe de base
    Snowfall.Tests/Web.Api/TestsIntegration/EvenementsTests.cs
    public class EvenementsTests : WebApiIntegrationTestBase
  4. Retirez l'attribut en lien avec la collection puisque ce dernier est dans la classe de base
    [Collection("Test Database Collection")]
    public class EvenementsTests : WebApiIntegrationTestBase
  5. Retirez les attributs en lien avec l'application factory et TestDatabaseFixture
    public class EvenementsTests : WebApiIntegrationTestBase
    {
    private readonly SnowfallApiApplicationFactory _application;
    private readonly TestDatabaseFixture _databaseFixture;
  6. Modifiez le constructeur pour qu'il appelle simplement le constructeur de la classe de base.
    public class EvenementsTests : WebApiIntegrationTestBase
    {
    public EvenementsTests(
    SnowfallApiApplicationFactory application,
    TestDatabaseFixture database) : base(application, database)
    { }
  7. Retirez les fonctions InitializeAsync et DisposeAsync
  8. Finalement, faites en sorte que la fonction de test utilise Client , VilleRepository et EvenementRepository de la classe de base, en plus de supprimer les appels de scope pour l'injection de dépendance.
    public class EvenementsTests : WebApiIntegrationTestBase
    {
    public EvenementsTests(
    SnowfallApiApplicationFactory application,
    TestDatabaseFixture database) : base(application, database)
    { }

    [Fact]
    public async Task Obtenir_ListeEvenements_RetourneListeSucces()
    {
    // Arrange
    var ville = new Ville()
    {
    Nom = "Paris",
    PaysIso = "fr"
    };
    ville = await VilleRepository.Create(ville);

    List<Evenement> evenementsList = new()
    {
    new Evenement()
    {
    Nom = "Evenement de test",
    Description = "Description de l'événement",
    Capacite = 10,
    Prix = 100,
    Date = DateTime.Now,
    ImagePath = "image.jpg",
    VilleId = ville.Id,
    },
    new Evenement()
    {
    Nom = "Autre evenement de test",
    Description = "Description de l'événement",
    Capacite = 100,
    Prix = 200,
    Date = DateTime.Now,
    ImagePath = "image.jpg",
    VilleId = ville.Id,
    }
    };
    foreach (var evenement in evenementsList)
    {
    await EvenementRepository.Create(evenement);
    }

    string url = "api/evenements";

    // Act
    var evenements = await Client.GetFromJsonAsync<List<EvenementDto>?>(url);

    // Assert
    Assert.NotNull(evenements);
    Assert.NotEmpty(evenements);
    }
    }
info

Vous pourriez vouloir mettre dans des fonctions les blocs de seed de données pour les sortir de la fonction de test.

Je vous laisse le soin d'implémenter cette portion si vous la trouvez pertinente pour vos tests.