Observação
O acesso a essa página exige autorização. Você pode tentar entrar ou alterar diretórios.
O acesso a essa página exige autorização. Você pode tentar alterar os diretórios.
Nesta página, discutiremos técnicas para escrever testes automatizados que envolvem o sistema de banco de dados no qual o aplicativo é executado em produção. Há abordagens de teste alternativas, em que o sistema de banco de dados de produção é trocado por “dublês de teste”. Confira a Página de visão geral de testes para saber mais. Observe que o teste em um banco de dados diferente do utilizado em produção (por exemplo, Sqlite) não é abordado aqui, pois este banco de dados diferente é usado como um substituto de teste. Esta abordagem é detalhada em Testando sem o seu sistema de banco de dados de produção.
O principal obstáculo com testes que envolvem um banco de dados real é garantir o isolamento de teste adequado, para que os testes em execução em paralelo (ou mesmo em série) não interfiram uns com os outros. Confira aqui o código de exemplo completo para o que será exibido abaixo.
Dica
Esta página mostra técnicas xUnit, mas conceitos semelhantes existem em outras estruturas de teste, incluindo NUnit.
Configurando seu sistema de banco de dados
A maioria dos sistemas de banco de dados atualmente pode ser facilmente instalada, tanto em ambientes de CI quanto em computadores desenvolvedores. Embora seja frequentemente fácil instalar o banco de dados por meio do mecanismo de instalação regular, imagens prontas para uso do Docker estão disponíveis para a maioria dos principais bancos de dados e podem facilitar especialmente a instalação na CI. Para o ambiente do desenvolvedor, os workspaces do GitHub, o contêiner de desenvolvimento pode configurar todos os serviços e dependências necessários, incluindo o banco de dados. Embora isso exija um investimento inicial na instalação, uma vez feito, você tem um ambiente de teste de trabalho e pode se concentrar em coisas mais importantes.
Em determinados casos, os bancos de dados têm uma edição ou versão especial que pode ser útil para testes. Ao usar o SQL Server, LocalDB pode ser usado para executar testes localmente sem praticamente nenhuma configuração, ativando a instância do banco de dados sob demanda e possivelmente economizando recursos em máquinas de desenvolvedor menos poderosas. No entanto, o LocalDB não está sem seus problemas:
- O escopo de suporte dele é menor do que a da Edição do SQL Server Developer.
- Ele só está disponível no Windows.
- Isso pode causar atraso na primeira execução de teste conforme o serviço é ativado.
Geralmente, é recomendável instalar o SQL Server Developer Edition em vez do LocalDB, pois ele fornece o conjunto de recursos completo do SQL Server e geralmente é muito fácil de fazer.
Ao usar um banco de dados de nuvem, geralmente é apropriado testar em uma versão local do banco de dados, tanto para melhorar a velocidade quanto para diminuir os custos. Por exemplo, ao usar o SQL Azure em produção, você pode testar em um SQL Server instalado localmente – os dois são extremamente semelhantes (embora ainda seja sábio executar testes no próprio SQL Azure antes de entrar em produção). Ao usar o Azure Cosmos DB, o emulador do Azure Cosmos DB é uma ferramenta útil para o desenvolvimento local e para a execução de testes.
Criando, populando e gerenciando um banco de dados de teste
Depois que o banco de dados estiver instalado, você estará pronto para começar a usá-lo em seus testes. Na maioria dos casos simples, o conjunto de testes tem um único banco de dados compartilhado entre vários testes em várias classes de teste, portanto, precisamos de alguma lógica para garantir que o banco de dados seja criado e semeado exatamente uma vez durante o tempo de vida da execução do teste.
Ao usar o Xunit, isso pode ser feito por meio de uma classe fixture , que representa o banco de dados e é compartilhada em várias execuções de teste.
public class TestDatabaseFixture
{
private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True;ConnectRetryCount=0";
private static readonly object _lock = new();
private static bool _databaseInitialized;
public TestDatabaseFixture()
{
lock (_lock)
{
if (!_databaseInitialized)
{
using (var context = CreateContext())
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
context.AddRange(
new Blog { Name = "Blog1", Url = "http://blog1.com" },
new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();
}
_databaseInitialized = true;
}
}
}
public BloggingContext CreateContext()
=> new BloggingContext(
new DbContextOptionsBuilder<BloggingContext>()
.UseSqlServer(ConnectionString)
.Options);
}
Quando uma instância do acessório de teste acima é criada, ela usa EnsureDeleted() para remover o banco de dados (caso existente em uma execução anterior) e, em seguida, EnsureCreated() para criá-la com sua configuração de modelo mais recente (confira os documentos dessas APIs). Depois da criação do banco de dados, o acessório de teste o propaga com alguns dados que podem ser usados em testes. Vale a pena gastar algum tempo pensando em seus dados de semente, já que alterá-los mais tarde para um novo teste pode fazer com que os testes existentes falhem.
Para usar o acessório de teste em uma classe de teste, basta implementar IClassFixture no tipo de acessório de teste e o xUnit o injetará no construtor:
public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
public BloggingControllerTest(TestDatabaseFixture fixture)
=> Fixture = fixture;
public TestDatabaseFixture Fixture { get; }
Sua classe de teste agora tem uma propriedade Fixture que pode ser usada por testes para criar uma instância de contexto totalmente funcional:
[Fact]
public async Task GetBlog()
{
using var context = Fixture.CreateContext();
var controller = new BloggingController(context);
var blog = (await controller.GetBlog("Blog2")).Value;
Assert.Equal("http://blog2.com", blog.Url);
}
Por fim, você pode ter visto alguns bloqueios na lógica de criação do acessório de teste acima. Se um fixture for usado apenas em uma única classe de teste, o xUnit garante que ele seja instanciado exatamente uma vez; mas é comum usar o mesmo fixture de banco de dados em várias classes de teste. Embora o xUnit forneça acessórios de teste de coleção, esse mecanismo impede que classes de teste sejam executadas em paralelo, o que é importante para o desempenho do teste. Para gerenciar isso com segurança com uma instalação de classe xUnit, fazemos um bloqueio simples em torno da criação e propagação de banco de dados e usamos um sinalizador estático para garantir que nunca precisemos fazer isso duas vezes.
Testes que modificam dados
O exemplo acima mostrou um teste somente leitura, que é o caso fácil do ponto de vista do isolamento de teste: como nada é modificado, não é possível haver interferência de teste. Por outro lado, os testes que modificam dados são mais problemáticos, pois podem interferir uns com os outros. Uma técnica comum para isolar testes de gravação é encapsulá-los em uma transação e reverter a transação no final do teste. Como nada é realmente gravado no banco de dados, outros testes não veem nenhuma modificação e a interferência é evitada.
Aqui está um método de controlador que adiciona um Blog ao nosso banco de dados:
[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
_context.Blogs.Add(new Blog { Name = name, Url = url });
await _context.SaveChangesAsync();
return Ok();
}
Podemos testar esse método com o seguinte:
[Fact]
public async Task AddBlog()
{
using var context = Fixture.CreateContext();
context.Database.BeginTransaction();
var controller = new BloggingController(context);
await controller.AddBlog("Blog3", "http://blog3.com");
context.ChangeTracker.Clear();
var blog = await context.Blogs.SingleAsync(b => b.Name == "Blog3");
Assert.Equal("http://blog3.com", blog.Url);
}
Algumas anotações sobre o código de teste acima:
- Iniciamos uma transação para garantir que as alterações abaixo não sejam confirmadas no banco de dados e não interfiram em outros testes. Como a transação nunca é confirmada, ela é revertida implicitamente no final do teste quando a instância de contexto é descartada.
- Depois de fazer as atualizações desejadas, limpamos o rastreador de alterações da instância de contexto com ChangeTracker.Clear, para garantir que realmente carregamos o blog do banco de dados abaixo. Em vez disso, poderíamos usar duas instâncias de contexto, mas teríamos que garantir que a mesma transação seja usada por ambas as instâncias.
- Também é possível iniciar a transação no
CreateContextdo acessório de teste, para os testes receberem uma instância de contexto que já está em uma transação e pronta para atualizações. Isso pode ajudar a evitar casos em que a transação é esquecida acidentalmente, levando à interferência de teste que pode ser difícil de depurar. Você também pode querer separar testes somente leitura e de gravação em classes de teste diferentes.
Testes que gerenciam explicitamente transações
Há uma categoria final de testes que apresenta uma dificuldade adicional: testes que modificam dados e também gerenciam explicitamente transações. Como os bancos de dados normalmente não dão suporte a transações aninhadas, não é possível usar transações para isolamento como acima, pois elas precisam ser usadas pelo código real do produto. Embora esses testes tendem a ser mais raros, é necessário lidar com eles de uma maneira especial: você deve limpar seu banco de dados para o estado original após cada teste e a paralelização deve ser desabilitada para que esses testes não interfiram uns com os outros.
Vamos examinar o seguinte método de controlador como um exemplo:
[HttpPost]
public async Task<ActionResult> UpdateBlogUrl(string name, string url)
{
// Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);
if (blog is null)
{
return NotFound();
}
blog.Url = url;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return Ok();
}
Vamos supor que, por algum motivo, o método requer que uma transação serializável seja usada (normalmente não é o caso). Como resultado, não é possível usar uma transação para garantir o isolamento do teste. Como o teste realmente confirmará alterações no banco de dados, definiremos outra instalação com seu próprio banco de dados separado, para garantir que não interfira nos outros testes já mostrados acima:
public class TransactionalTestDatabaseFixture
{
private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True;ConnectRetryCount=0";
public BloggingContext CreateContext()
=> new BloggingContext(
new DbContextOptionsBuilder<BloggingContext>()
.UseSqlServer(ConnectionString)
.Options);
public TransactionalTestDatabaseFixture()
{
using var context = CreateContext();
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
Cleanup();
}
public void Cleanup()
{
using var context = CreateContext();
context.Blogs.RemoveRange(context.Blogs);
context.AddRange(
new Blog { Name = "Blog1", Url = "http://blog1.com" },
new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();
}
}
Essa luminária é semelhante à usada acima, mas notavelmente contém um método Cleanup; Chamaremos isso após cada teste para garantir que o banco de dados seja redefinido para seu estado inicial.
Se esse acessório de teste só for usado por uma única classe de teste, será possível fazer referência a ele como um acessório de teste de classe, como acima, pois o xUnit não paraleliza testes na mesma classe. Para saber mais sobre coleções de teste e paralelização, confira os documentos do xUnit. Se, no entanto, quisermos compartilhar essa instalação entre várias classes, devemos garantir que essas classes não executem em paralelo, para evitar qualquer interferência. Para isso, use-o como um acessório de teste de coleção do xUnit em vez de como um acessório de teste de classe.
Primeiro, defina uma coleção de testes, que fará referência ao acessório de teste e será usada por todas as classes de teste transacionais que a exigem:
[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}
Agora, faça referência à coleção de testes na classe de teste e aceite o acessório de teste no construtor, como anteriormente:
[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
=> Fixture = fixture;
public TransactionalTestDatabaseFixture Fixture { get; }
Por fim, torne a classe de teste descartável, tomando medidas para que o método Cleanup do acessório de teste seja chamado após cada teste:
public void Dispose()
=> Fixture.Cleanup();
Observe que, como o xUnit só cria a instância do acessório de teste de coleção uma única vez, não é necessário usar o bloqueio na criação e na propagação do banco de dados, como acima.
O código completo para o exemplo acima pode ser visualizado aqui.
Dica
Se você tiver várias classes de teste com testes que modificam o banco de dados, ainda poderá executá-las em paralelo por meio de instalações diferentes, cada uma fazendo referência a seu próprio banco de dados. Criar e usar muitos bancos de dados de teste não é problemático e deve ser feito sempre que for útil.
Criação eficiente de banco de dados
Nos exemplos acima, EnsureDeleted() e EnsureCreated() foram usados antes da execução dos testes, a fim de garantir um banco de dados de teste atualizado. Essas operações podem ser um pouco lentas em determinados bancos de dados, o que pode ser um problema à medida que você itera sobre alterações de código e executa novamente testes várias vez. Se esse for o caso, remova temporariamente o comentário de EnsureDeleted no construtor do acessório de teste. Com isso, o mesmo banco de dados será reutilizado em execuções de teste.
A desvantagem dessa abordagem é que, se você alterar o modelo do EF Core, o esquema do banco de dados não estará atualizado e os testes poderão falhar. Como resultado, recomendamos fazer isso temporariamente durante o ciclo de desenvolvimento.
Limpeza eficiente do banco de dados
Vimos acima que, quando as alterações são realmente confirmadas no banco de dados, devemos limpar o banco de dados entre cada teste para evitar interferências. No exemplo de teste transacional acima, fizemos isso usando APIs do EF Core para excluir o conteúdo da tabela:
using var context = CreateContext();
context.Blogs.RemoveRange(context.Blogs);
context.AddRange(
new Blog { Name = "Blog1", Url = "http://blog1.com" },
new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();
Normalmente, essa não é a maneira mais eficiente de limpar uma tabela. Se a velocidade de teste for uma preocupação, talvez você queira usar o SQL bruto para excluir a tabela:
DELETE FROM [Blogs];
Talvez você também queira considerar o uso do pacote respawn, que limpa com eficiência um banco de dados. Além disso, ele não exige que você especifique as tabelas a serem limpas e, portanto, seu código de limpeza não precisa ser atualizado à medida que as tabelas são adicionadas ao seu modelo.
Resumo
- Ao testar em um banco de dados real, vale a pena distinguir entre as seguintes categorias de teste:
- Os testes somente leitura são relativamente simples e sempre podem ser executados em paralelo no mesmo banco de dados sem precisar se preocupar com o isolamento.
- Os testes de gravação são mais problemáticos, mas transações podem ser usadas para garantir o isolamento adequado.
- Os testes transacionais são os mais problemáticos, exigindo lógica para redefinir o banco de dados de volta ao estado original, bem como desabilitar a paralelização.
- Separar essas categorias de teste em classes separadas pode evitar confusão e interferência acidental entre testes.
- Considere os dados de teste da propagação e tente elaborar testes para não haver interrupções frequentes em caso de alterações nos dados da propagação.
- Use vários bancos de dados para paralelizar testes que modificam os bancos de dados e, possivelmente, também para permitir diferentes configurações de dados iniciais.
- Se a velocidade de teste for uma preocupação, talvez você queira examinar técnicas mais eficientes para criar seu banco de dados de teste e limpar seus dados entre execuções.
- Sempre tenha em mente a paralelização e o isolamento do teste.