Partilhar via


Testando contra o seu sistema de banco de dados de produção

Nesta página, discutimos técnicas para escrever testes automatizados que envolvem o sistema de banco de dados no qual o aplicativo é executado em produção. Existem abordagens de teste alternativas, onde o sistema de banco de dados de produção é trocado por duplas de teste; Consulte a página Visão geral do teste para obter mais informações. Observe que o teste em um banco de dados diferente do que é usado na produção (por exemplo, Sqlite) não é abordado aqui, uma vez que o banco de dados diferente é usado como um teste duplo; essa abordagem é abordada em Testes sem seu sistema de banco de dados de produção.

O principal obstáculo com os testes que envolvem um banco de dados real é garantir o isolamento adequado dos testes, para que os testes executados em paralelo (ou mesmo em série) não interfiram uns com os outros. O código de exemplo completo para o abaixo pode ser visto aqui.

Sugestão

Esta página mostra técnicas de 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 hoje em dia pode ser facilmente instalada, tanto em ambientes de CI quanto em máquinas de desenvolvedores. Embora seja frequentemente fácil instalar o banco de dados por meio do mecanismo de instalação regular, imagens do Docker prontas para uso estão disponíveis para a maioria dos principais bancos de dados e podem tornar a instalação particularmente fácil em CI. Para o ambiente de desenvolvedor, GitHub Workspaces, o Dev Container pode configurar todos os serviços e dependências necessários - incluindo o banco de dados. Embora isso exija um investimento inicial na configuração, uma vez feito isso, você tem um ambiente de teste de trabalho e pode se concentrar em coisas mais importantes.

Em certos casos, as bases de dados têm uma edição ou versão especial que pode ser útil para testes. Ao usar o SQL Server, o LocalDB pode ser usado para executar testes localmente praticamente sem nenhuma configuração, girando 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á isento de problemas:

  • Ele não oferece suporte a tudo o que o SQL Server Developer Edition faz.
  • Está disponível apenas no Windows.
  • Isso pode causar atraso na primeira execução de teste quando o serviço é iniciado.

Geralmente, recomendamos a instalação do SQL Server Developer edition em vez do LocalDB, pois ele fornece o conjunto completo de recursos do SQL Server e geralmente é muito fácil de fazer.

Ao usar um banco de dados em nuvem, geralmente é apropriado testar em relação a 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 relação a um SQL Server instalado localmente - os dois são extremamente semelhantes (embora ainda seja aconselhável 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 desenvolver localmente e executar testes.

Criando, inicializando e gerindo um banco de dados de teste

Assim que o banco de dados estiver instalado, você estará pronto para começar a usá-lo em seus testes. Na maioria dos casos simples, seu conjunto de testes tem um único banco de dados que é 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 configuração de classe, 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 o componente acima é instanciado, ele usa EnsureDeleted() para descartar a base de dados (caso esta exista de uma execução anterior) e em seguida, EnsureCreated() para criá-la com a sua configuração de modelo mais recente (consulte a documentação para estas APIs). Uma vez que o banco de dados é criado, o dispositivo o semeia com alguns dados que nossos testes podem usar. Vale a pena passar algum tempo pensando nos 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 em uma classe de teste, basta implementar IClassFixture sobre seu tipo de fixture e o xUnit irá injetá-lo em seu construtor:

public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
    public BloggingControllerTest(TestDatabaseFixture fixture)
        => Fixture = fixture;

    public TestDatabaseFixture Fixture { get; }

Sua classe de teste agora tem uma Fixture propriedade 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);
}

Finalmente, pode ter notado algum bloqueio na lógica de criação do dispositivo acima. Se o acessório for usado apenas em uma única classe de teste, é garantido que ele será instanciado exatamente uma vez pelo xUnit; mas é comum usar o mesmo dispositivo de banco de dados em várias classes de teste. O xUnit fornece dispositivos de coleta, mas esse mecanismo impede que suas classes de teste sejam executadas em paralelo, o que é importante para o desempenho do teste. Para gerenciar isso com segurança com um dispositivo de classe xUnit, usamos 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 tenhamos que fazer isso duas vezes.

Testes que modificam dados

O exemplo acima mostrou um teste de leitura apenas, que é o caso fácil do ponto de vista do isolamento de testes: como nada está a ser modificado, a interferência do teste não é possível. Em contrapartida, os testes que modificam os dados são mais problemáticos, uma vez que podem interferir uns com os outros. Uma técnica comum para isolar testes de escrita é envolver o teste em uma transação e reverter essa transação no final do teste. Como nada é realmente gravado no banco de dados, outros testes não veem modificações 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 este 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 notas 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 que desejamos, 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. 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.
  • Você pode até querer iniciar a transação no CreateContext, para que os testes recebam uma instância de contexto que já está numa transação e pronta para ser atualizada. Isso pode ajudar a evitar casos em que a transação é acidentalmente esquecida, levando a interferências de teste que podem ser difíceis de depurar. Você também pode querer separar os testes de leitura e de escrita em classes de teste diferentes.

Testes que gerenciam explicitamente as transações

Há uma última categoria de testes que apresenta uma dificuldade adicional: testes que modificam dados e também gerenciam explicitamente as transações. Como os bancos de dados normalmente não oferecem 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 tendam a ser mais raros, é necessário manipulá-los de uma maneira especial: você deve limpar seu banco de dados para seu 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 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 uma transação serializável para ser usada (isso não é normalmente o caso). Como resultado, não podemos usar uma transação para garantir o isolamento do teste. Como o teste realmente confirmará alterações no banco de dados, definiremos outro dispositivo com seu próprio banco de dados separado, para garantir que não interfiramos com os 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();
    }
}

Este dispositivo é semelhante ao usado acima, mas notavelmente contém um Cleanup método, vamos chamá-lo após cada teste para garantir que o banco de dados seja redefinido para seu estado inicial.

Se este dispositivo for usado apenas por uma única classe de teste, podemos referenciá-lo como um dispositivo de classe como acima - o xUnit não paraleliza testes dentro da mesma classe (leia mais sobre coleções de teste e paralelização nos documentos xUnit). Se, no entanto, quisermos compartilhar esse equipamento entre várias classes, devemos garantir que essas classes não funcionem em paralelo, para evitar qualquer interferência. Para fazer isso, usaremos isso como um dispositivo de coleta xUnit em vez de como um dispositivo de classe.

Primeiro, definimos uma coleção de testes, que faz referência ao nosso equipamento e será usada por todas as classes de teste transacionais que o exigem:

[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

Agora fazemos referência à coleção de teste na nossa classe de teste e aceitamos a configuração no construtor como antes.

[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
    public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
        => Fixture = fixture;

    public TransactionalTestDatabaseFixture Fixture { get; }

Finalmente, tornamos a nossa classe de teste descartável, providenciando para que o método da fixture Cleanup seja chamado após cada teste.

public void Dispose()
    => Fixture.Cleanup();

Observe que, como o xUnit só instancia o dispositivo de coleta uma vez, não há necessidade de usar o bloqueio em torno da criação e propagação do banco de dados, como fizemos acima.

O código de exemplo completo para o acima pode ser visto aqui.

Sugestão

Se você tiver várias classes de teste com testes que modificam o banco de dados, ainda poderá executá-las em paralelo por ter diferentes equipamentos, cada um fazendo referência ao 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, usamos EnsureDeleted() e EnsureCreated() antes de executar testes, para garantir que temos um banco de dados de teste de data up-to. 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 testes repetidamente. Se esse for o caso, você pode querer comentar EnsureDeleted temporariamente no construtor do seu equipamento: isso reutilizará o mesmo banco de dados 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 apenas temporariamente durante o ciclo de desenvolvimento.

Limpeza eficiente do banco de dados

Vimos acima que, quando as alterações são realmente efetuadas no banco de dados, devemos limpar o banco de dados entre testes 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();

Essa normalmente não é a maneira mais eficiente de limpar uma tabela. Se a velocidade do teste for uma preocupação, convém usar SQL bruto para excluir a tabela:

DELETE FROM [Blogs];

Você também pode considerar o uso do pacote respawn , que limpa eficientemente 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 ter que se preocupar com o isolamento.
    • Os testes de escrita são mais problemáticos, mas as transações podem ser usadas para garantir que estejam devidamente isoladas.
    • Os testes transacionais são os mais problemáticos, exigindo lógica para redefinir o banco de dados de volta ao seu estado original, bem como desabilitar a paralelização.
  • A separação destas categorias de ensaio em classes separadas pode evitar confusões e interferências acidentais entre ensaios.
  • Pense antecipadamente nos seus dados de teste com sementes e tente escrever os seus testes de uma forma que não falhem com frequência se esses dados mudarem.
  • Use vários bancos de dados para paralelizar testes que modificam o banco de dados e, possivelmente, também para permitir diferentes configurações de dados de semente.
  • Se a velocidade de teste for uma preocupação, convém examinar técnicas mais eficientes para criar seu banco de dados de teste e limpar seus dados entre execuções.
  • Tenha sempre em mente a paralelização e o isolamento dos testes.