Durée de lecture : environ 8 minutes

Les tests d’intégration ont toujours été considérés comme lents et fragiles. Lents car ils impliquent le lancement d’API, de connexions réseau, de requêtes de base de données, etc… Fragiles car dès qu’une dépendance n’est pas disponible ou que des données sont modifiées, ces tests peuvent échouer. De plus, la création ou modification de nouvelles données a un impact important sur une instance de base de données partagée.
Cependant, nous avons besoin de tests d’intégration pour compléter les tests d’acceptance. Plus précisément, nous devons tester les interactions entre notre code et les dépendances externes : désérialisation JSON, requêtes de base de données, etc… Heureusement, de nouveaux outils tels que Verify et Testcontainers peuvent nous aider à résoudre certains problèmes. Voyons comment simplifier les tests d’intégration en C# en 2025 !

Résoudre les longues assertions multiples

Un des problèmes rencontrés avec les tests d’intégration est qu’il peut y avoir beaucoup d’assertions. Si on veut être rigoureux, on doit vérifier que le JSON renvoyé par un service HTTP ou le résultat d’un SELECT en base de données vont renvoyer ce qu’ils sont supposés renvoyer.

Code d’exemple !

J’ai créé un repository dédié à cet article. C’est un extrait du repository BookShop que Benoît et moi avons utilisé pour notre série d’articles sur les tests qui ont du sens. C’est centré sur le scénario simple « liste les livres du catalogue » avec quelques modifications ici et là. En l’occurrence j’ai ajouté une base de données PostgreSQL qui tourne dans Docker. Dans la classe BasicIntegrationTests, j’ai créé le test suivant :

[Fact]
public async Task Should_return_all_7_books_from_database_without_Verify()
{
    var options = new ConnectionStringsOptions { BookShopDatabase = "Server=localhost;Port=5432;User Id=postgres;Password=postgres;Database=bookshop;" };
    var adapter = new BookDatabaseAdapter(Options.Create(options));

    var books = await adapter.Get();

    books.Count.ShouldBe(7);

    books[0].Id.ToString().ShouldBe("978-133888319-0");
    books[0].Author.ShouldBe("Tui T. Sutherland");
    books[0].Title.ShouldBe("The Dragonet Prophecy (Wings of Fire #1)");
    books[0].NumberOfPages.ShouldBe(336);
    books[0].PictureUrl.ShouldNotBeNull();
    books[0].PictureUrl!.AbsoluteUri.ShouldBe("https://s2.qwant.com/thumbr/0x0/5/4/3dde4aa99ad8275bf403c085737594fefa3a0c6b011359b3133c455df2570e/.jpg?u=http%3A%2F%2Fwww.scholastic.ca%2Fhipoint%2F648%2F%3Fsrc%3D9780545349239.jpg%26w%3D260&q=0&b=1&p=0&a=0");

    books[1].Id.ToString().ShouldBe("978-054534919-2");
    books[1].Author.ShouldBe("Tui T. Sutherland");
    books[1].Title.ShouldBe("The Lost Heir (Wings of Fire #2)");
    books[1].NumberOfPages.ShouldBe(296);
    books[1].PictureUrl.ShouldNotBeNull();
    books[1].PictureUrl!.AbsoluteUri.ShouldBe("https://s2.qwant.com/thumbr/0x380/6/7/99129d301bb33a6fe827579d3978bac1636ed3224b6278def5209446085b14/700.jpg?u=https%3A%2F%2Fembed.cdn.pais.scholastic.com%2Fv1%2Fchannels%2Fsso%2Fproducts%2Fidentifiers%2Fisbn%2F9780545349246%2Fprimary%2Frenditions%2F700%3FuseMissingImage%3Dtrue&q=0&b=1&p=0&a=0");

    // etc...
}

C’est un sacré morceau. Et on ne teste que 2 livres, on pourrait ajouter facilement plus de 30 lignes supplémentaires pour les 5 restants. Bien sûr on pourrait refactorer quelques trucs mais le test resterait difficile à lire.

Snapshot testing 📸

Il existe une technique nommée « snapshot testing » qui peut vraiment nous aider ici. Le snapshot testing consiste en gros à comparer la sortie d’un test avec une référence préalablement enregistrée. On fait tourner le test pour la première fois, on enregistre la sortie, puis chaque lancement ultérieur vérifiera que la sortie sera bien égale à la référence. Et quand un test échoue, vous pouvez soit corriger le bug qui l’a causé soit remplacer la référence par la nouvelle sortie. Voici un diagramme de flux :

Organigramme illustrant le flux du snapshot testing :
1 - Création du test
2 - Lancement du test
3 - Enregistrement de la sortie
4 - La référence existe-t-elle ?
  4.1 - Si ce n'est pas le cas, la référence est enregistrée et le test échoue.
  4.2 - Si c'est le cas, le résultat du test correspond-il à la référence ?
    4.2.1 - S'il ne correspond pas, le test échoue.
    4.2.2 - S'il correspond, le test passe.

C’est très utile pour vérifier des sorties complexes ou longues (ex : gros payload JSON). À quel point est-ce facile à faire en .Net ?

Présentation de Verify

Grâce à Simon Cropp, il existe une library appelée Verify qui va faire tout le sale boulot pour nous. Elle est très puissante et s’intègre avec la plupart des frameworks de test. Elle va :

  • sérialiser la sortie vers une chaîne lisible,
  • vérifier si la référence existe,
  • faire la comparaison,
  • faire passer ou échouer le test,
  • lancer l’outil de diff par défaut du système si le test échoue.

On peut aussi ignorer ou remplacer certains champs spécifiques avec une valeur statique (on parle de « scrubbing »). Par défaut, Verify va scrubber certains types de données comme les GUIDs ou les DateTimes. C’est bien sûr configurable. Voici à quoi ressemble notre test après avoir remplacé toutes nos assertions manuelles par un appel à Verify :

[Fact]
public async Task Should_return_all_7_books_from_database()
{
    var options = new ConnectionStringsOptions { BookShopDatabase = "Server=localhost;Port=5432;User Id=postgres;Password=postgres;Database=bookshop;" };
    var adapter = new BookDatabaseAdapter(Options.Create(options));

    var books = await adapter.Get();

    await Verify(books);
}

C’est bien plus simple à lire ! On pourrait discuter de la perte d’intention du test. C’est en effet le cas ici mais de manière générale le snapshot testing n’est pas vraiment fait pour ça. On y reviendra.

On peut maintenant facilement vérifier des requêtes à la base ou des appels à HTTP. Super ! Mais ça ne résoud pas les princiaux problèmes soulevés dans l’introduction : fragilité, contexte partagé et performance.

Utiliser Testcontainers pour monter des dépendances en local

Le logo de Testcontainers qui est en gros un cube en 3D de couleur turquoise.

Un outil récent dont j’ai eu connaissance l’année dernière est Testcontainers. Il fournit des libraries dans divers langages afin de monter des images containerisées directement depuis votre code de test. Vous démarrez votre moteur de containers, lancez les tests et voilà !

L’avantage principal est bien évidemment de maîtriser complètement vos dépendances. Vous savez qu’elles ont démarré et sont disponibles, vous pouvez écrire ou supprimer des données à volonté, vous pouvez faire tourner des tests en concurrence dans des containers différents et chaque container sera disposé à la fin des tests. Bref, un environnement complet ou partiel à la demande, écrit dans le même code que vos tests.

Un peu de code

Dans notre test précédent, nous nous connections à la véritable base de données en passant la véritable chaîne de connexion. Instancions maintenant une base containerisée avec Testcontainers :

public readonly PostgreSqlContainer PostgreSqlContainer = new PostgreSqlBuilder()
    .WithImage("postgres:latest")
    .WithDatabase("bookshop")
    .WithUsername("postgres")
    .Build();

C’est suffisamment simple à lire. Ce bout de code ne va pas lancer le container immédiatement donc nous devons le faire dans une méthode de setup, en effectuant simplement :

await PostgreSqlContainer.StartAsync();

Une fois que le lanceur de test entrera dans un nouveau test, nous aurons une instance dédiée de notre base de données juste pour nous. On pourra s’y connecter en récupérant la chaîne de connexion depuis l’objet PostgreSqlContainer. Et on peut y ajouter Verify pour tester la sortie :

[Fact]
public async Task Should_return_all_7_books_from_database()
{
    var options = new ConnectionStringsOptions { BookShopDatabase = _postgreSqlContainer.GetConnectionString() };
    var adapter = new BookDatabaseAdapter(Options.Create(options));

    var books = await adapter.Get();

    await Verify(books, _verifySettings);
}

Et c’est tout. Voici ce que nous pourrions faire si nous voulions tester l’ajout d’un nouveau livre :

[Fact]
public async Task Should_add_a_book_into_database_and_return_all_8_books()
{
    var options = new ConnectionStringsOptions { BookShopDatabase = _postgreSqlContainer.GetConnectionString() };
    var adapter = new BookDatabaseAdapter(Options.Create(options));

    var newBook = new Book(ISBN.Parse("978-0545685436"), "Talons of Power (Wings of Fire #9)", "Tui T. Sutherland", 336, new Uri("https://i.ebayimg.com/images/g/ydEAAOSwCU1YsXIS/s-l960.jpg"));
    await adapter.Add(newBook);

    var books = await adapter.Get();

    await Verify(books, _verifySettings);
}

On obtient des tests d’intégration robustes qui tournent en local, dans le même langage et le même projet que le reste de nos tests.

Considérations relatives aux performances

Créer et démarrer le container PostgreSQL prend 3 à 4 secondes sur mon ordinateur (sans compter la récupération de l’image qui peut prendre jusqu’à une minute). Ce n’est pas mal. En revanche, si l’on construit PostgreSqlContainer dans notre classe de test, alors on créera un container par test. Cela signifie 3-4 secondes par test. Ça ne semble pas optimal, surtout si nos tests sont indépendants et peuvent être lancés en toute sécurité côte à côte sur la même instance de base de données.

C’est pourquoi nous pouvons mettre ce code qui initialise et démarre le container dans un setup unique. Avec xUnit on parle de ClassFixture et vous pouvez en voir un exemple ici. La classe de test utilise cette fixture et vous pouvez y aller : chaque test de la classe utilisera le même container qui ne sera instancié qu’une seule fois.

Résultats de tests dont le container est mis en ClassFixture : il faut moins de 3 secondes pour démarrer et ensuite chaque test prend 17 ms, 22 ms et 77 ms à s'exécuter.
Notez comme les tests vont vite une fois le container démarré.

Configuration de la base de données

Une chose dont je n’ai pas encore parlé est la configuration de la base de données. Évidemment, lorsqu’on crée une base depuis zéro, il manquera le schéma et les données dont nous aurons besoin pour faire tourner les tests. Il nous faudra donc restaurer un backup existant ou tout créer à la main au préalable.

Dans notre projet d’exemple, j’ai opté pour la première méthode. Un script de backup est stocké dans le sous-répertoire Backups du projet de tests et est restauré dans la méthode InitializeAsync() de notre fixture. Le code consiste simplement à appeler ExecScriptAsync() sur l’objet portant le container. J’avais pu faire la même chose avec SQL Server mais c’était plus compliqué.

Accès concurrent

Un autre sujet dont il faut être conscient est l’accès concurrent. Parfois vous voudrez avoir plusieurs tests tournant en parallèle et utilisant la même base de données. D’autres fois, à cause de l’imprévisibilité et des race conditions (ex : un test écrit dans une table et un autre lit dedans), vous voudrez l’éviter. C’est là que vous voudrez vous pencher sur la façon dont votre framework de test gère la parallélisation. Dans le cas de xUnit, il y a toute une documentation sur le sujet.

Dans notre projet d’exemple, j’ai choisi de ne rien faire de spécial. Bien qu’ils partagent la même container de base de données, les tests situés dans la même classe vont tourner de façon séquentielle. C’est le comportement par défaut de xUnit et il suffit pour les besoins de cet article. Mais vous pouvez vouloir séparer les problèmes et créer différents contextes ou être plus explicite dans la configuration du lanceur de tests.

Points d’attention

Mosaïque au sol provenant de Pompéi représentant un chien en laisse à l'air méchant. Sous le chien figure l'inscription « Cave canem », qui signifie « Attention au chien » en latin.

N’utilisez Verify que pour certains scénarios

Bien que j’adore la facilité avec laquelle Verify nettoie le code de test, je trouve qu’il fait ça un peu trop bien. Comme vu précédemment, je pense qu’il cache les intentions derrière le test : en supprimant les assertions, on ne comprend plus vraiment ce que l’on veut tester. De façon corollaire, j’évite Verify lorsque je travaille en TDD où l’intention derrière le test est primordiale. Cependant, il est possible de combiner des intentions spécifiques dans des assertions distinctes et Verify dans un même test.

Pas de vérification du réseau

Le but de Testcontainers est de nous permettre de tester comment se comporte notre code une fois connecté à nos dépendances. On ne vérifie pas si le réseau est OK (ex : notre application a-t-elle bien accès aux dépendances ? Pas de problème de droit, etc… ?). De mon côté j’utilise plutôt des health checks déployés avec le code de production pour répondre à ces besoins.

Ayez un backup à portée de main

Un autre point à prendre en compte lorsqu’on utilise des bases de données containerisées est d’avoir un backup correct à restaurer à disposition. Il doit être suffisamment récent pour contenir les dernières modifications de schéma. Cela signifie aussi pouvoir accéder à ce backup depuis notre environnement local mais aussi depuis l’usine de build, ce qui n’est pas toujours aisé. Une bonne stratégie est de voir avec votre DBA pour la mise à disposition d’une image différée de la prod. C’est une pratique fréquente de les générer donc tout ce dont vous avez besoin c’est d’y avoir accès.

Performances

Comme dit plus haut, on parle tout de même de monter un container, soit un OS virtuel lançant par exemple un serveur de base de données. Et cela a un coût de lancement non négligeable. Sur mon laptop, on parle de 3 à 4 secondes pour lancer un Postgresql. Il est donc impensable de faire ça pour chaque test. Dans un prochain article, je reviendrai sur quelques astuces pour améliorer ce point.

Conclusion

Même s’il y a toujours des limitations, j’aime bien la direction que prennent les tests d’intégration en C#. On a la possibilité de constuire des tests fiables, répétables, plus ou moins rapides. On peut tester nos adaptateurs vers nos dépendances externes sans un horrible code de plomberie, ce qui est très appréciable pour la lisibilité et la maintenabilité.
En fonction des projets, le but sera de trouver l’équilibre entre quelle(s) dépendance(s) appeler directement et lesquelles peuvent être testées dans un Docker local. Mais j’estime que c’est une question plus saine que de se dire « oh non, je vais devoir faire des tests d’intégration, ça va être une tannée » 😊

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 on the problem space and ask questions to business experts before even coding the feature.


Guillaume Téchené

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 on the problem space and ask questions to business experts before even coding the feature.