Choix d’une stratégie de test

Comme indiqué dans la section Vue d’ensemble, une décision fondamentale à prendre est de déterminer si vos tests impliqueront votre système de base de données de production (comme votre application) ou si vos tests s’exécuteront sur un double de test, qui remplace votre système de base de données de production.

La réalisation de tests sur une ressource externe réelle, plutôt que de la remplacer par un double de test, peut soulever les difficultés suivantes :

  1. Dans de nombreux cas, il n’est tout simplement pas possible ou peu pratique de réaliser des tests sur la ressource externe réelle. Par exemple, il est possible que votre application interagisse avec un service difficilement testable (en raison d’une limitation de débit ou de l’absence d’un environnement de test).
  2. Même lorsqu’il est possible d’impliquer la ressource externe réelle, le processus peut être excessivement lent. En effet, la réalisation d’une grande quantité de tests sur un service cloud peut entraîner un délai d’exécution trop long. Les tests doivent faire partie du flux de travail quotidien d’un développeur. Il est donc nécessaire que les tests s’exécutent rapidement.
  3. L’exécution de tests sur une ressource externe peut impliquer des problèmes d’isolement, dans le cadre desquels les tests interfèrent les uns avec les autres. Par exemple, l’exécution simultanée de plusieurs tests sur une base de données peut modifier les données et entraîner l’échec de chacun d’entre eux de diverses manières. L’utilisation d’un double de test permet d’éviter cela en exécutant chaque test sur sa propre ressource en mémoire, ce qui l’isole naturellement des autres tests.

Cependant, les tests menés avec succès sur un double de test ne garantissent pas que votre programme fonctionne lorsqu’il est exécuté sur la ressource externe réelle. Par exemple, un double de test de base de données peut effectuer des comparaisons de chaînes en tenant compte de la casse, tandis que le système de base de données de production effectue des comparaisons sans tenir compte de la casse. Ces problèmes ne sont détectés que lorsque les tests sont exécutés sur votre base de données de production réelle. Par conséquent, ces tests sont un élément important de toute stratégie de test.

L’exécution de tests sur la base de données peut être plus facile qu’il n’y paraît

En raison des difficultés mentionnées ci-dessus concernant les tests effectués sur une base de données réelle, les développeurs sont fréquemment invités à utiliser les doubles de test en premier, et ils disposent d’une suite de tests robuste qu’ils peuvent exécuter fréquemment sur leurs machines. Les tests impliquant la base de données, en revanche, sont censés être exécutés beaucoup moins fréquemment et, dans de nombreux cas, ils offrent une couverture moindre. Nous vous recommandons de considérer attentivement ce dernier point, et nous pensons que les bases de données sont en réalité moins affectées par les problèmes susmentionnés que ce que l’on a tendance à penser :

  1. La plupart des bases de données peuvent désormais être facilement installées sur l’ordinateur d’un développeur. Les technologies basées sur des conteneurs telles que Docker peuvent faciliter cette opération, et les technologies telles que les espaces de travail Github et les conteneurs de développement configurent l’ensemble de votre environnement de développement pour vous (y compris la base de données). Lorsque vous utilisez SQL Server, il est également possible de tester sur LocalDB sur Windows ou de configurer facilement une image Docker sur Linux.
  2. La réalisation de tests sur une base de données locale, avec un ensemble de données de test raisonnable, est généralement extrêmement rapide : la communication se fait de façon locale, et les données de test sont généralement mises en mémoire tampon côté base de données. EF Core contient plus de 30 000 tests sur SQL Server seul. Ces opérations sont effectuées de manière fiable en quelques minutes, s’exécutent en CI sur chaque validation et sont très fréquemment exécutées localement par les développeurs. Certains développeurs s’orientent vers une base de données en mémoire (un « faux ») en pensant que cela est nécessaire pour augmenter la vitesse, mais ce n’est presque jamais le cas.
  3. L’isolement constitue un véritable obstacle lors de l’exécution de tests sur une base de données réelle car les tests peuvent modifier les données et interférer les uns avec les autres. Toutefois, il existe différentes techniques pour garantir l’isolement dans les scénarios de test de base de données. Nous les abordons dans la section Test sur votre système de base de données de production.

Le but n’est pas de dénigrer les doubles de test ou de s’opposer à leur utilisation. Les doubles de test sont nécessaires pour certains scénarios qui ne peuvent pas être testés autrement, comme la simulation d’une défaillance de la base de données. Cependant, d’après notre expérience, les utilisateurs hésitent souvent à tester leur base de données pour les raisons susmentionnées, pensant que la procédure sera lente, difficile ou peu fiable, alors que ce n’est pas nécessairement le cas. La section Test sur votre système de base de données de production vise à résoudre ce problème, en fournissant des recommandations et des exemples pour l’écriture de tests rapides et isolés sur votre base de données.

Différents types de doubles de test

Le double de test est un vaste concept qui englobe des approches très différentes. Cette section décrit certaines techniques courantes impliquant des doubles de test pour tester des applications EF Core :

  1. Utiliser SQLite (mode en mémoire) comme une fausse base de données pour remplacer votre système de base de données de production.
  2. Utiliser le fournisseur de base de données en mémoire EF Core en tant que fausse base de données pour remplacer votre système de base de données de production.
  3. Simuler DbContext et DbSet.
  4. Introduisez une couche de référentiel entre EF Core et le code de votre application, et simulez cette couche.

Ci-après, nous allons explorer ce qu’implique chaque méthode et la comparer avec les autres. Nous vous recommandons de lire les différentes méthodes pour mieux comprendre chacune d’elles. Si vous avez décidé d’écrire des tests qui n’impliquent pas votre système de base de données de production, l’utilisation d’une couche de référentiel est la seule approche qui permet la simulation complète et fiable de la couche de données. Toutefois, cette approche présente un coût important en termes de mise en œuvre et de maintenance.

SQLite en tant que fausse base de données

Une approche de test possible consiste à échanger votre base de données de production (par exemple SQL Server) avec SQLite, en l’utilisant comme une « fausse » base de données de test. Outre la facilité d’installation, SQLite dispose d’une fonctionnalité de base de données en mémoire qui s’avère particulièrement utile pour les tests : chaque test est naturellement isolé dans sa propre base de données en mémoire et aucun fichier réel ne doit être géré.

Toutefois, avant de procéder ainsi, il est important de comprendre que dans EF Core, différents fournisseurs de base de données se comportent différemment. EF Core ne vise pas à faire abstraction de tous les aspects du système de base de données sous-jacent. Fondamentalement, cela signifie que les tests effectués sur SQLite ne garantissent pas les mêmes résultats que ceux effectués sur SQL Server ou toute autre base de données. Voici quelques exemples de différences comportementales possibles :

  • Une même requête LINQ peut retourner des résultats différents en fonction des fournisseurs. Par exemple, SQL Server effectue une comparaison de chaînes ne tenant pas compte de la casse par défaut, tandis que SQLite respecte la casse. Cela peut entraîner une réussite de vos tests avec SQLite alors qu’ils échoueraient avec SQL Server (ou vice versa).
  • Certaines requêtes qui fonctionnent sur SQL Server ne sont simplement pas prises en charge sur SQLite, car la prise en charge de SQL dans ces deux bases de données diffère.
  • Si votre requête utilise une méthode propre au fournisseur, comme celle du serveur SQL EF.Functions.DateDiffDay, elle échouera sur SQLite et ne pourra pas être testée.
  • Le langage SQL brut peut fonctionner, mais il peut tout aussi bien échouer ou renvoyer des résultats différents en fonction des opérations effectuées. Les dialectes SQL diffèrent à bien des égards d’une base de données à l’autre.

Il est relativement facile de commencer par utiliser SQLite par rapport à l’exécution de tests sur votre système de base de données de production. C’est pourquoi un grand nombre d’utilisateurs utilisent cette approche. Malheureusement, les limitations susmentionnées tendent à devenir problématiques lors du test d’applications EF Core, même si elles ne semblent pas l’être au début. Par conséquent, nous recommandons d’écrire vos tests sur votre base de données réelle ou, si l’utilisation d’un double de test est une nécessité absolue, de prendre en considération le coût d’un modèle de référentiel, comme indiqué ci-dessous.

Pour plus d’informations sur l’utilisation de SQLite pour les tests, consultez cette section.

Fournisseur de base de données en mémoire en tant que fausse base de données

En guise d’alternative à SQLite, EF Core est également doté d’un fournisseur de base de données en mémoire. Bien que ce fournisseur ait été initialement conçu pour prendre en charge les tests internes d’EF Core lui-même, certains développeurs l’utilisent comme une fausse base de données pour tester des applications EF Core. Cette pratique est fortement déconseillée : en tant que fausse base de données, le fournisseur de base de données en mémoire présente les mêmes problèmes que SQLite (voir ci-dessus), en plus des limitations suivantes :

  • Le fournisseur de base de données en mémoire prend généralement en charge moins de types de requêtes que le fournisseur SQLite, car il ne s’agit pas d’une base de données relationnelle. Davantage de requêtes échoueront ou se comporteront différemment par rapport à votre base de données de production.
  • Les transactions ne sont pas prises en charge.
  • Le langage SQL brut n’est pas pris en charge. À titre de comparaison, SQLite permet d’utiliser du code SQL brut, à condition que ce dernier fonctionne de la même manière sur SQLite que sur votre base de données de production.
  • La base de données en mémoire n’a pas été optimisée en matière de performances et fonctionnera globalement plus lentement que SQLite en mode mémoire (ou même que votre système de base de données de production).

En résumé, la base de données en mémoire présente tous les inconvénients de SQLite, auxquels s’ajoutent quelques autres, et n’offre aucun avantage en retour. Si vous recherchez une fausse base de données en mémoire simple, utilisez SQLite plutôt que la base de données en mémoire, mais envisagez également d’utiliser le modèle de référentiel décrit ci-dessous.

Pour plus d’informations sur l’utilisation de la base de données en mémoire pour les tests, consultez cette section.

Simulation DbContext et DbSet

Cette approche utilise généralement une infrastructure fictive pour créer un double de test de DbContext et de DbSet, sur lesquels sont exécutés les tests. La simulation DbContext peut être une bonne approche pour tester différentes fonctionnalités sans requête, telles que les appels à Add ou SaveChanges(), ce qui vous permet de vérifier que votre code les a appelées dans des scénarios d’écriture.

Cependant, il n’est pas possible de simuler correctement la fonctionnalité de DbSet requête, car les requêtes sont exprimées par des opérateurs LINQ, qui correspondent à des appels de méthodes d’extension statiques via IQueryable. Par conséquent, lorsque certaines personnes parlent de « simulation de DbSet », ce qu’elles veulent dire en réalité, c’est qu’elles créent un système DbSet reposant sur une collection en mémoire, puis qu’elles évaluent les opérateurs de requête sur cette collection en mémoire, exactement comme le ferait un simple IEnumerable. Il s’agit plus d’une sorte de faux que d’une simulation, où la collection en mémoire remplace la base de données réelle.

Étant donné que seul le DbSet est faux et que la requête est évaluée en mémoire, cette approche est extrêmement similaire à l’utilisation du fournisseur en mémoire EF Core : les deux techniques exécutent des opérateurs de requête en .NET sur une collection en mémoire. Par conséquent, cette technique présente également les mêmes inconvénients : les requêtes se comportent différemment (par exemple, selon prise en compte de la casse) ou échouent simplement (par exemple, en raison de méthodes propres au fournisseur), le langage SQL brut ne fonctionnera pas et les transactions seront, au mieux, ignorées. Cette technique doit généralement être évitée pour tester tout code de requête.

Modèle de référentiel

Les approches citées ci-dessus tentaient soit de remplacer le fournisseur de base de données de production d’EF Core par un faux fournisseur de test, soit de créer une base de données reposant sur une collection en mémoire. Ces techniques sont similaires dans la mesure où elles évaluent les requêtes LINQ du programme, soit dans SQLite, soit dans la mémoire, et il s’agit en fin de compte de la source des difficultés évoquées ci-dessus : une requête conçue pour être exécutée dans une base de données de production spécifique ne peut pas être exécutée ailleurs de manière fiable et exempte de tout problème.

Pour disposer d’un double de test correct et fiable, envisagez d’introduire une couche de référentiel qui fait le lien entre le code de votre application et EF Core. L’implémentation de production du référentiel contient les requêtes LINQ réelles et les exécute via EF Core. Dans le cadre des tests, l’abstraction du référentiel est directement simulée sans qu’il soit nécessaire d’effectuer des requêtes LINQ, ce qui permet de supprimer EF Core de la pile de tests et de se concentrer uniquement sur le code de l’application.

Le diagramme suivant compare la méthode de la fausse base de données (SQLite/en mémoire) à celle du référentiel :

Comparison of fake provider with repository pattern

Étant donné que les requêtes LINQ ne font plus partie des tests, vous pouvez directement fournir des résultats de requête à votre application. Autrement dit, les approches précédentes permettent de simuler approximativement les entrées de requête (par exemple, en remplaçant les tables SQL Server par des tables en mémoire), mais exécutent toujours les opérateurs de requête réels en mémoire. Le modèle de référentiel, en revanche, vous permet de simuler directement les sorties de requête, ce qui vous permet d’effectuer des tests unitaires bien plus puissants et plus ciblés. Notez que pour que cela fonctionne, votre référentiel ne peut pas exposer de méthodes de retour IQueryable, car ces méthodes ne peuvent pas être simulées. La méthode IEnumerable doit être retournée à la place.

Toutefois, étant donné que le modèle de référentiel exige d’encapsuler chaque requête LINQ (testable) dans une méthode de retour IEnumerable, il impose une couche architecturale supplémentaire à votre application et peut entraîner des coûts importants de mise en œuvre et de maintenance. Ce coût ne doit pas être négligé lors du choix de la méthode de test d’une application, d’autant plus que des tests sur la base de données réelle seront probablement encore nécessaires pour les requêtes exposées par le référentiel.

Il est important de noter que les référentiels présentent des avantages au-delà des tests. Ils garantissent que l’intégralité du code d’accès aux données est réuni en un seul endroit plutôt que d’être dispersé au sein de l’application. Et si votre application doit prendre en charge plus d’une base de données, l’abstraction du référentiel peut s’avérer très utile pour ajuster les requêtes d’un fournisseur à l’autre.

Pour obtenir un exemple de test avec un référentiel, consultez cette section.

Comparaison globale

Le tableau suivant fournit un aperçu comparatif des différentes techniques de test et indique quelles fonctionnalités peuvent être testées sous quelle approche :

Fonctionnalité En mémoire SQLite en mémoire Simulation DbContext Modèle de référentiel Tests sur la base de données
Type de double de test Faux Faux Faux Simulation Réel, pas de double
SQL brut ? Non Dépend Non Oui Oui
Transactions ? Non (ignoré) Oui Oui Oui Oui
Traductions spécifiques au fournisseur ? Non Pas de Non Oui Oui
Comportement exact des requêtes ? Dépend Dépend Dépend Oui Oui
Possibilité d’utiliser LINQ n’importe où dans l’application ? Oui Oui Oui Non* Oui

* Toutes les requêtes LINQ de base de données testables doivent être encapsulées dans les méthodes de référentiel de retour IEnumerable, afin d’être fictives/simulées.

Résumé

  • Nous recommandons aux développeurs de disposer d’une solide couverture de test de leur application fonctionnant avec leur système de base de données de production réel. Cela permet de s’assurer que l’application fonctionne réellement en production et, avec une conception adéquate, les tests peuvent être exécutés de manière fiable et rapide. Dans la mesure où ces tests sont indispensables, il est judicieux de commencer par cette méthode et, si nécessaire, d’ajouter ultérieurement des tests à l’aide de doubles de tests en fonction des besoins.
  • Si vous avez décidé d’utiliser un double de test, nous vous recommandons d’implémenter le modèle de référentiel, qui vous permet de simuler votre couche d’accès aux données par-dessus EF Core, plutôt que d’utiliser un faux fournisseur EF Core (Sqlite/en mémoire) ou de simuler DbSet.
  • Si le modèle de référentiel ne constitue pas une option viable pour une raison quelconque, envisagez d’utiliser des bases de données SQLite en mémoire.
  • Évitez de recourir à un fournisseur de base de données en mémoire à des fins de test : cette solution est déconseillée et n’est prise en charge que pour les applications existantes.
  • Évitez de simuler DbSet à des fins d’interrogation.