Simplificando setup de testes em C# com Test Data Builders

Implementar uma boa suíte de testes unitários para nossos projetos pode ser muito recompensador, e na maioria dos casos, é indispensável. Mas, se não tomarmos certos cuidados, a manutenção desta suíte pode se tornar tediosa e até terrivelmente trabalhosa.

Vejamos um cenário muito comum, e que pode ser resolvido facilmente com o design pattern Builder aplicado a testes para obter o que chamamos de test data builders, ou simplesmente test builders. Ao longo deste artigo e de eventuais outros, vou chamar esses construtores de test builders.

Existem bibliotecas muito boas que podem nos ajudar a configurar esses builders, como o NBuilder, mas neste artigo vamos focar na escrita artesanal de builders especialmente desenhados para a aplicação.

Testes repetitivos

Digamos que no nosso sistema imaginário, temos um objeto de negócio como este:

public class Cliente
{
    public int Codigo { get; set; }
    public string Nome { get; set; }
    public string Cpf { get; set; }
}

Uma classe real que representa um cliente provavelmente seria muito maior, mas esta será suficiente para o nosso exercício.

Agora imaginemos um cenário de teste simples que precisa passar um cliente para uma rotina.

[Fact]
public void Deve_persistir_um_novo_cliente()
{
    var cliente = new Cliente
    {
        Nome = "John Doe",
        Cpf = "071.777.111-89"
    };

    var codigo = _repositorio.Salvar(cliente);

    //... algum código de validação
}

À medida em que nossa aplicação vai crescendo, surgem mais e mais cenários que precisam instanciar um cliente para testar alguma regra de negócio. A configuração do cliente é muito parecido em todos eles, mas dependendo do dia, do desenvolvedor, e do alinhamento dos astros, as configurações começam a divergir, e talvez nem todos os cenários sejam realmente válidos.

Como podemos ver no exemplo a seguir, algumas semanas e muitos testes depois daquele primeiro cenário de teste que vimos um pouco acima, um desenvolvedor escreveu um novo teste para validar o CPF de um cliente.

[Fact]
public void Deve_garantir_que_o_cliente_possui_cpf_valido()
{
    var cliente = new Cliente
    {
        Codigo = 2,
        Nome = "Terminator",
        Cpf = "07166611189"
    };

    var cpfValido = _validadorCpfCliente.Validar(cliente);

    cpfValido.Should().BeTrue();
}

Podemos ver que já existem divergências na forma como a propriedade CPF é configurada em cada cenário: algumas possuem CPFs formatados, outras não; alguns CPFs talvez sejam válidos, mas a maioria não é.

E então chega o dia em que o time decide que para resolver inconsistências nos tratamentos do CPF do cliente, vão implementar um objeto rico do tipo CPF, que vai se autovalidar.

A classe CPF é mais ou menos assim:

public class CPF
{
    private readonly long _valor;

    public CPF(long cpf)
    {
        ValidarCpf(cpf);
        _valor = cpf;
    }

    public void ValidarCpf(long cpf) {...}
    public override string ToString() {...}
}

O time está contente com essa implementação e decide que a propriedade CPF do cliente deverá ser alterada de string para CPF.

Mas então se dão conta de que o objeto cliente é instanciado em 652 testes, e de diferentes formas.

Alguém sugere que poderiam fazer esse ajuste facilmente com uma operação de substituição (CTRL+H), mas então se dá conta que a classe CPF é instanciada a partir de um long, e não de uma String, e que vão ter que ajustar na mão todos os 652 testes.

O problema que eles eles estão enfrentando é fruto do alto acoplamento entre os cenários de teste de o código de produção.

Este é o típico cenário que o uso de Test Builders poderia ter evitado uma grande dor de cabeça.

Test Builders

Os Test Builders podem nos ajudar de várias formas:

  • Evitando duplicação na configuração de testes
  • Garantindo a consistência dos objetos construídos
  • Melhorando legibilidade dos cenários de teste

A seguir, vamos investigar de que formas um Test Builder bem construído pode nos ajudar, e como eles afetam nossas suítes de testes.

Evitando duplicação com Test Builders

Vamos começar imaginando como teria sido se o time fictício do cenário anterior tivesse usado um Test Builder desde o segundo ou terceiro cenário em que precisaram instanciar um cliente num cenário de teste.

Na sua forma mais simples, um ClienteBuilder não passa de uma factory simples, que retorna uma instância padronizada de Cliente.

public class ClienteBuilder
{
    public ClienteBuilder()
    {
        return new Cliente
        {
            Codigo = 0,
            Nome = "Terminator",
            Cpf = "722.111.333-64"
        };
    }
}

Então todos os cenários estariam instanciando novos clientes assim:

[Fact]
public void Teste_que_nao_se_importa_com_o_cpf()
{
    var cliente = new ClienteBuilder().Construir();
    //... resto do cenário de teste
}

[Fact]
public void Teste_que_precisa_configurar_um_cpf_especifico()
{
    var cliente = new ClienteBuilder()
        .ComCpf("071.722.111-90")
        .Construir();
    //... teste que valida CPF
}

Se agora o time tomar a decisão de transformar a propriedade CPF do cliente de String para CPF, será muito simples, pois basta fazer este ajuste no próprio builder, uma única vez:

public class ClienteBuilder
{
    private string cpf;

    public ClienteBuilder()
    {
        const string cpfValidoString = "946.549.990-00";
        var cpfString = (cpf ?? cpfValidoString)
            .Replace(".", "")
            .Replace("-", "");

        return new Cliente
        {
            Codigo = 0,
            Nome = "Terminator",
            Cpf = new CPF(valor: long.Parse(cpfString))
        };
    }

    public ClienteBuilder ComCpf(string cpf){
        this._cpf = cpf;
        return this;
    }
}

E se algum teste passar a quebrar, provavelmente será porque o CPF informado não era válido em primeiro lugar.

Por fim, imaginemos que o time decidiu que a classe Cliente não deve mais ter setters públicos, e que todos os campos serão preenchidos via construtor:

public class Cliente
{
    public int Codigo { get; }
    public string Nome { get; }
    public CPF Cpf { get; }

    public Cliente(int codigo, string nome, CPF cpf)
    {
        Codigo = codigo;
        Nome = nome;
        Cpf = cpf;
    }
}

Não fosse pelo uso de Test Builders (ou pelo menos algum tipo de factory), teríamos que ajustar todos os cenários de teste. Mas como temos nosso ClienteBuilder, somente ele precisará ser alterado, e todos os cenários de testes passam ilesos pela alteração.

Abaixo, temos um ClienteBuilder totalmente customizável, usando este novo construtor:

public class ClienteBuilder
{
    private int? _codigo;
    private string nome;
    private string cpf;

    public ClienteBuilder()
    {
        const string cpfValidoString = "946.549.990-00";
        var cpfString = (cpf ?? cpfValidoString)
            .Replace(".", "")
            .Replace("-", "");

        return new Cliente(
            codigo: _codigo ?? 0,
            nome: nome ?? "Terminator",
            cpf: new CPF(valor: long.Parse(cpfString))
        );
    }

    public ClienteBuilder ComCodigo(int codigo){
        this._codigo = codigo;
        return this;
    }

    public ClienteBuilder ComNome(string nome){
        this._nome = nome;
        return this;
    }

    public ClienteBuilder ComCpf(string cpf){
        this._cpf = cpf;
        return this;
    }
}

E um teste que configura todos os campos de uma forma legível e fácil de acompanhar:

[Fact]
public void Teste_que_precisa_configurar_um_cpf_especifico()
{
    var cliente = new ClienteBuilder()
        .ComCodigo(42)
        .ComNome("Pensador Profundo")
        .ComCpf("049.908.620-15")
        .Construir();
    //... alguma lógica de teste
}

Como pudemos ver nos exemplos acima, o uso de test builders bem implementados ajuda a desacoplar os testes do código testado, ao mesmo tempo em que resulta em cenários de teste mais consistentes e fáceis de ler.

Construindo objetos consistentes com Test Builders

Builders também podem ser usados para evitar a criação de objetos que sejam negocialmente inválidos, e isso acaba tendo um efeito colateral benéfico para nossos testes: eles ficam mais realistas.

Um exemplo corriqueiro é o de propriedades que precisam ser configuradas juntas para fazerem sentido.

Vamos para um exemplo fácil de visualizar: precisamos simular uma requisição HTTP para um sistema legado que retorna um objeto do tipo PrecoResponse.

public class PrecoResponse
{
    public int CodigoProduto { get; set; }
    public float Valor { get; set; }
    public int Tipo { get; set; }
    public string DescricaoTipo { get; set; }
    public int CodigoTabela { get; set; }
    public int InicioFaixa { get; set; }
    public int FimFaixa { get; set; }
}

Vamos supor ainda que tenhamos duas regras para observar nessa integração:

  1. Tipo e DescricaoTipo são sempre atualizados juntos. Existem três tipos de preço, dos quais os dois primeiros são tabelados;
  2. Alguns preços fazem parte de uma tabela de preços aplicada por quantidade. Estes tipos, que obedece a uma faixa de quantidade de produtos vendidos. Quando CodigoTabela for zero, InicioFaixa e FimFaixa também serão.

Esta regra de negócio é um pouco complicada, e se nosso builder não for implementado com cuidado, essas regras não vão ficar claras nos testes, ou pior ainda, vários cenários podem não fazer sentido nenhum!

Podemos garantir que todos os cenários sejam construídos de acordo com estas regras simplesmente traduzindo-as para a interface do PrecoResponseBuilder:

public enum TipoPreco
{
    Praticado = 1, // tabelado
    Previsto = 2, // tabelado
    Minimo = 3
}

public class PrecoResponseBuilder
{
    private int? _codigoProduto = null;
    private float? _valor = null;
    private TipoPreco? _tipo = null;
    private string _descricaoTipo = null;
    private int? _codigoTabela = null;
    private int? _inicioFaixa = null;
    private int? _fimFaixa = null;

    public PrecoResponseBuilder Construir()
    {
        return new PrecoResponseBuilder
        {
            CodigoProduto = _codigoProduto ?? 1,
            Valor = _valor ?? 100f,
            Tipo = (int)(_tipo ?? TipoPreco.Praticado),
            DescricaoTipo = _descricaoTipo ?? "PREVISTO",
            CodigoTabela = _codigoTabela ?? 1,
            InicioFaixa = _inicioFaixa ?? 1,
            FimFaixa = _fimFaixa ?? 100
        };
    }

    public PrecoResponseBuilder DoTipoMinimo()
    {
        return this.DoTipo(TipoPreco.Minimo);
    }

    public PrecoResponseBuilder DoTipoPraticado(
        int codigoTabela,
        int inicioFaixa, int fimFaixa)
    {
        return this
            .DoTipo(TipoPreco.Praticado)
            .ComTabelaPrecos(codigoTabela, inicioFaixa, fimFaixa);
    }

    public PrecoResponseBuilder DoTipoPrevisto(
        int codigoTabela,
        int inicioFaixa, int fimFaixa)
    {
        return this
            .DoTipo(TipoPreco.Previsto)
            .ComTabelaPrecos(codigoTabela, inicioFaixa, fimFaixa);
    }

    private PrecoResponseBuilder DoTipo(TipoPreco tipoPreco)
    {
        this._tipoPreco = TipoPreco.Praticado;
        this._descricaoPreco = TipoPreco.Praticado.ToString();
        return this;
    }

    private PrecoResponseBuilder ComTabelaPrecos(
        int codigoTabela,
        int inicioFaixa, int fimFaixa)
    {
        this._codigoTabela = codigoTabela;
        this._inicioFaixa = inicioFaixa;
        this._fimFaixa = fimFaixa;
        return this;
    }
}

Como podemos ver no exemplo acima, será muito difícil criar estados inválidos usando esse PrecoResponseBuilder. Cada configuração de preço atualiza em conjunto as propriedades Tipo e DescricaoTipo. Além disso, apenas os métodos para configurar preços dos tipos Praticado e Previsto permitem configurar tabelas de preço, conforme a regra de negócio.

[Fact]
public void Teste_que_utiliza_dtos_de_resposta_de_preco()
{
    var precoMinimo = new PrecoResponseBuilder()
        .DoTipoMinimo()
        .Construir();
    var precoPrevisto = new PrecoResponseBuilder()
        .DoTipoPrevisto(codigoTabela: 1, inicioFaixa: 1, fimFaixa: 10)
        .Construir();
    var precoPraticado = new PrecoResponseBuilder()
        .DoTipoPraticado(codigoTabela: 1, inicioFaixa: 1, fimFaixa: 10)
        .Construir();

    //... alguma lógica de teste
}

Como pudemos ver, os builders podem ser extremamente benéficos para nossas suítes de testes e ajudam a proteger nossos testes de mudanças nos nossos sistemas, simplificando muito nosso dia-a-dia.

Outras opções para compartilhar:
comments powered by Disqus