Start Debugging

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:

  1. Las claves generadas nunca se asignan. Con un proveedor real, Add(blog) para una columna [Key] int Id { get; set; } da a blog.Id un valor negativo temporal, y luego una clave real después de SaveChanges. Un contexto simulado se salta ambos pasos. Las pruebas que leen blog.Id después de Add ven 0, lo que pasa silenciosamente las comprobaciones de igualdad contra otras entidades sin guardar.
  2. 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 de ReferenceEquals(ctx.Blogs.Find(1), ctx.Blogs.First(b => b.Id == 1)) funciona en producción y falla en la prueba, o viceversa.
  3. SaveChanges se convierte en un verificador no-op. El SaveChanges() del mock devuelve 0 y nunca valida las navegaciones requeridas, nunca ejecuta los convertidores de valor, nunca dispara los interceptores, nunca lanza DbUpdateConcurrencyException cuando 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:

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:

  1. 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.
  2. 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.
  3. 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.
  4. Si te encuentras queriendo simular DbContext directamente para verificar “¿llamé a SaveChanges?”, 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 con ChangeTracker.

La combinación que falla es “simular DbContext para consultas”. Cualquier otra combinación tiene una respuesta defendible.

Posts relacionados y fuentes primarias

Fuentes primarias:

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver