Teste com o sistema de banco de dados de produção

Nesta página, discutiremos técnicas para elaborar testes automatizados que envolvem o sistema de banco de dados em que 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 daquele usado em produção (por exemplo, Sqlite) não é abordado aqui, pois esse banco de dados diferente é usado como um dublê de teste. A abordagem para isso é descrita em Teste sem o sistema de banco de dados de produção.

O principal obstáculo nos testes que envolvem um banco de dados real é garantir o isolamento adequado, para que aqueles que estão 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 as técnicas do xUnit, mas há conceitos semelhantes em outras estruturas de teste, como o NUnit.

Configuração do sistema de banco de dados

A maioria dos sistemas de banco de dados atuais pode ser facilmente instalada, tanto em ambientes de CI quanto em computadores de desenvolvedores. Embora geralmente seja fácil instalar o banco de dados por meio do mecanismo de instalação regular, há imagens do Docker prontas para uso disponíveis para a maioria dos principais bancos de dados, a fim de facilitar particularmente 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. Depois de um esforço inicial na instalação, você tem um ambiente de teste funcional e pode se concentrar em coisas mais importantes.

Em alguns casos, os bancos de dados têm uma edição ou versão especial que pode ser útil para testes. No caso do SQL Server, é possível usar o LocalDB para executar testes localmente sem precisar realizar praticamente nenhuma configuração, ativando a instância do banco de dados sob demanda e possivelmente economizando recursos em computadores de desenvolvedor menos eficientes. No entanto, o LocalDB também tem seus problemas:

  • O escopo de suporte dele é menor do que o do SQL Server Developer Edition.
  • Ele só está disponível no Windows.
  • Isso pode causar atraso na primeira execução de teste conforme o serviço é ativado.

Em geral, recomendamos a instalação do SQL Server Developer Edition em vez do LocalDB, porque ela é normalmente muito fácil de fazer e fornece o conjunto completo de recursos do SQL Server.

Normalmente, ao usar um banco de dados de nuvem, é adequado realizar testes em uma versão local dele, o que melhora a velocidade e diminui os custos. Por exemplo, ao usar o SQL Azure em produção, é possível realizar testes em um SQL Server instalado localmente. Além disso, embora os dois sejam extremamente semelhantes, ainda é útil realizar testes no próprio SQL Azure antes de movê-lo para produção. Ao usar o Azure Cosmos DB, o emulador do Azure Cosmos DB é uma ferramenta útil tanto para o desenvolvimento local quanto para a execução de testes.

Criação, propagação e gerenciamento de um banco de dados de teste

Depois que o banco de dados estiver instalado, você estará pronto para começar a usá-lo em testes. Na maioria dos casos simples, como o conjunto de testes tem um único banco de dados que é compartilhado entre vários testes de várias classes, é necessária alguma lógica para garantir que esse banco de dados seja criado e propagado exatamente uma vez durante o tempo de vida da execução de teste.

Ao usar o xUnit, isso pode ser feito por meio de um acessório de teste de classe, que representa o banco de dados e é compartilhado entre várias execuções de teste:

public class TestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True";

    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, ele usa EnsureDeleted() para remover o banco de dados (caso ele exista de uma execução anterior) e, em seguida, EnsureCreated() para criá-lo 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. É importante levar em consideração os dados da propagação, pois a alteração posterior deles para um novo teste pode causar falha nos testes atuais.

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

A 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 void GetBlog()
{
    using var context = Fixture.CreateContext();
    var controller = new BloggingController(context);

    var blog = controller.GetBlog("Blog2").Value;

    Assert.Equal("http://blog2.com", blog.Url);
}

Por fim, talvez você tenha notado bloqueios na lógica de criação do acessório de teste acima. Se o acessório de teste for usado em uma única classe de teste, o xUnit certamente criará uma instância para ele exatamente uma vez. No entanto, é comum usar o mesmo acessório de teste 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 esse processo de maneira segura com um acessório de teste de classe do xUnit, você faz um bloqueio simples na criação e na propagação do banco de dados e usa um sinalizador estático a fim de garantir que nunca seja preciso fazer isso de novo.

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 confirmado no banco de dados, nenhuma modificação é detectada pelos outros testes e a interferência é evitada.

Veja o seguinte método de controlador que adiciona um blog ao banco de dados:

[HttpPost]
public ActionResult AddBlog(string name, string url)
{
    _context.Blogs.Add(new Blog { Name = name, Url = url });
    _context.SaveChanges();

    return Ok();
}

É possível testar esse método usando o seguinte código:

[Fact]
public void AddBlog()
{
    using var context = Fixture.CreateContext();
    context.Database.BeginTransaction();

    var controller = new BloggingController(context);
    controller.AddBlog("Blog3", "http://blog3.com");

    context.ChangeTracker.Clear();

    var blog = context.Blogs.Single(b => b.Name == "Blog3");
    Assert.Equal("http://blog3.com", blog.Url);

}

Algumas anotações sobre o código de teste acima:

  • É preciso iniciar 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, é preciso limpar o rastreador de alterações da instância de contexto com ChangeTracker.Clear, a fim de garantir que o blog do banco de dados abaixo seja realmente carregado. Seria possível usar duas instâncias de contexto como alternativa, mas seria necessário garantir o uso da mesma transação por ambas.
  • Também é possível iniciar a transação no CreateContext do acessório de teste, para que os testes recebam uma instância de contexto que já esteja em uma transação e esteja pronta para atualizações. Isso pode ajudar a evitar casos em que a transação é esquecida acidentalmente, o que resulta em uma 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 última categoria de testes que apresenta uma dificuldade adicional: os 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, porque elas precisam ser usadas pelo código real do produto. Embora esses testes possam ser mais raros, é necessário lidar com eles de maneira especial: limpe o banco de dados para o estado original após cada teste e desative a paralelização para que eles não interfiram uns com os outros.

Confira o seguinte método de controlador de exemplo:

[HttpPost]
public 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.
    using var transaction = _context.Database.BeginTransaction(IsolationLevel.Serializable);

    var blog = _context.Blogs.FirstOrDefault(b => b.Name == name);
    if (blog is null)
    {
        return NotFound();
    }

    blog.Url = url;
    _context.SaveChanges();

    transaction.Commit();
    return Ok();
}

Suponha que, por algum motivo, o método requeira o uso de uma transação serializável (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, é preciso definir outro acessório de teste com um banco de dados próprio, a fim de garantir que não haja interferência nos outros testes já mostrados acima:

public class TransactionalTestDatabaseFixture
{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True";

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

Esse acessório de teste é semelhante ao usado acima, mas contém claramente um método Cleanup, que deve ser chamado após cada teste a fim de garantir que o banco de dados seja redefinido para o 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. No entanto, ao compartilhar esse acessório de teste entre várias classes, é preciso garantir que elas não sejam executadas em paralelo, a fim de evitar interferências. 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.

Confira aqui o código de exemplo completo para o que foi exibido acima.

Dica

Se você tiver várias classes de teste com testes que modificam o banco de dados, ainda será possível executá-las em paralelo por meio de diferentes acessórios de teste, em que cada um faz referência ao próprio banco de dados. Criar e usar muitos bancos de dados de teste não é problemático, e essa abordagem deve ser adotada sempre que for útil.

Criação eficiente de bancos 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 lentas em determinados bancos de dados, o que pode ser um problema à medida que você faz iterações em alterações de código e executa várias vez novos testes. 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. Portanto, recomendamos fazer isso temporariamente durante o ciclo de desenvolvimento.

Limpeza eficiente de bancos de dados

Conforme mencionado acima, quando alterações são realmente confirmadas no banco de dados, é preciso limpá-lo após cada teste para evitar interferências. No exemplo de teste transacional acima, isso foi feito usando as 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, use o SQL bruto como alternativa para excluir a tabela:

DELETE FROM [Blogs];

Também é possível considerar o uso do pacote respawn, que limpa com eficiência o banco de dados. Além disso, como ele não exige que você especifique as tabelas a serem limpas, o código de limpeza não precisa ser atualizado à medida que tabelas são adicionadas ao modelo.

Resumo

  • Ao fazer testes em um banco de dados real, é útil 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 preocupações 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 e exigem lógica para redefinir o banco de dados de volta ao estado original e 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 a fim de que não haja interrupções frequentes em caso de alterações nos dados da propagação.
  • Use vários bancos de dados para paralelizar testes que modificam o banco de dados e, possivelmente, a fim de também permitir diferentes configurações de dados de propagação.
  • Se a velocidade de teste for uma preocupação, talvez você queira examinar técnicas mais eficientes de criação do banco de dados de teste e limpar os dados entre as execuções.
  • Sempre leve em consideração a paralelização e o isolamento dos testes.