Durée de lecture : environ 5 minutes

Depuis 2008 que je fais du C#, le monde des tests unitaires est globalement composé de 2 grands acteurs : NUnit ou xUnit. MSTest existe aussi mais je ne sais pas dans quelles proportions il est répandu ; je ne l’ai quant à moi jamais rencontré en environnement professionnel. Il existe aussi d’autres libraries plus confidentielles avec lesquelles je n’ai personnellement jamais joué. De mon point de vue, le choix entre NUnit et xUnit était surtout une histoire de préférence. Je n’ai jamais réellement constaté de réelle différence entre l’une ou l’autre, bien que les attributs [Fact] et [Theory] de xUnit m’aient toujours rendu un peu perplexe.

Cependant, depuis peu, un nouveau venu fait de plus en plus parler de lui : TUnit. Au-delà de l’effet de nouveauté, y a-t-il un véritable intérêt à l’utiliser ? Dans cet article, je vais vous faire part de mon expérience avec TUnit et pourquoi je pense qu’il apporte un grand bol d’air frais dans le petit monde des tests en C#.

ATTENTION : je me suis récemment rendu compte que TUnit était développé avec l’aide de Claude.

Le besoin

Chez mon client actuel, nous utilisons des tests « gros grains » qui lancent l’API à tester ainsi que sa dépendance à une base de données PostgreSQL. Nous avons opté pour une stratégie où un unique container Postgre est mis à disposition de tous les tests et chaque test crée sa propre base de données en isolation de tous les autres. C’est ce qui a inspiré mon précédent article sur les tests avec Testcontainers.

Cependant, nous utilisions à l’origine xUnit et nous nous sommes aperçus que tous les tests à l’intérieur d’une même collection ne pouvaient tourner que séquentiellement, jamais en parallèle. Étonnant ! Même en fouillant et hormis quelques hacks un peu trop techniques, il n’y avait pas de réelle solution. Même son de cloche côté NUnit. À noter que c’était il y a plus d’un an, quand la v3 de xUnit existait mais pas la « v3 4 » sortie en août dernier qui semble apporter plus de souplesse sur le sujet. On notera la grande confusion autour des numéros de version de xUnit, qui n’arrange rien.

Schéma décrivant la situation dans le cas de xUnit. Dans une session de tests (soit 1 projet de tests), on a 1 container PostgreSQL qui contient N base de données, nommées "database_<GUID>".
Chaque base de données est reliée à 1 test. Les tests sont groupés par "Test collection" au-dessus desquelles il est mentionné "1 Test collection = 1 thread".
Dans le cas xUnit : 1 thread par test collection (= 1 classe le plus souvent).

Ayant vu une vidéo de Nick Chapsas vantant les performances de TUnit, j’ai voulu creuser un peu plus le sujet. Un des atouts mis en avant dans la doc officielle est la parallélisation par défaut. J’ai très vite accroché à la syntaxe lisible et parlante et ai décidé de faire une branche dans notre repository où tous les tests utiliseraient TUnit plutôt que xUnit. La migration s’est faite assez bien, le plus gros du travail étant de bien faire en sorte que nos tests puissent réellement tourner en parallèle 😅

Schéma décrivant la situation dans le cas de TUnit. Dans une session de tests (soit 1 projet de tests), on a 1 container PostgreSQL qui contient N base de données, nommées "database_<GUID>".
Chaque base de données est reliée à 1 test. Une inscription sur le côté en rouge dit "1 test = 1 thread.
Dans le cas de TUnit : 1 thread par test.

Au bout de quelques semaines, j’ai présenté le résultat à l’équipe : les performances étaient nettement meilleures, de l’ordre de 20 à 25% au global, en local comme sur les agents de build.

Concrètement

La migration

La migration xUnit → TUnit se fait très facilement pour peu qu’on n’utilise pas trop de fonctionnalités avancées comme les IClassFixture. Par exemple, j’avais fait une sorte de « parallel run » où je maintenais la branche qui migrait vers TUnit pendant quelques semaines le temps de voir s’il y avait un gain réel et aucun blocage au niveau de l’ajout de tests. J’adaptais donc au fur et à mesure les nouveaux tests dans la branche et c’était très rapide à faire. Le plus long a été de bien câbler la fixture qui mettait le container en place. Heureusement la doc de TUnit est très complète, notamment la partie sur le cycle de vie des objets et des attributs.

Le lancement des tests

Comme pour la plupart des frameworks de tests, on peut lancer les tests TUnit depuis l’IDE ou la ligne de commande. Par contre, pas besoin de lib de runner dédiée comme xunit.runner ou NUnit Console ; en effet, les tests TUnit compilent directement dans un exécutable qui peut être lancé directement.

En ligne de commande (CLI)

Il existe diverses options ; pour notre part, nous avons opté pour la commande suivante en local :

dotnet run --project <chemin vers le csproj de tests> --no-build

C’est simple, direct, on a un résumé des tests qui se met à jour avec de la couleur, bref c’est parfait. Pas besoin de spécifier le parallélisme maximal, TUnit prendra par défaut le max disponible. Sur la CI en revanche, on a opté pour quelque chose de plus complet avec reporting au format TRX et avec un parallélisme fixe pour limiter les ressources (mais je ne suis pas persuadé que ce soit encore utile aujourd’hui) :

dotnet <chemin vers la DLL de tests> --disable-logo --maximum-parallel-tests 4 --progress off --report-trx --output Detailed

Dans l’IDE (Rider ou VS avec ReSharper)

On utilise ReSharper donc tout fonctionne mais, TUnit utilisant la Microsoft Testing Platform (comme la plupart des frameworks récents en fait), il faut cocher une option :

Fenêtre d'options dans ReSharper. Dans la section Tools -> Unit Testing -> Test Frameworks -> Testing Platform, il y a 2 options. La première, "Enable Testing Platform support" est cochée.

Il faut cocher « Enable Testing Platform support » pour que les tests soient découverts, sinon on ne verra rien. Mais à part ça tout fonctionne.

NCrunch : pas disponible !

Étant un grand utilisateur de NCrunch depuis 10 ans, j’ai eu la mauvaise surprise de voir que TUnit n’était pas supporté. C’est malheureux car, vu les performances de TUnit, ce serait une très belle combinaison avec NCrunch. Tant pis !

Résultats

Des surprises

Une fois que j’ai eu tout câblé et que tout a bien tourné, j’ai eu quelques surprises. En effet, au bout d’un certain nombre de tests, PostgreSQL affichait une erreur « too many connections » 🤔 En effet, certaines connexions dans notre code de prod étaient mal fermées, ce qui ne se voyait pas forcément en vrai mais s’est matérialisé très vite avec 200-300 tests en concurrence. En gros, nos tests d’acceptance ont joué le rôle de stress tests pour notre code, là où la sérialisation introduite par xUnit cachait les problèmes.

Un gain non négligeable

Comme dit précédemment, sur mon parallel run j’ai pu observer des gains de 20 à 25% sur notre CI ; en gros à l’époque on passait de ~1m20s – 1m30s à moins de 1 minute pour 200 à 300 tests juste en changeant de framework.

TUnit : le choix de la modernité

De mon point de vue aujourd’hui, la conclusion est claire : utiliser TUnit enlève des barrières inutiles posées par des frameworks vieillissants tout en privilégiant les performances. Donc utiliser TUnit sur un nouveau projet me paraît aller de soi.
Sur un projet existant, la question peut se poser. Si les perfs ne sont pas un sujet et que tout le monde est relativement content du framework en place, pas vraiment besoin de bouger. En revanche, si les perfs ne sont pas au rendez-vous ou dans le cadre d’une migration (ex : xUnit v2 → v3), pourquoi ne pas opter pour TUnit ? La migration est simple voire automatique et quelques lancements en local et sur la CI diront tout de suite si quelque chose cloche.

Pour terminer sur le sujet des performances, TUnit publie un benchmark rejoué régulièrement en fonction des dernières versions des frameworks. Il montre le gap générationnel entre TUnit et le reste ; en fait l’approche AOT fait tout et xUnit v3 l’a bien compris en le proposant dans sa dernière version (la v3 4… bref j’en ai déjà parlé).

De notre côté, le choix TUnit s’avère aujourd’hui payant. Outre les gains mentionnés plus haut, j’ai poussé un peu le sujet des performances… pour arriver à jouer plus de 1000 tests gros grain en environ 10 secondes en local ! Je reviendrai sur le chemin vers cette optimisation dans un prochain article.

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.

Catégories : Dev

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.