Start Debugging

Como simular o DbContext sem quebrar o rastreamento de alterações

Simular o DbContext diretamente quebra silenciosamente o ChangeTracker, e por isso a Microsoft desencoraja essa prática. Este guia mostra os dois padrões que realmente funcionam no EF Core 11: SQLite em memória com uma conexão mantida aberta para que o ChangeTracker real seja executado, e o padrão repositório, que tira o EF Core inteiramente do teste.

Se você simular o DbContext para testar unitariamente sua camada de dados, o teste dá luz verde para um código que quebra no momento em que toca um banco de dados real. O motivo é o ChangeTracker: um Mock<MyDbContext> não executa a detecção de alterações, não atribui chaves primárias em Add, não impõe a resolução de identidade e mente silenciosamente sobre o que SaveChanges realmente persistiria. Os dois padrões que funcionam são: manter um DbContext real e trocar o provedor por SQLite em memória, ou envolver o EF Core atrás de uma interface de repositório e simular a interface no lugar dele. Este guia percorre ambos, com .NET 11 e EF Core 11 (Microsoft.EntityFrameworkCore 11.0.0, C# 14, xUnit 2.9), além dos helpers do EF Core 11 que tornam a troca mais limpa.

Por que simular o DbContext quebra o ChangeTracker

DbContext é um coordenador, não um armazenamento de dados. Add, Update, Remove, Attach e a detecção implícita que roda antes de SaveChanges passam todas pelo ChangeTracker, que fica sobre um IStateManager interno. Quando você faz var ctx = new Mock<MyDbContext>() e diz a ele para retornar um DbSet falso, você ignora tudo isso. Três coisas concretas quebram:

  1. As chaves geradas nunca são atribuídas. Com um provedor real, Add(blog) para uma coluna [Key] int Id { get; set; } dá a blog.Id um valor temporário negativo, e depois uma chave real após SaveChanges. Um contexto simulado pula ambos. Testes que leem blog.Id depois de Add enxergam 0, o que passa silenciosamente nas verificações de igualdade contra outras entidades não salvas.
  2. A resolução de identidade desaparece. O EF Core garante que carregar a mesma chave primária duas vezes retorna a mesma instância em memória. Um mock apoiado por um List<T>.AsQueryable() retorna o que LINQ-to-objects retornar, normalmente uma projeção anônima nova, então a igualdade por referência quebra. Código que depende de ReferenceEquals(ctx.Blogs.Find(1), ctx.Blogs.First(b => b.Id == 1)) funciona em produção e falha no teste, ou vice-versa.
  3. SaveChanges vira um verificador no-op. O SaveChanges() do mock retorna 0 e nunca valida navegações obrigatórias, nunca executa conversores de valor, nunca dispara interceptores, nunca lança DbUpdateConcurrencyException quando o vetor da linha diz que a linha mudou. Os tokens de concorrência nem são lidos.

A orientação de testes da Microsoft é direta: simular o DbContext só é apropriado para verificar efeitos colaterais que não são consultas (meu código chamou Add? chamou SaveChanges?), e mesmo aí você está testando majoritariamente que escreveu a linha que escreveu. Para qualquer coisa que dependa do resultado de uma consulta, a Microsoft recomenda uma das duas abordagens abaixo.

O modelo de entidade mínimo usado em todo o artigo

Cada trecho abaixo mira o mesmo modelo. Duas entidades, uma relação pai-filho, uma chave gerada e um token de concorrência, porque essa é a menor forma que faz aflorar todas as três falhas de rastreamento de alterações acima.

// .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>();
}

O sistema sob teste é um pequeno serviço que adiciona um blog com dois posts, salva e retorna o id do novo 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;
    }
}

Se Add e SaveChanges não se coordenam pelo ChangeTracker, blog.Id é 0 e a asserção no fim do teste passa pelo motivo errado.

Padrão A: SQLite em memória mantém o ChangeTracker real

O objetivo aqui é manter o BloggingContext exatamente como está em produção e apenas trocar o provedor. O SQLite tem um modo :memory: que é privado a uma única conexão aberta e é destruído quando a conexão fecha, o que dá isolamento por teste sem precisar gerenciar arquivos. A pegadinha é que o EF Core abre e fecha conexões agressivamente, então o banco em memória some entre chamadas. A solução é abrir uma SqliteConnection no fixture do teste e passar essa instância exata para UseSqlite, de modo que a conexão fique viva durante todo o ciclo de vida da classe de teste.

// .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);
    }
}

Três coisas para notar. Primeiro, a conexão é aberta no construtor e descartada no Dispose, então o banco em memória sobrevive para todos os métodos de teste da classe mas não vaza entre classes. Segundo, o teste usa duas instâncias de BloggingContext, uma para escrita e outra para leitura, o que força o EF Core a materializar a entidade a partir do banco em vez de devolver a instância em cache do primeiro contexto. É isso que pega bugs do tipo “esqueci de chamar SaveChanges”. Terceiro, como o ChangeTracker real está em ação, blog.Id realmente muda de 0 para um inteiro real, e a asserção NotEqual(0, id) é significativa.

A diferença comportamental do seu banco de produção que mais importa: o SQLite é sensível a maiúsculas e minúsculas em LIKE e em igualdade por padrão, enquanto o SQL Server é insensível sob as colações típicas *_CI_AS. Se a sua consulta tem Where(b => b.Name == "walter"), ela retorna linhas no SQL Server e nenhuma linha no SQLite. A orientação geral é manter esses testes para comportamentos que não dependam de colação, e escrever um conjunto menor de testes de integração contra o provedor real com Testcontainers para o resto.

Uma segunda armadilha: o SQLite não impõe algumas verificações de integridade referencial por padrão. Se você precisa que o comportamento em cascata combine exatamente com o do SQL Server, execute PRAGMA foreign_keys = ON; depois de abrir a conexão. O EF Core 7+ faz isso por você quando usa o provedor SQLite, então normalmente não precisa pensar nisso, mas vale saber se você escreve SQL bruto em testes.

Padrão B: o padrão repositório tira o EF Core do teste

Se as suas consultas são complexas o bastante para que uma troca para SQLite minta para você (funções específicas do provedor, colunas JSON, busca full-text, SQL bruto), a forma mais limpa de fazer testes unitários é colocar o EF Core atrás de uma interface que retorne dados materializados. Você move o LINQ para um wrapper fino, simula o wrapper, e os testes unitários param de saber sobre o 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);
}

O detalhe crítico é o tipo de retorno: IReadOnlyList<Blog> e Task<Blog?>, nunca IQueryable<Blog>. No instante em que você expõe IQueryable, os chamadores podem fazer .Where(...) em cima dele, e agora seu teste tem que avaliar esse Where contra alguma coisa, o que te coloca de volta no problema original. Materialize na fronteira.

O serviço agora depende da interface:

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);
    }
}

E o teste simula a interface, não o 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);
}

O teste agora afirma o contrato de BlogService (ele construiu um blog com dois posts e pediu ao repositório para salvá-lo) sem afirmar nada sobre o EF Core. O próprio repositório é então exercitado por um conjunto separado e menor de testes que toca um banco de dados real. Esse é o estratificação que a Microsoft recomenda quando o equilíbrio entre fidelidade do teste e velocidade do teste pende para velocidade.

O preço é real. Uma nova camada arquitetural significa mais código, mais interfaces, mais arquivos, e a tentação de escrever um IRepository<T> genérico que acaba sendo uma reimplementação vazada de DbSet. Resista a isso. Faça as interfaces baseadas em tarefas, não em entidades: GetActiveSubscriptions(userId), não Get(int id). Cada método deve corresponder a uma consulta significativa no seu domínio.

Por que o provedor in-memory do EF Core não está nesta lista

A terceira opção que algumas equipes consideram é o Microsoft.EntityFrameworkCore.InMemory. A orientação oficial vem se firmando contra ele de forma constante, e a página atual no Learn classifica seu uso para testes como “fortemente desencorajado” e “suportado apenas para aplicações herdadas”. Três motivos:

Se você tem um conjunto de testes existente que o usa e ainda não tomou uma rasteira, está se apoiando em um banco falso que finge ser mais permissivo do que o real. O caminho de migração é normalmente uma troca de uma linha de UseInMemoryDatabase("name") para o padrão SQLite-em-memória acima, mais um construtor que abre a conexão e popula os dados.

Helpers do EF Core 11 que mudam a conta

Duas adições recentes do EF Core 11 valem a pena conhecer porque removem a parte mais chata de trocar provedores em um fixture de teste, que é desfazer o que a composition root de produção já registrou.

RemoveDbContext<TContext>() retira o contexto e seus DbContextOptions vinculados de uma IServiceCollection em uma única chamada, substituindo a dança manual de RemoveAll<DbContextOptions<MyContext>>() mais RemoveAll(typeof(MyContext)) que costumava ser frágil. Combinado com a sobrecarga sem parâmetros de AddPooledDbContextFactory<TContext>(), trocar um registro de SQL Server por um de SQLite dentro de WebApplicationFactory<TStartup> vira:

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();
    }
}

Duas linhas de registro substituem as oito antigas, e a limpeza sobrevive a qualquer mudança futura em como o EF Core faz o wiring do seu pipeline de opções. O contexto completo desse par está em o novo RemoveDbContext para trocas limpas de provedor em testes.

Se você também escreve interceptores pré-SaveChanges que leem ChangeTracker.Entries(), GetEntriesForState do EF Core 11 evita a passagem duplicada de DetectChanges, o que torna esses interceptores mais baratos de testar em um loop apertado.

Escolhendo entre os dois padrões

Um fluxo curto de decisão que se sustenta na prática:

  1. Se o seu código sob teste é lógica de negócio que chama um método de repositório, simule o repositório. Não suba banco de dados nenhum.
  2. Se o seu código sob teste é a própria implementação do repositório, ou qualquer coisa que construa consultas LINQ contra DbSet, use SQLite em memória.
  3. Se sua consulta depende de comportamento específico do provedor (funções JSON do SQL Server, índices full-text, EF.Functions.DateDiffDay, SQL bruto com sintaxe do fornecedor), escreva em vez disso um teste de integração contra o provedor real com Testcontainers. O SQLite vai compilar mas mentir em tempo de execução.
  4. Se você se vê querendo simular o DbContext diretamente para verificar “chamei SaveChanges?”, refatore o ponto da chamada para depender de uma interface menor (IUnitOfWork, IBlogRepository) e verifique contra ela. O mock será menor, o teste vai ler melhor, e você não estará brigando com o ChangeTracker.

A combinação que falha é “simular DbContext para consultas”. Toda outra combinação tem uma resposta defensável.

Posts relacionados e fontes primárias

Fontes primárias:

Comments

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

< Voltar