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:
- As chaves geradas nunca são atribuídas. Com um provedor real,
Add(blog)para uma coluna[Key] int Id { get; set; }dá ablog.Idum valor temporário negativo, e depois uma chave real apósSaveChanges. Um contexto simulado pula ambos. Testes que leemblog.Iddepois deAddenxergam0, o que passa silenciosamente nas verificações de igualdade contra outras entidades não salvas. - 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 deReferenceEquals(ctx.Blogs.Find(1), ctx.Blogs.First(b => b.Id == 1))funciona em produção e falha no teste, ou vice-versa. SaveChangesvira um verificador no-op. OSaveChanges()do mock retorna 0 e nunca valida navegações obrigatórias, nunca executa conversores de valor, nunca dispara interceptores, nunca lançaDbUpdateConcurrencyExceptionquando 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:
- Transações são silenciosamente ignoradas.
BeginTransactionretorna um no-op, então um teste para “isso falha no meio e faz rollback” passa independentemente de o rollback funcionar. SQLite em memória suporta transações reais. - Não é relacional. Restrições únicas, integridade referencial e a maioria das traduções específicas do provedor estão ausentes. Uma consulta que falha no SQL Server com um erro de tradução roda alegremente contra o provedor in-memory.
- SQL bruto não é suportado. O SQLite suporta
FromSqlRawcontra qualquer SQL que ele entenda.
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:
- 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.
- 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. - 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. - Se você se vê querendo simular o
DbContextdiretamente para verificar “chameiSaveChanges?”, 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 oChangeTracker.
A combinação que falha é “simular DbContext para consultas”. Toda outra combinação tem uma resposta defensável.
Posts relacionados e fontes primárias
- Como testar unitariamente código que usa HttpClient cobre o padrão paralelo de substituir a costura (
HttpMessageHandler) em vez de simular a superfície (HttpClient). - EF Core 11 Preview 3 adiciona RemoveDbContext para trocas limpas de provedor em testes explica o helper usado no trecho de
WebApplicationFactoryacima. - EF Core 11 adiciona GetEntriesForState para pular DetectChanges é um pano de fundo útil quando você está testando interceptores de auditoria.
- Como usar records com EF Core 11 corretamente merece uma olhada se suas entidades blog/post forem records, porque a igualdade de records interage com a resolução de identidade do
ChangeTrackerde formas surpreendentes. - Como usar IAsyncEnumerable com EF Core 11 é o tipo de retorno certo quando um método de repositório precisa fazer streaming em vez de materializar uma lista.
Fontes primárias:
- Choosing a testing strategy no Microsoft Learn, que é a orientação autoritativa contra simular
DbSetpara consultas e contra o provedor in-memory. - Testing without your production database system para os exemplos de SQLite-em-memória e repositório que este post adapta.
- SQLite in-memory database documentation para a semântica de tempo de vida da conexão em que o padrão SQLite-em-memória se baseia.
- Testcontainers for .NET para a saída de emergência de teste de integração quando o SQLite mente.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.