Durée de lecture : environ 10 minutes

On vient de voir dans l’article précédent ce que les builders associés à une API fluent pouvaient apporter à la lisibilité des tests. En revanche, on s’est aperçu rapidement que la complexité du code purement technique augmentait en fonction de la complexité du code à tester. Nous allons donc nous intéresser à ce qu’on peut mettre en place pour y remédier ; mais avant cela, nous allons vous présenter la situation de laquelle nous partions au tout début de cette aventure.

Au commencement étaient les tests unitaires

Quand je suis arrivé chez mon client en mars 2020, notre API était très bien couverte : tests unitaires pour notre code et tests end-to-end avec Selenium pour tous les sites. En revanche, comme il s’agit d’un backend unique pour tous les sites du groupe, on avait une complexité assez importante. Les tests unitaires étaient donc assez lourds, parfois des classes de plusieurs milliers de lignes, où chaque test pouvait peser plusieurs centaines de lignes et le setup global 500 à 600 lignes. La lisibilité, la maintenance et l’ajout de nouveaux tests étaient donc très pénibles, causant les développeurs à user et abuser de copier/coller hasardeux. Ce qui rendait les tests encore moins lisibles, créant une sorte de spirale infernale dont il était difficile de sortir.

Doit-on vraiment tester chaque brique en isolation ou veut-on tester un scénario complet en ne stubbant que les I/O ?

On s’est d’abord posé la question de la granularité des tests : doit-on vraiment tester chaque brique en isolation ou veut-on tester un scénario complet en ne stubbant que les I/O ? Avec l’équipe on a opté pour la deuxième possibilité, donc on est partis début 2021 sur le système de builders vu précédemment. Le travail a pris du temps en raison de la complexité du code de prod… et cela s’est reflété dans les builders. Si les tests en eux-mêmes étaient devenus plus agréables, on a rencontré rapidement des gros soucis : dépendances en cascade, mélange de la création des dépendances et des stubs, bref tout ce qu’on mentionnait dans l’article précédent mais à l’échelle d’un véritable code de backend pour un site d’e-commerce. Comme vu dans le paragraphe “Améliorations“, nous avions aussi beaucoup d’objets Specifications dont le but était de refléter un modèle ou un état du point de vue métier.

Début 2022, à la faveur d’un gros chantier de migration technique (changement de progiciel au niveau de toute l’entreprise avec gros impact IT), on a alors commencé à vouloir découper la plomberie des builders, notamment en séparant les responsabilités “spécifications métier/stub/dépendances/tests”.

Scénarios : des builders métier pour mettre en place un contexte de test

Une des critiques de notre code de 2021 était qu’on spécifiait tout dans le test, y compris des données peu voire pas utiles, afin de faire passer le test. Cela créait de la verbosité pour rien. De même, le fuzzer était nécessairement créé dans les tests eux-mêmes alors qu’il s’agit d’un détail d’implémentation qui ne devrait pas apparaître à cet endroit.

Définition

On a donc fait émerger la notion de scénario : un objet avec une API fluent dont le but était de s’approcher d’un langage dédié (ou Domain-Specific Language) afin de ne parler que de métier et pas de technique. On a créé une sorte de “builder fonctionnel” où tout est mis en place pour que le contexte du “happy path” soit généré automatiquement. En fait, on a repris ici le pattern Object Mother de Martin Fowler. On lui délègue alors la gestion du fuzzing, ce qui permet de sortir pas mal de code technique ou de setup de nos tests. Les tests deviennent alors nettement plus lisibles car n’expriment que la partie métier importante : ce que l’on veut réellement tester.

En pratique

Repartons de notre code BookShop avant les tests en reprenant le premier test : lister tous les livres du catalogue quand notre API est appelée sur la méthode GET de la route api/Catalog. Ici, on est en plein happy path : pas de condition particulière, cas d’utilisation basique, on veut que ça marche et la fonctionnalité de pagination ne doit pas être un frein. La déclaration du scénario est donc très simple :

C#
public class CatalogControllerShould
{
    [Fact]
    public async Task List_all_books_when_called_on_GetCatalog()
    {
        var scenario = new CatalogListScenario();
    }
}

À l’intérieur du scénario en revanche, on fait pas mal de boulot : déclaration du fuzzer (en laissant l’option d’introduire le seed depuis le test via le constructeur) et création des BookSpecifications qui seront ensuite accessibles à qui les voudra :

C#
public class CatalogListScenario
{
    private const int DefaultNumberOfBooksPerPage = 5;
    public BookSpecification[] Books { get; }

    public CatalogListScenario(int? seed = null)
    {
        var fuzzer = new Fuzzer(seed);

        var numberOfBooksToGenerate = fuzzer.GenerateInteger(1, DefaultNumberOfBooksPerPage);
        Books = Enumerable.Range(1, numberOfBooksToGenerate)
            .Select(_ => new BookSpecification(fuzzer))
            .ToArray();
    }
}

C’est assez simple en fait : on a déporté les morceaux de technique restant dans les tests vers une classe dédiée, notre “builder fonctionnel”. Si on voulait rester sur les builders de l’article précédent, il suffirait alors de passer scenario.Books à la méthode WithBooks() du builder et en route.

En allant plus loin

Si maintenant on voulait faire des choses un peu plus complexes, il faudrait enrichir notre scénario. Par exemple si on voulait tester la pagination avec 3 livres par page, 5 livres dans le catalogue et vérifier qu’on a bien 2 pages dont une avec 3 livres et l’autre avec 2 livres, on pourrait écrire le test ainsi :

C#
public class CatalogControllerShould
{
    [Fact]
    public async Task Return_2_pages_when_there_are_5_books_in_the_catalog_and_the_number_of_books_to_display_on_one_page_is_3()
    {
        var scenario = new CatalogListScenario()
            .WithNumberOfBooksPerPage(3)
            .WithRandomBooks(5);
    }
}

Le scénario devient alors, avec un peu de refactoring :

C#
public class CatalogListScenario
{
    private int _numberOfBooksPerPage = 5;
    private readonly Fuzzer _fuzzer;

    public BookSpecification[] Books { get; private set; }

    public CatalogListScenario(int? seed = null)
    {
        _fuzzer = new Fuzzer(seed);

        var numberOfBooksToGenerate = _fuzzer.GenerateInteger(1, _numberOfBooksPerPage);
        Books = GenerateRandomBooks(numberOfBooksToGenerate);
    }
  
    public CatalogListScenario WithNumberOfBooksPerPage(int numberOfBooksPerPage)
    {
        _numberOfBooksPerPage = numberOfBooksPerPage;
        return this;
    }
  
    public CatalogListScenario WithRandomBooks(int numberOfBooksToGenerate)
    {
        Books = GenerateRandomBooks(numberOfBooksToGenerate);
        return this;
    }

    private BookSpecification[] GenerateRandomBooks(int numberOfBooksToGenerate)
    {
        return Enumerable.Range(1, numberOfBooksToGenerate)
            .Select(_ => new BookSpecification(_fuzzer))
            .ToArray();
    }
}

Le test reste lisible, le scénario se borne à créer les bons objets pour que le test passe, c’est parfait. Mais… le test ne teste rien du tout pour l’instant 😁

Appeler l’API grâce à la WebApplicationFactory

Présentation

Ce composant permet de lancer une API in-memory en passant par le conteneur d’IoC de .Net.

C’est là que Benoît nous a montrés la WebApplicationFactory de .Net. Ce composant permet de s’affranchir des builders en lançant une API in-memory dans les tests et en passant par le conteneur d’IoC de .Net. Cela nous permet d’éviter de recréer toutes les dépendances à la main, ce qui est un gain énorme en complexité. Un exemple tout simple pour lancer l’API, la requêter et récupérer la réponse :

C#
var api = new WebApplicationFactory<Program>();
var client = api.CreateDefaultClient();
var response = await client.GetAsync("api/Catalog?currency=EUR");
Check.That(response.StatusCode).IsEqualTo(200);

(modulo l’ajout de la ligne public partial class Program { } comme préconisé par la doc officielle, une sorte de bidouille pour faire fonctionner la WebApplicationFactory avec les minimal APIs)
Et c’est tout. En 4 lignes on a créé l’API avec toutes ses dépendances, on a créé un HttpClient pour pouvoir la requêter et on a vérifié que la réponse était un code 200.

Et pour les tests ?

Alors tout c’est c’est bien sympa, mais encore faut-il pouvoir stubber les I/O sinon on va marcher sur les plate-bandes des tests d’intégration. Là encore, la WebApplicationFactory permet de faire ça facilement via la méthode ConfigureTestServices() :

C#
var api = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
    builder.ConfigureTestServices(services =>
    {
        var metadataProvider = Substitute.For<IProvideBookMetadata>();
        services.AddTransient<IProvideBookMetadata>(_ => metadataProvider);
    });
});

C’est donc ici que vont s’effectuer tous les stubs. En revanche, si on doit y mettre tous les stubs de toutes les dépendances, ça va vite devenir invivable. Il nous reste alors à introduire la dernière pièce du puzzle : les simulateurs.

Les simulateurs : les briques dédiées aux stubs

Là où les scénarios concentrent les données relatives aux tests, les simulateurs consistent à stubber chacun une dépendance en fonction de ce qui est précisé dans le scénario. Il suffira alors d’utiliser ce simulateur pour remplacer la dépendance dans la WebApplicationFactory et on aura découpé correctement le code en fonction des responsabilités. Rien de bien compliqué ici.
Par exemple, en stubbant tout correctement, notre premier test ressemble à ceci :

C#
[Fact]
public async Task List_all_books_when_called_on_GetCatalog()
{
    var scenario = new CatalogListScenario();

    var api = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            var metadataProvider = Substitute.For<IProvideBookMetadata>();
            var bookReferences = scenario.Books.Select(book => book.ToBookReference()).ToList();
            metadataProvider.Get().Returns(bookReferences);

            var inventoryProvider = Substitute.For<IProvideInventory>();
            var books = scenario.Books.Select(book => book.ToBook()).ToList();
            inventoryProvider.Get(Arg.Any<IEnumerable<BookReference>>())
                .Returns(callInfo =>
                {
                    var requestedBooksIsbns = callInfo.Arg<IEnumerable<BookReference>>();
                    return books.IntersectBy(requestedBooksIsbns, book => book.Reference);
                });

            services.AddTransient(_ => metadataProvider);
            services.AddTransient(_ => inventoryProvider);

            services.AddTransient(_ => new BookAdvisorHttpClient(new HttpClient(new StubHttpMessageHandler())
            {
                BaseAddress = new Uri("https://fake-address-for-tests")
            }));
        });
    });
    var client = api.CreateDefaultClient();
    var response = await client.GetAsync("api/Catalog?currency=EUR");
    Check.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
}

StubMessageHandler est une classe bidon qui dérive de HttpMessageHandler pour renvoyer une liste vide de ResponseRatings.
C’est un peu lourd, donc on va introduire ici un simulateur pour chaque brique : les metadata, l’inventaire et les ratings. Exemple de simulateur, avec l’inventaire :

C#
public class InventorySimulator
{
    private readonly IProvideInventory _inventoryProvider;

    public InventorySimulator(CatalogListScenario scenario)
    {
        _inventoryProvider = Substitute.For<IProvideInventory>();
        Simulate(scenario);
    }

    private void Simulate(CatalogListScenario scenario)
    {
        var books = scenario.Books.Select(book => book.ToBook()).ToList();
        _inventoryProvider.Get(Arg.Any<IEnumerable<BookReference>>())
            .Returns(callInfo =>
            {
                var requestedBooksIsbns = callInfo.Arg<IEnumerable<BookReference>>();
                return books.IntersectBy(requestedBooksIsbns, book => book.Reference);
            });
    }

    public void Register(IServiceCollection services)
    {
        services.AddTransient(_ => _inventoryProvider);
    }
}

Si on bouge aussi le code de création de l’API dans une classe dédiée, cela permet d’avoir un test concis et plutôt clair d’un point de vue métier :

C#
[Fact]
public async Task List_all_books_when_called_on_GetCatalog()
{
    var scenario = new CatalogListScenario();
    var api = new CatalogApi(scenario);

    var response = await api.GetCatalog("EUR");

    Check.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
    var catalogResponse = await response.Content.ReadFromJsonAsync<CatalogResponse>();
    Check.That(catalogResponse).IsNotNull();
    Check.That(catalogResponse!.Books).HasSize(scenario.Books.Length);
    Check.That(catalogResponse.TotalNumberOfPages).IsEqualTo(1);
}

Le code complet jusqu’ici est disponible à cet endroit du repository Github. On y trouvera aussi l’implémentation complète du test visant à tester la pagination.

Les tests SAS : Scenario/API/Simulator

En combinant ces 3 notions, scénarios, API et simulateurs, on s’aperçoit qu’on arrive à écrire des tests d’acceptance clairs, bien découpés et permettant d’isoler chaque comportement.
On résoud ainsi le problème de dépendances et de code spaghetti que l’on pouvait rencontrer avec les builders, même si, nous le verrons plus tard, le système SAS n’est pas exempt de défauts.
En revanche, on a toujours un peu de code très technique, ne serait-ce que pour instancier la WebApplicationFactory ou enregistrer chaque simulateur dans l’IoC.

Une librairie pour faciliter les tests SAS

Afin d’externaliser la plomberie et l’effort technique, Benoît a créé un package Nuget appelé sas. Il s’agit d’une librairie offrant une syntaxe fluide et des objets facilitant l’écriture de tests au format “scénario/API/simulateurs”. Voyons comment l’utiliser dans le cadre des tests écrits précédemment.

Logo de la librairie SAS généré par IA, un rond bleuté avec les lettres SAS en blanc.
Logo de la librairie (généré par IA)

L’interface BaseScenario comme marqueur

Concernant les scénarios, peu de changements. En effet, ces objets ayant pour but de modéliser le contexte métier propre à nos tests, il sera difficile de faire une librairie utile à tous. En revanche, on peut faire dériver chaque scénario de la classe BaseScenario qui nous sera utile pour l’instanciation de l’API mais qui n’offre rien de particulier.

Construction de l’API : se débarrasser de la plomberie

Dans le test précédent, on a créé une classe CatalogApi dont le but est de s’abstraire de la WebApplicationFactory et d’offrir des méthodes parlantes qui vont correspondre aux endpoints de l’API à tester. Le travail d’initialisation du constructeur est un peu rébarbatif : pour chaque simulateur, on l’enregistre dans le conteneur d’IoC de la WebApplicationFactory en passant (ou non) le scénario afin de faciliter le stub. Si on devait créer une autre API, par exemple pour le checkout du panier, on devrait probablement copier/coller une bonne partie de ce code, certes avec d’autres simulateurs mais tout de même très similaire. C’est pour remédier à cela que la librairie sas offre d’une part la classe BaseApi (et son équivalent lazy, LazyBaseApi) et la classe BaseSimulator (dans le package sas.simulators.nsubstitute ; on utilisera AbstractSimulator dans le cadre d’une autre librairie de mock) d’autre part.

Le but du jeu est tout simplement de faire hériter chaque simulateur de BaseSimulator, de surcharger la méthode Simulate() afin d’implémenter le stub et pour finir de faire dériver notre CatalogApi de BaseApi. Enfin dans le constructeur de l’API, on listera les simulateurs. C’est tout. Voyons ce que cela donne avec notre exemple.

Les simulateurs

Voici ce que donne un simulateur simple comme l’InventorySimulator :

C#
public class InventorySimulator : BaseSimulator<IProvideInventory>
{
    protected override void Simulate(BaseScenario baseScenario)
    {
        if (baseScenario is not CatalogListScenario scenario)
        {
            return;
        }

        var books = scenario.Books.Select(book => book.ToBook()).ToList();
        Instance.Get(Arg.Any<IEnumerable<BookReference>>())
            .Returns(callInfo =>
            {
                var requestedBooksIsbns = callInfo.Arg<IEnumerable<BookReference>>();
                return books.IntersectBy(requestedBooksIsbns, book => book.Reference);
            });
    }
}

En seulement 11 lignes, on fait tout ce qu’on faisait avant sans la plomberie encombrante.

On le voit, il ne reste plus que la partie qui a une vraie valeur ajoutée, à savoir le stub en lui-même. La création du stub via NSubstitute et l’enregistrement dans l’IoC sont faits pour nous ; on n’a qu’à utiliser BaseSimulator avec le bon type et utiliser le champ Instance exposé par la classe parente.
On note au passage l’utilisation du marqueur BaseScenario dans la surcharge de la méthode Simulate().

Avec un simulateur un peu plus complexe comme celui du client HTTP vers BookAdvisor, c’est encore mieux :

C#
public class BookAdvisorSimulator : BaseHttpClientSimulator<BookAdvisorHttpClient>
{
    protected override void Simulate(BaseScenario scenario)
    {
        HttpClient.Get(Arg.Is<string>(route => route.StartsWith("reviews/ratings")))
            .Returns(_ => new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = JsonContent.Create(new RatingsResponse(5m, 1))
            });
    }
}

En seulement 11 lignes, on fait tout ce qu’on faisait avant sans la plomberie encombrante du HttpMessageHandler ni l’enregistrement dans l’IoC. Là encore, dériver de la classe BaseHttpClientSimulator (dans le package sas.simulators.http.nsubstitute) nous permet de nous concentrer sur le seul code qui a de la valeur ajoutée.

Quid de l’API ?

Côté API c’est la même chose :

C#
public class CatalogApi : BaseApi<Program>
{
    private CatalogApi(CatalogListScenario scenario, ISimulateBehaviour[] simulators, IEnrichConfiguration[] configurations)
      : base(scenario, simulators, configurations) {}

    public static CatalogApi CreateApi(CatalogListScenario scenario)
    {
        return new CatalogApi(scenario, [
            new BookAdvisorSimulator(),
            new InventorySimulator(),
            new MetadataSimulator()
        ], []);
    }

    public async Task<HttpResponseMessage> GetCatalog(string currency, int numberOfBooksPerPage = 5)
    {
        return await HttpClient.GetAsync($"api/Catalog?currency={currency}&pageNumber=1&numberOfItemsPerPage={numberOfBooksPerPage}");
    }
}

Toute la gestion de la WebApplicationFactory a disparu, c’est la BaseApi qui s’en charge. À noter ici qu’on doit spécifier le type du point d’entrée de l’application ; ici dans le cadre d’une Minimal API il s’agit de la classe Program mais dans de l’ASP Core traditionnel ce sera plus probablement Startup.

On n’a plus de code technico-technique : on instancie notre BaseApi en passant les simulateurs, éventuellement les classes permettant de surcharger la configuration, et c’est tout. On peut se focaliser sur l’écriture des méthodes qu’on pourra exposer aux tests, tout en bénéficiant de la propriété HttpClient offerte par la BaseApi.

Bonus : des utilitaires pour la gestion des payloads

D’autre part, si on regarde le code du test, on note aussi la présence régulière de désérialisation de JSON et autres checks similaires. Le package sas.nfluent propose plusieurs extensions à NFluent pour faciliter un peu tout ça et récupérer le payload sans transpirer. Notre premier test devient alors :

C#
[Fact]
public async Task List_all_books_when_called_on_GetCatalog()
{
    var scenario = new CatalogListScenario();
    var api = CatalogApi.CreateApi(scenario);

    var response = await api.GetCatalog("EUR");

    Check.That(response).IsOk<CatalogResponse>()
        .WhichPayload(catalogResponse =>
        {
            Check.That(catalogResponse).IsNotNull();
            Check.That(catalogResponse!.Books).HasSize(scenario.Books.Length);
            Check.That(catalogResponse.TotalNumberOfPages).IsEqualTo(1);
        });
}

C’est un peu plus sympa que de faire des ReadFromJsonAsync() et des checks de status codes.

Le code complet utilisant la librairie sas peut se trouver sur la branche with_sas_library du repo GitHub.

Des tests mieux organisés pour se concentrer sur l’essentiel

On vient de le voir, le concept des tests SAS couplé à la librairie éponyme permet d’avancer sur de nombreux points :

  • lisibilité du test avec une orientation métier claire et une syntaxe fluide ;
  • découpage et isolation des responsabilités ;
  • réduction du code boilerplate à sa plus simple expression ;
  • test de tout notre code, de l’endpoint de l’API à l’appel des couches d’infra ;
  • plus besoin de recréer l’arbre des dépendances dans notre code de test ;
  • on peut choisir de faire plusieurs API dans le code de tests même si le code de prod contient un seul gros controller ;
  • on peut, en se passant des simulateurs, garder la même structure pour faire des tests d’intégration ;
  • bonus : quelques helpers permettant de fluidifier encore plus l’écriture et la lecture des tests.

En revanche, les tests SAS ne résolvent pas tout : si le code de prod est complexe avec beaucoup d’adapters d’infra sollicités et des dépendances en cascade, cela sera reflété dans le nombre de simulateurs et de scénarios. D’ailleurs si les briques métier sont mal découpées, on le verra très vite au niveau de nos scénarios qui pourront vite devenir compliqués à construire et maintenir.
Un autre point à prendre en compte est la montée en compétences sur ce type de test. Il faut le faire progressivement, avec toute l’équipe et idéalement sur un nouveau projet en ensemble programming. Introduire des tests SAS sur un projet existant et ayant une certaine complexité peut s’avérer périlleux et chronophage, tant du point de vue technique que du point de vue de la conduite du changement.

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things.
I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs.
This is why I favor a test-first approach to focus first on the problem space and ask questions to business experts before even coding the feature.

Categories: Dev