Cómo simular DbContext sin romper el rastreo de cambios
Simular DbContext directamente rompe silenciosamente el ChangeTracker, por eso Microsoft lo desaconseja. Esta guía muestra los dos patrones que sí funcionan en EF Core 11: SQLite en memoria con una conexión que se mantiene abierta para que se ejecute el ChangeTracker real, y el patrón repositorio que saca por completo a EF Core de la prueba.
Si simulas DbContext para hacer pruebas unitarias de tu capa de datos, la prueba da luz verde a código que se rompe en el momento en que toca una base de datos real. La razón es el ChangeTracker: un Mock<MyDbContext> no ejecuta la detección de cambios, no asigna claves primarias en Add, no aplica la resolución de identidad, y miente silenciosamente sobre lo que SaveChanges realmente persistiría. Los dos patrones que funcionan son: mantener un DbContext real y cambiar el proveedor a SQLite en memoria, o envolver EF Core detrás de una interfaz de repositorio y simular la interfaz en su lugar. Esta guía recorre ambos, con .NET 11 y EF Core 11 (Microsoft.EntityFrameworkCore 11.0.0, C# 14, xUnit 2.9), además de los helpers de EF Core 11 que hacen el cambio más limpio.
Por qué simular DbContext rompe ChangeTracker
DbContext es un coordinador, no un almacén de datos. Add, Update, Remove, Attach, y la detección implícita que se ejecuta antes de SaveChanges fluyen todas a través de ChangeTracker, que se asienta sobre un IStateManager interno. Cuando haces var ctx = new Mock<MyDbContext>() y le dices que devuelva un DbSet falso, te saltas todo eso. Tres cosas concretas se rompen:
- Las claves generadas nunca se asignan. Con un proveedor real,
Add(blog)para una columna[Key] int Id { get; set; }da ablog.Idun valor negativo temporal, y luego una clave real después deSaveChanges. Un contexto simulado se salta ambos pasos. Las pruebas que leenblog.Iddespués deAddven0, lo que pasa silenciosamente las comprobaciones de igualdad contra otras entidades sin guardar. - La resolución de identidad desaparece. EF Core garantiza que cargar la misma clave primaria dos veces devuelve la misma instancia en memoria. Un mock respaldado por un
List<T>.AsQueryable()devuelve lo que devuelva LINQ-to-objects, que normalmente es una proyección anónima nueva, así que la igualdad por referencia se rompe. El código que depende deReferenceEquals(ctx.Blogs.Find(1), ctx.Blogs.First(b => b.Id == 1))funciona en producción y falla en la prueba, o viceversa. SaveChangesse convierte en un verificador no-op. ElSaveChanges()del mock devuelve 0 y nunca valida las navegaciones requeridas, nunca ejecuta los convertidores de valor, nunca dispara los interceptores, nunca lanzaDbUpdateConcurrencyExceptioncuando el vector de la fila dice que la fila cambió. Los tokens de concurrencia ni siquiera se leen.
La guía de pruebas de Microsoft lo dice sin rodeos: simular DbContext solo es apropiado para verificar efectos secundarios no relacionados con consultas (¿llamó mi código a Add? ¿llamó a SaveChanges?), e incluso ahí estás probando sobre todo que escribiste la línea que escribiste. Para cualquier cosa que dependa del resultado de una consulta, Microsoft recomienda uno de los dos enfoques siguientes.
El modelo de entidad mínimo usado en todo el artículo
Cada fragmento de abajo apunta al mismo modelo. Dos entidades, una relación padre-hijo, una clave generada y un token de concurrencia, porque esa es la forma más pequeña que hace aflorar los tres fallos de rastreo de cambios anteriores.
// .NET 11, EF Core 11, C# 14
public class Blog
{
public int Id { get; set; } // generated by the provider
public required string Name { get; set; }
public List<Post> Posts { get; set; } = new();
[Timestamp] public byte[] RowVersion { get; set; } = default!;
}
public class Post
{
public int Id { get; set; }
public required string Title { get; set; }
public int BlogId { get; set; }
public Blog Blog { get; set; } = default!;
}
public class BloggingContext(DbContextOptions<BloggingContext> options) : DbContext(options)
{
public DbSet<Blog> Blogs => Set<Blog>();
public DbSet<Post> Posts => Set<Post>();
}
El sistema bajo prueba es un pequeño servicio que añade un blog con dos posts, los guarda y devuelve el id del nuevo blog:
public class BlogService(BloggingContext context)
{
public async Task<int> CreateBlogAsync(string name, IEnumerable<string> postTitles)
{
var blog = new Blog { Name = name };
blog.Posts.AddRange(postTitles.Select(t => new Post { Title = t }));
context.Blogs.Add(blog);
await context.SaveChangesAsync();
return blog.Id;
}
}
Si Add y SaveChanges no se coordinan a través de ChangeTracker, blog.Id es 0 y la aserción al final de la prueba pasa por la razón equivocada.
Patrón A: SQLite en memoria conserva el ChangeTracker real
El objetivo aquí es mantener BloggingContext exactamente como está en producción y solo cambiar el proveedor. SQLite tiene un modo :memory: que es privado a una única conexión abierta y se destruye cuando la conexión se cierra, lo que te da aislamiento por prueba sin tener que gestionar archivos. La trampa es que EF Core abre y cierra conexiones de forma agresiva, así que la base de datos en memoria desaparece entre llamadas. La solución es abrir una SqliteConnection en el fixture de la prueba y pasar esa misma instancia a UseSqlite, para que la conexión siga viva durante la vida útil de la clase de prueba.
// .NET 11, EF Core 11.0.0, Microsoft.EntityFrameworkCore.Sqlite 11.0.0
public sealed class BlogServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<BloggingContext> _options;
public BlogServiceTests()
{
_connection = new SqliteConnection("Filename=:memory:");
_connection.Open();
_options = new DbContextOptionsBuilder<BloggingContext>()
.UseSqlite(_connection)
.Options;
using var ctx = new BloggingContext(_options);
ctx.Database.EnsureCreated();
}
public void Dispose() => _connection.Dispose();
private BloggingContext CreateContext() => new(_options);
[Fact]
public async Task CreateBlogAsync_assigns_a_real_key_and_persists_posts()
{
await using var write = CreateContext();
var sut = new BlogService(write);
var id = await sut.CreateBlogAsync("Walter", new[] { "Hello", "World" });
Assert.NotEqual(0, id);
await using var read = CreateContext();
var blog = await read.Blogs.Include(b => b.Posts).SingleAsync(b => b.Id == id);
Assert.Equal("Walter", blog.Name);
Assert.Equal(2, blog.Posts.Count);
}
}
Tres cosas a notar. Primero, la conexión se abre en el constructor y se libera en Dispose, así que la base de datos en memoria sobrevive para todos los métodos de prueba en la clase pero no se filtra entre clases. Segundo, la prueba usa dos instancias de BloggingContext, una para escribir y otra para leer, lo que obliga a EF Core a materializar la entidad desde la base de datos en lugar de devolver la instancia cacheada del primer contexto. Eso es lo que atrapa los bugs del tipo “olvidé llamar a SaveChanges”. Tercero, como el ChangeTracker real está en juego, blog.Id realmente cambia de 0 a un entero real, y la aserción NotEqual(0, id) tiene sentido.
La diferencia de comportamiento con tu base de datos de producción que más importa: SQLite es sensible a mayúsculas y minúsculas en LIKE e igualdad por defecto, mientras que SQL Server es insensible bajo las colaciones típicas *_CI_AS. Si tu consulta tiene Where(b => b.Name == "walter"), devuelve filas en SQL Server y ninguna en SQLite. La guía general es mantener estas pruebas para comportamientos que no dependan de la colación, y escribir un conjunto más pequeño de pruebas de integración contra el proveedor real con Testcontainers para el resto.
Un segundo detalle a vigilar: SQLite no aplica algunas comprobaciones de integridad referencial por defecto. Si necesitas que el comportamiento en cascada coincida exactamente con SQL Server, ejecuta PRAGMA foreign_keys = ON; después de abrir la conexión. EF Core 7+ hace esto por ti cuando usas el proveedor SQLite, así que normalmente no tienes que pensarlo, pero vale la pena saberlo si escribes SQL crudo en pruebas.
Patrón B: el patrón repositorio saca a EF Core de la prueba
Si tus consultas son lo bastante complejas como para que un cambio a SQLite te mienta (funciones específicas del proveedor, columnas JSON, búsqueda de texto completo, SQL crudo), la forma más limpia de hacer pruebas unitarias es poner EF Core detrás de una interfaz que devuelva datos materializados. Mueves el LINQ a un wrapper delgado, simulas el wrapper, y las pruebas unitarias dejan de saber sobre EF Core.
public interface IBlogRepository
{
Task<int> AddBlogAsync(Blog blog, CancellationToken ct = default);
Task<Blog?> GetBlogByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<Blog>> GetAllBlogsAsync(CancellationToken ct = default);
}
public sealed class BlogRepository(BloggingContext context) : IBlogRepository
{
public async Task<int> AddBlogAsync(Blog blog, CancellationToken ct = default)
{
context.Blogs.Add(blog);
await context.SaveChangesAsync(ct);
return blog.Id;
}
public Task<Blog?> GetBlogByIdAsync(int id, CancellationToken ct = default)
=> context.Blogs.Include(b => b.Posts).FirstOrDefaultAsync(b => b.Id == id, ct);
public async Task<IReadOnlyList<Blog>> GetAllBlogsAsync(CancellationToken ct = default)
=> await context.Blogs.AsNoTracking().ToListAsync(ct);
}
El detalle crítico es el tipo de retorno: IReadOnlyList<Blog> y Task<Blog?>, nunca IQueryable<Blog>. En el instante en que expones IQueryable, los llamadores pueden hacer .Where(...) sobre él, y ahora tu prueba tiene que evaluar ese Where contra algo, lo que te devuelve al problema original. Materializa en la frontera.
El servicio ahora depende de la interfaz:
public class BlogService(IBlogRepository blogs)
{
public async Task<int> CreateBlogAsync(string name, IEnumerable<string> postTitles)
{
var blog = new Blog { Name = name };
blog.Posts.AddRange(postTitles.Select(t => new Post { Title = t }));
return await blogs.AddBlogAsync(blog);
}
}
Y la prueba simula la interfaz, no DbContext:
[Fact]
public async Task CreateBlogAsync_returns_id_from_repository()
{
var repo = new Mock<IBlogRepository>();
repo.Setup(r => r.AddBlogAsync(It.IsAny<Blog>(), default))
.Callback<Blog, CancellationToken>((b, _) => b.Id = 42)
.ReturnsAsync(42);
var sut = new BlogService(repo.Object);
var id = await sut.CreateBlogAsync("Walter", new[] { "Hello", "World" });
Assert.Equal(42, id);
repo.Verify(r => r.AddBlogAsync(It.Is<Blog>(b => b.Posts.Count == 2), default), Times.Once);
}
La prueba ahora afirma el contrato de BlogService (construyó un blog con dos posts y le pidió al repositorio que lo guardara) sin afirmar nada sobre EF Core. El repositorio en sí lo ejercita después un conjunto separado y más pequeño de pruebas que toca una base de datos real. Esta es la estratificación que recomienda Microsoft cuando el equilibrio entre fidelidad de la prueba y velocidad de la prueba se inclina hacia la velocidad.
El precio es real. Una nueva capa arquitectónica significa más código, más interfaces, más archivos y la tentación de escribir un IRepository<T> genérico que termina siendo una reimplementación con fugas de DbSet. Resiste eso. Haz que las interfaces se basen en tareas, no en entidades: GetActiveSubscriptions(userId), no Get(int id). Cada método debería corresponder a una consulta significativa en tu dominio.
Por qué el proveedor en memoria de EF Core no está en esta lista
La tercera opción que algunos equipos consideran es Microsoft.EntityFrameworkCore.InMemory. La guía oficial se ha ido endureciendo en su contra de forma constante, y la página actual de Learn califica su uso para pruebas como “fuertemente desaconsejado” y “soportado solo para aplicaciones heredadas”. Tres razones:
- Las transacciones se ignoran silenciosamente.
BeginTransactiondevuelve un no-op, así que una prueba para “esto falla a mitad de camino y hace rollback” pasa independientemente de si el rollback funciona. SQLite en memoria soporta transacciones reales. - No es relacional. Las restricciones únicas, la integridad referencial y la mayoría de las traducciones específicas del proveedor están ausentes. Una consulta que falla en SQL Server con un error de traducción se ejecuta felizmente contra el proveedor en memoria.
- El SQL crudo no está soportado. SQLite soporta
FromSqlRawcontra cualquier SQL que entienda.
Si tienes un conjunto de pruebas existente que lo usa y aún no te ha mordido, te estás apoyando en una base de datos falsa que finge ser más permisiva que la real. La ruta de migración suele ser un cambio de una línea de UseInMemoryDatabase("name") al patrón SQLite-en-memoria de arriba, más un constructor que abre la conexión y siembra los datos.
Helpers de EF Core 11 que cambian las cuentas
Dos adiciones recientes de EF Core 11 vale la pena conocerlas porque eliminan la parte más molesta de cambiar proveedores en un fixture de prueba, que es deshacer lo que la composición raíz de producción ya registró.
RemoveDbContext<TContext>() quita el contexto y sus DbContextOptions enlazadas de un IServiceCollection en una sola llamada, reemplazando el manual RemoveAll<DbContextOptions<MyContext>>() más RemoveAll(typeof(MyContext)) que solía ser frágil. Combinado con la sobrecarga sin parámetros de AddPooledDbContextFactory<TContext>(), cambiar un registro de SQL Server por uno de SQLite dentro de WebApplicationFactory<TStartup> se convierte en:
public class TestWebFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection = new("Filename=:memory:");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
_connection.Open();
builder.ConfigureTestServices(services =>
{
services.RemoveDbContext<BloggingContext>();
services.AddDbContext<BloggingContext>(o => o.UseSqlite(_connection));
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing) _connection.Dispose();
}
}
Dos líneas de registro reemplazan a las ocho antiguas, y la limpieza sobrevive a cualquier cambio futuro en cómo EF Core conecta su pipeline de opciones. El trasfondo completo de este par está en el nuevo RemoveDbContext para cambios limpios de proveedor en pruebas.
Si además escribes interceptores pre-SaveChanges que leen ChangeTracker.Entries(), GetEntriesForState de EF Core 11 evita la pasada duplicada de DetectChanges, lo que hace que esos interceptores sean más baratos de probar en un bucle estrecho.
Cómo elegir entre los dos patrones
Un flujo de decisión corto que aguanta en la práctica:
- Si tu código bajo prueba es lógica de negocio que llama a un método de repositorio, simula el repositorio. No levantes una base de datos en absoluto.
- Si tu código bajo prueba es la propia implementación del repositorio, o cualquier cosa que construya consultas LINQ contra
DbSet, usa SQLite en memoria. - Si tu consulta depende de comportamiento específico del proveedor (funciones JSON de SQL Server, índices full-text,
EF.Functions.DateDiffDay, SQL crudo con sintaxis del proveedor), escribe en cambio una prueba de integración contra el proveedor real con Testcontainers. SQLite compilará pero mentirá en tiempo de ejecución. - Si te encuentras queriendo simular
DbContextdirectamente para verificar “¿llamé aSaveChanges?”, refactoriza el sitio de la llamada para que dependa de una interfaz más pequeña (IUnitOfWork,IBlogRepository) y verifica contra eso. El mock será más pequeño, la prueba se leerá mejor y no estarás peleando conChangeTracker.
La combinación que falla es “simular DbContext para consultas”. Cualquier otra combinación tiene una respuesta defendible.
Posts relacionados y fuentes primarias
- Cómo hacer pruebas unitarias de código que usa HttpClient cubre el patrón paralelo de sustituir la costura (
HttpMessageHandler) en lugar de simular la superficie (HttpClient). - EF Core 11 Preview 3 añade RemoveDbContext para cambios limpios de proveedor en pruebas explica el helper usado en el fragmento de
WebApplicationFactoryde arriba. - EF Core 11 añade GetEntriesForState para saltarse DetectChanges es trasfondo útil cuando estás probando interceptores de auditoría.
- Cómo usar records con EF Core 11 correctamente merece un vistazo si tus entidades blog/post son records, porque la igualdad de records interactúa con la resolución de identidad de
ChangeTrackerde formas sorprendentes. - Cómo usar IAsyncEnumerable con EF Core 11 es el tipo de retorno correcto cuando un método de repositorio necesita transmitir en lugar de materializar una lista.
Fuentes primarias:
- Choosing a testing strategy en Microsoft Learn, que es la guía autoritativa contra simular
DbSetpara consultas y contra el proveedor en memoria. - Testing without your production database system para los ejemplos de SQLite-en-memoria y repositorio que este post adapta.
- SQLite in-memory database documentation para la semántica de la vida útil de la conexión en la que se basa el patrón SQLite-en-memoria.
- Testcontainers for .NET para la salida de emergencia de pruebas de integración cuando SQLite miente.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.