Un service qui passe par EF Core cache beaucoup de choses : traduction LINQ en SQL, contraintes, transactions, jetons de concurrence, intercepteurs. Le tester contre un faux moteur donne une confiance trompeuse : le test passe, la production échoue. Cet article compare les approches possibles (provider InMemory, SQLite en mémoire, vraie base avec Testcontainers), montre comment isoler les tests par transaction ou avec Respawn, et comment vérifier le SQL d’une requête sans même ouvrir de connexion.
Cet article fait partie de la série Entity Framework : 12 sur 12.
- Part 1 - Comprendre la "magie" derrière Entity Framework (EF Core)
- Part 2 - Le Database Context (EF Core)
- Part 3 - Le Change Tracker (EF Core)
- Part 4 - Bonnes pratiques pour les requêtes (EF Core)
- Part 5 - Manipuler le Change Tracker (EF Core)
- Part 6 - Optimiser les performances (EF Core)
- Part 7 - Mesurer et diagnostiquer les performances (EF Core)
- Part 8 - AsSplitQuery — maîtriser le chargement de plusieurs relations (EF Core)
- Part 9 - Migrations en équipe et en CI (EF Core)
- Part 10 - Concurrence optimiste (EF Core)
- Part 11 - Les intercepteurs (EF Core)
- Part 12 - Cet article
1. Ce qu’on veut vraiment tester
Dans du code EF Core, les bugs se cachent rarement dans le C# lui-même. Ils viennent plutôt :
- d’une requête LINQ non traduisible, ou traduite autrement qu’on le pensait ;
- d’une contrainte de base (unicité, clé étrangère, longueur) violée ;
- d’une différence de sémantique : comparaison de chaînes sensible ou non à la casse,
null, arrondis surdecimal; - de la logique branchée sur
SaveChanges: intercepteurs (classes appelées par EF Core avant chaque enregistrement, par exemple pour transformer une suppression en suppression logique), jetons de concurrence ; - des migrations elles-mêmes.
Aucun de ces points n’est vérifié si la base est simulée. D’où la règle : la logique métier se teste unitairement, sans EF Core ; le code d’accès aux données se teste contre le moteur de production.
1.1. Le modèle de l’exemple
Les tests de cet article portent sur le catalogue d’une bibliothèque : des livres classés par genre, avec suppression logique, et des adhérents identifiés par leur e-mail.
// dotnet add package Microsoft.EntityFrameworkCore.SqlServer
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public required string Isbn { get; set; }
public string Summary { get; set; } = ""; // résumé, texte long
public int GenreId { get; set; }
public bool IsAvailable { get; set; } = true; // false = emprunté
public bool IsDeleted { get; set; } // suppression logique
}
public class Genre
{
public int Id { get; set; }
public required string Name { get; set; }
}
public class Member
{
public int Id { get; set; }
public required string Email { get; set; }
}
public class LibraryDbContext(DbContextOptions<LibraryDbContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<Member> Members => Set<Member>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>().HasQueryFilter(b => !b.IsDeleted); // livres supprimés masqués
modelBuilder.Entity<Book>().HasIndex(b => b.Isbn).IsUnique();
modelBuilder.Entity<Book>().HasOne<Genre>().WithMany().HasForeignKey(b => b.GenreId);
modelBuilder.Entity<Member>().HasIndex(m => m.Email).IsUnique();
// Données de référence, insérées par les migrations
modelBuilder.Entity<Genre>().HasData(
new Genre { Id = 1, Name = "Roman" },
new Genre { Id = 2, Name = "Science-fiction" },
new Genre { Id = 3, Name = "Histoire" });
}
}
// Transforme un Remove(book) en UPDATE IsDeleted = 1
// (version synchrone SavingChanges omise pour la concision)
public sealed class SoftDeleteInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
foreach (var entry in eventData.Context!.ChangeTracker.Entries<Book>()
.Where(e => e.State == EntityState.Deleted))
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
}
return ValueTask.FromResult(result);
}
}
Les tests utilisent xUnit v3 (TestContext.Current.CancellationToken, IAsyncLifetime avec ValueTask).
2. Pourquoi éviter le provider InMemory
Le package Microsoft.EntityFrameworkCore.InMemory est tentant : une ligne de configuration, aucun prérequis, des tests très rapides. Mais ce n’est pas une base relationnelle, et la documentation EF Core elle-même déconseille de l’utiliser pour tester.
// ❌ Ce test est vert avec InMemory... et la requête échoue en production
[Fact]
public async Task SearchByEmail_IgnoresCase()
{
var ct = TestContext.Current.CancellationToken;
var options = new DbContextOptionsBuilder<LibraryDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new LibraryDbContext(options);
db.Members.Add(new Member { Email = "alice@biblio.fr" });
db.Members.Add(new Member { Email = "alice@biblio.fr" }); // doublon : l'index unique n'est pas vérifié
await db.SaveChangesAsync(ct);
// Méthode C# arbitraire : exécutée en mémoire par InMemory,
// mais impossible à traduire en SQL (InvalidOperationException sur SQL Server)
var found = await db.Members
.Where(m => SameEmail(m.Email, "ALICE@biblio.fr"))
.ToListAsync(ct);
Assert.Equal(2, found.Count);
}
private static bool SameEmail(string a, string b)
=> string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
Ce que le provider InMemory ne fait pas :
| Comportement | InMemory | Base relationnelle |
|---|---|---|
| Index uniques, clés étrangères | ❌ Ignorés | ✅ Vérifiés |
| Transactions | ❌ Ignorées (avertissement levé en exception par défaut) | ✅ Réelles |
| Comparaison de chaînes | Sémantique C# | Collation de la base |
FromSql, SQL brut |
❌ Non supporté | ✅ |
ExecuteUpdate / ExecuteDelete |
❌ Non supporté | ✅ |
rowversion, valeurs par défaut SQL, colonnes calculées |
❌ | ✅ |
| Requête non traduisible en SQL | Exécutée en mémoire sans erreur | ❌ Exception |
Le dernier point est le plus sournois : InMemory exécute n’importe quelle expression LINQ, y compris celles qu’EF Core refusera de traduire face à SQL Server.
3. SQLite en mémoire : un compromis
SQLite est une vraie base relationnelle, embarquée dans le processus. En mode mémoire, la base vit tant que la connexion reste ouverte : il faut donc créer la connexion soi-même et la garder pendant tout le test.
// dotnet add package Microsoft.EntityFrameworkCore.Sqlite
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
public sealed class SqliteMemberTests : IAsyncLifetime
{
private readonly SqliteConnection _connection = new("DataSource=:memory:");
private LibraryDbContext _db = default!;
public async ValueTask InitializeAsync()
{
await _connection.OpenAsync(); // la base existe tant que la connexion est ouverte
var options = new DbContextOptionsBuilder<LibraryDbContext>()
.UseSqlite(_connection)
.Options;
_db = new LibraryDbContext(options);
await _db.Database.EnsureCreatedAsync(); // schéma créé depuis le modèle, sans migrations
}
public async ValueTask DisposeAsync()
{
await _db.DisposeAsync();
await _connection.DisposeAsync();
}
[Fact]
public async Task Email_MustBeUnique()
{
var ct = TestContext.Current.CancellationToken;
_db.Members.Add(new Member { Email = "alice@biblio.fr" });
_db.Members.Add(new Member { Email = "alice@biblio.fr" });
// ✅ L'index unique est bien vérifié
await Assert.ThrowsAsync<DbUpdateException>(() => _db.SaveChangesAsync(ct));
}
}
C’est nettement mieux qu’InMemory (contraintes, transactions, vraie traduction SQL), mais le dialecte reste différent de celui de la production :
- pas de
rowversion: un[Timestamp]n’est pas généré automatiquement, la concurrence optimiste (détection des modifications concurrentes via une colonne de version) ne se teste donc pas ; - pas de schémas, types plus pauvres (
decimalstocké en texte,DateTimeOffsetlimité) ; - comparaisons de chaînes sensibles à la casse par défaut ;
- SQL brut écrit pour SQL Server (
WITH (UPDLOCK), fonctionsEF.Functionsspécifiques) non exécutable ; - schéma créé par
EnsureCreated: les migrations SQL Server ne sont pas testées.
SQLite convient donc à une application qui tourne réellement sur SQLite, ou à des tests rapides sur des requêtes simples, en complément d’une suite sur le vrai moteur.
4. Une vraie base avec Testcontainers
Testcontainers démarre depuis le code de test un conteneur Docker jetable, ici une vraie instance SQL Server, et le supprime à la fin. Docker doit donc être disponible sur le poste et sur le runner de CI. Pour tester une API complète de la même façon, voir les tests d’intégration avec Testcontainers.
dotnet add package Testcontainers.MsSql
dotnet add package Respawn
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Testcontainers.MsSql;
public sealed class SqlServerFixture : IAsyncLifetime
{
// Image figée : même version majeure qu'en production
private readonly MsSqlContainer _container =
new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04").Build();
public string ConnectionString { get; private set; } = "";
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
// Base dédiée plutôt que master
ConnectionString = new SqlConnectionStringBuilder(_container.GetConnectionString())
{
InitialCatalog = "LibraryTests"
}.ConnectionString;
await using var db = CreateContext();
await db.Database.MigrateAsync(); // ✅ les migrations sont testées au passage
}
// Mêmes intercepteurs qu'en production : ils font partie de ce qu'on teste
public LibraryDbContext CreateContext() => new(
new DbContextOptionsBuilder<LibraryDbContext>()
.UseSqlServer(ConnectionString)
.AddInterceptors(new SoftDeleteInterceptor())
.Options);
public ValueTask DisposeAsync() => _container.DisposeAsync();
}
[CollectionDefinition(Name)]
public sealed class SqlServerCollection : ICollectionFixture<SqlServerFixture>
{
public const string Name = "SQL Server";
}
Une fixture de collection xUnit est créée une seule fois et partagée par toutes les classes marquées du même [Collection] : le conteneur démarre donc une fois par collection (quelques secondes), puis tous les tests de la collection le partagent. Reste à isoler les tests entre eux : deux stratégies.
5. Isoler les tests
5.1. Une transaction annulée par test
Chaque test ouvre une transaction qu’il ne valide jamais : à la libération du contexte, tout est annulé.
[Collection(SqlServerCollection.Name)]
public sealed class BookPersistenceTests(SqlServerFixture fixture) : IAsyncLifetime
{
private readonly LibraryDbContext _db = fixture.CreateContext();
public async ValueTask InitializeAsync()
=> await _db.Database.BeginTransactionAsync(TestContext.Current.CancellationToken);
// Pas de Commit : la transaction est annulée à la libération
public ValueTask DisposeAsync() => _db.DisposeAsync();
[Fact]
public async Task SoftDelete_HidesBookFromQueries()
{
var ct = TestContext.Current.CancellationToken;
// Arrange
var book = new Book { Title = "Dune", Isbn = "978-2266320481", GenreId = 2 };
_db.Books.Add(book);
await _db.SaveChangesAsync(ct);
// Act
_db.Books.Remove(book);
await _db.SaveChangesAsync(ct);
_db.ChangeTracker.Clear(); // ⚠️ sinon la lecture peut servir l'instance déjà suivie
// Assert : invisible par défaut, mais toujours présent en base
Assert.False(await _db.Books.AnyAsync(b => b.Id == book.Id, ct));
Assert.True(await _db.Books.IgnoreQueryFilters()
.AnyAsync(b => b.Id == book.Id && b.IsDeleted, ct));
}
}
Avantages : très rapide, aucune donnée résiduelle, aucun nettoyage à écrire. Limites :
- si le code testé appelle lui-même
BeginTransactionAsync, EF Core lève une exception (transaction déjà en cours) ; - si le code testé utilise un autre
DbContext, il faut lui faire partager la même connexion et la même transaction (UseSqlServer(connection)puisDatabase.UseTransaction(...)) ; ChangeTracker.Clear()est indispensable entre l’arrange et l’assert, sinon on vérifie le cache du contexte et non la base.
5.2. Respawn : vider la base entre deux tests
Quand le code testé gère ses propres transactions, ou pour des tests de bout en bout, on laisse les données être validées et on nettoie avant chaque test avec Respawn, qui calcule une fois l’ordre des DELETE d’après les clés étrangères :
using Respawn;
using Respawn.Graph;
// Dans SqlServerFixture, après MigrateAsync()
private Respawner _respawner = default!;
private async Task CreateRespawnerAsync()
{
_respawner = await Respawner.CreateAsync(ConnectionString, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
TablesToIgnore =
[
new Table("__EFMigrationsHistory"), // historique des migrations
new Table("Genres") // données de référence (HasData)
]
});
}
public Task ResetAsync() => _respawner.ResetAsync(ConnectionString);
Chaque classe de test appelle fixture.ResetAsync() dans son InitializeAsync. On conserve la table Genres, remplie par HasData lors des migrations, sinon les tests suivants ne trouveraient plus les genres référencés par leurs livres.
graph TD
S[Démarrage de la collection] --> C[Conteneur SQL Server<br/>MigrateAsync]
C --> T1{Stratégie<br/>d'isolation}
T1 -->|Transaction| TX[BeginTransaction<br/>test<br/>Dispose = rollback]
T1 -->|Respawn| RS[ResetAsync<br/>test<br/>données validées]
TX --> F[Fin de collection<br/>conteneur supprimé]
RS --> F
style C fill:#4A90E2
style TX fill:#7ED321
style RS fill:#F5A623
6. Tester les requêtes
6.1. Contre la base
Une requête réutilisable s’écrit volontiers comme une méthode d’extension sur IQueryable<T> :
public static class BookQueries
{
public static IQueryable<BookDto> AvailableInGenre(this IQueryable<Book> books, int genreId)
=> books
.Where(b => b.IsAvailable && b.GenreId == genreId)
.OrderBy(b => b.Title)
.Select(b => new BookDto(b.Id, b.Title, b.Isbn));
}
public sealed record BookDto(int Id, string Title, string Isbn);
Elle se teste avec les fixtures précédentes : on insère quelques lignes, on vide le Change Tracker, on exécute la requête et on vérifie le résultat. C’est le seul moyen de s’assurer qu’elle est traduisible et qu’elle renvoie les bonnes lignes avec la collation réelle.
6.2. Vérifier le SQL avec ToQueryString
ToQueryString() renvoie le SQL que générerait la requête, sans ouvrir de connexion. On peut donc vérifier la forme d’une requête dans un test unitaire ultra-rapide :
[Fact]
public void AvailableInGenre_FiltersAndProjectsOnServer()
{
// Aucune connexion n'est ouverte : la chaîne n'a pas besoin d'être valide
using var db = new LibraryDbContext(new DbContextOptionsBuilder<LibraryDbContext>()
.UseSqlServer("Server=unused;Database=unused")
.Options);
var sql = db.Books.AvailableInGenre(3).ToQueryString();
Assert.Contains("[b].[GenreId] = @genreId", sql); // filtre côté serveur, paramétré
Assert.Contains("ORDER BY [b].[Title]", sql);
Assert.DoesNotContain("[b].[Summary]", sql); // projection : pas de colonne volumineuse
}
Le SQL obtenu ressemble à ceci (EF Core 10 simplifie les noms de paramètres) :
DECLARE @genreId int = 3;
SELECT [b].[Id], [b].[Title], [b].[Isbn]
FROM [Books] AS [b]
WHERE [b].[IsDeleted] = CAST(0 AS bit) AND [b].[IsAvailable] = CAST(1 AS bit) AND [b].[GenreId] = @genreId
ORDER BY [b].[Title]
On y retrouve le filtre global de soft delete déclaré dans OnModelCreating : ToQueryString() montre la requête réellement envoyée, filtres compris.
C’est utile pour verrouiller des propriétés importantes : présence d’un TagWith, absence de colonnes volumineuses, AsSplitQuery effectif, filtre global appliqué. En revanche, comparer le SQL complet caractère par caractère rend les tests fragiles à chaque mise à jour d’EF Core : on teste des fragments significatifs, pas le texte entier.
7. Repository ou pas ?
Une question revient souvent : faut-il encapsuler EF Core derrière des repositories pour pouvoir le simuler ?
DbContextest déjà une unité de travail, etDbSet<T>déjà un repository. SimulerDbSet<T>avec un framework de mock est laborieux (IQueryable,IAsyncEnumerable) et ne teste rien d’utile : le mock exécute le LINQ en mémoire, exactement comme InMemory.- ✅ Un repository orienté domaine (
ILoanRepository.GetActiveLoanAsync,AddAsync), aux méthodes peu nombreuses et expressives, a du sens : il sert de frontière pour tester les services métier avec un mock, comme dans l’article sur NSubstitute. Son implémentation EF Core se teste alors contre la vraie base. - ❌ Un repository générique (
IRepository<T>avecGetAll(),Find(Expression<...>)) qui exposeIQueryablen’apporte que de l’indirection : les requêtes restent écrites dans les services et ne sont toujours pas testées.
En résumé : on simule au-dessus d’EF Core (le repository métier), jamais EF Core lui-même.
8. Tableau comparatif
| Approche | Fidélité | Vitesse | Contraintes / transactions | Migrations testées | Usage recommandé |
|---|---|---|---|---|---|
| Provider InMemory | ❌ Faible | ✅ Très rapide | ❌ Non | ❌ Non | À éviter |
Mock de DbSet<T> |
❌ Nulle | ✅ Très rapide | ❌ Non | ❌ Non | À éviter |
| SQLite en mémoire | ⚠️ Moyenne | ✅ Rapide | ✅ Oui (dialecte SQLite) | ❌ Non | Appli SQLite, requêtes simples |
ToQueryString() |
⚠️ Forme du SQL uniquement | ✅ Très rapide | — | — | Verrouiller la traduction d’une requête |
| Testcontainers + transaction | ✅ Identique à la prod | ⚠️ Démarrage ~quelques s | ✅ Oui | ✅ Oui | Repositories, requêtes, intercepteurs |
| Testcontainers + Respawn | ✅ Identique à la prod | ⚠️ Un peu plus lent | ✅ Oui | ✅ Oui | Code gérant ses transactions, tests d’API |
Les durées sont des ordres de grandeur : le démarrage d’un conteneur SQL Server prend typiquement quelques secondes, puis chaque test s’exécute en quelques millisecondes à quelques dizaines de millisecondes.
Résumé
| Pratique | Pourquoi |
|---|---|
| Pas de provider InMemory | Pas de contraintes, pas de transactions, LINQ exécuté en mémoire |
| SQLite en mémoire avec connexion ouverte | Compromis rapide, mais dialecte et types différents |
Testcontainers + MigrateAsync() |
Même moteur que la prod, migrations testées |
| Une fixture de collection | Un seul conteneur pour tous les tests |
| Transaction non validée par test | Isolation rapide, rollback automatique |
Respawn (TablesToIgnore) |
Quand le code gère ses transactions ; garder l’historique et les données de référence |
ChangeTracker.Clear() avant l’assert |
Vérifier la base, pas le cache du contexte |
ToQueryString() |
Contrôler le SQL sans connexion |
| Repository métier simulé, EF Core réel derrière | Tests unitaires des services + tests d’intégration des requêtes |