Deixando seus testes mais legíveis e robustos

É muito comum, ao iniciar com a escrita de testes automatizados, não tomarmos certas precauções, fazendo com que a leitura e a manutenção dos mesmos se tornem um pesadelo. Acabamos vendo testes falhando (sequer compilando) devido a modificações na base de código de produção que não tem nenhuma relação com a regra coberta pelo teste quebrado. Isso acaba deixando a manutenção dos testes extremamente chata e trabalhosa.

Neste artigo, trago algumas dicas úteis para que possamos construir e manter uma suíte de testes de forma mais “saudável”. Embora o foco esteja em testes de unidade, a ideia central pode ser usada em testes integrados também.

Vamos lá!

POR QUE CUIDAR DA BASE DE CÓDIGO DE TESTE

Código de teste é código como o de produção. Ele também deverá ser mantido e evoluído ao longo do tempo.

Uma suíte de testes mal-escrita irá mais te atrapalhar do que ajudar:
– Você acabará corrigindo diversas vezes testes que passam a falhar repentinamente.
– Você terá muita dificuldade de entender e, portanto, manter a base de testes.

Para isso, entram em cena o famoso refactoring e as boas práticas para manter os testes robustos e compreensíveis.

CARACTERÍSTICAS DE UM BOM TESTE [QUANTO À LEGIBILIDADE]

Testes legíveis são simples, o que significa que eles devem:

– Deixar bem claras suas fases (arrange/act/assert)
– Evitar código duplicado (DRY – Don’t Repeat Yourself)
– Ser pequenos (sim, isso é relativo mas é bom ligar o sinal de alerta para testes com 20, 30 linhas de código)
– Usar frases descritivas e com significado (DAMP – Descriptive and Meaningful Phrases)
– Possuir complexidade ciclomática igual a 1, o que quer dizer que o teste deve seguir um único caminho, passando por suas 3 fases, sem possuir condicionais e iteradores.

Seguindo todos os pontos acima, podemos dizer que os testes formam uma DSL (Domain-Specific Language) que descreve o comportamento do código de produção e, mais que isso, podemos dizer que nossa suíte de testes é confiável.

DAMP x DRY

Sabemos que duplicação de código é, desde o início dos tempos, a raiz de todo o mal no desenvolvimento de software e devemos combatê-la o tempo todo.

Será que isso é verdade para um código de teste? Respondendo: NÃO.

Repetição é algo inerente aos testes, uma vez que temos, em vários casos, mais de um teste para o mesmo método, por exemplo. Isso não significa que devemos jogar o DRY no lixo mas que, nos testes, não devemos levá-lo em consideração de forma isolada.

Considerem o exemplo abaixo:

[TestFixture]
public class ServicoTestes
{
    [Test]
    public void Teste()
    {
        // criando o SUT
        var sut = CriarServico();
        // ..........
    }

    [Test]
    public void Outro_teste()
    {
        // criando o SUT
        var sut = CriarServico();
        // ..........
    }

    [Test]
    public void Teste_com_sut_criada_de_outra_forma()
    {
        var mock = new Mock<IDependencia>();
        var sut = CriarServicoCom(mock.Object);
        // .........
    }

    private Servico CriarServico()
    {
        return new Servico(dummy);
    }

    private Servico CriarServicoCom(IDependencia dependencia)
    {
        return new Servico(dependencia);
    }
}

Imaginem que essa classe tenha ficado dessa forma em uma tentativa de refatoração. A ideia inicial foi encapsular a criação do serviço que é testado em um helper “CriarServico()” e usá-lo nos testes ao invés de instanciar diretamente o serviço.

No entanto, a medida que outros testes foram surgindo, fez-se necessária a criação do Servico passando explicitamente a dependência, o que resultou no segundo helper.

Embora os testes tenham ficado mais limpos, não resolvemos o problema:

– A classe de teste ainda continua acoplada ao construtor do serviço e seremos obrigados a corrigir os dois helpers sempre que os parâmetros do construtor forem alterados.
– A classe de teste perdeu coesão pois temos diferentes helpers sendo usados em diferentes métodos de teste. (Outro exemplo de falta de coesão seria um grupo de fields da classe de teste sendo usado em alguns métodos e outro grupo sendo usado em outros métodos de teste.)
– A manutenção da classe fica comprometida a medida que novos cenários (e novos parâmetros para o construtor) surjam, forçando a criação de mais helper methods com combinações diferentes de parâmetros!

MELHORANDO  O “ARRANGE” DOS TESTES

O problema descrito acima ocorre na fase de preparação do cenário do teste (“arrange”, “fixture setup” ou ainda  o “given” na linguagem do BDD), onde é o momento em que criamos o objeto que será testado (o SUT – System Under Test) e os demais objetos usados na criação do SUT ou como parâmetros do método testado.

Veremos como resolver isso, mantendo o DRY, mas sem esquecer do DAMP.

1. Test Data Builders

Uma prática comum e bastante útil é o uso de builders para a construção de dados de teste. Já falei sobre eles neste artigo <aqui>.

A ideia principal é evitar que as classes de teste dependam explicitamente dos construtores dos objetos. Esta dependência nos obriga a ficar corrigindo testes que deixam de compilar devido a alterações nos construtores das classes, o que costuma acontecer bastante, principalmente no início dos projetos. (Leia o artigo acima para ver um exemplo de implementação de um builder com interface fluente.)

2. SUT Builders

Seguindo a mesma ideia, podemos usar builders para criar a própria SUT, afinal não estamos interessados no construtor e sim no método que será testado (a menos, é claro, que estejamos testando o construtor!).

[Test]
public void Teste()
{
    // arrange
    var produto = ProdutoBuilder.Novo().Build();
    var carrinho = CarrinhoBuilder.Novo().Build();

    // act
    carrinho.Adicionar(produto, 2);

    // assert
}

No exemplo acima, usamos builders tanto para dados de teste (o produto) quanto para o SUT (o carrinho).

3. Evitando repetição

Com o uso de builders, podemos atender o DRY evitando a repetição de dados usados para a criação dos objetos. Em outras palavras, informamos somente o que é necessário para o teste específico. Exemplos:

[Test]
public void Teste()
{
    // .......
    var usuario = UsuarioBuilder.Novo()
                                .ComLogin("user01")
                                .ComSenha("senha123")
                                .Build();
    // .......
}

[Test]
public void Teste()
{
    // .......
    var usuario = UsuarioBuilder.Novo().Build();
    // .......
}

Notem que no primeiro teste, foi exigida a criação de um usuário com login e senha específicos. Já no segundo teste, essas informações são desnecessárias e, portanto, não precisam ser informadas. (Nota: mesmo que login e senha sejam informações obrigatórias para a criação do objeto, o builder pode se encarregar de “setar” um valor default para eles.)

4. Criando uma linguagem de mais alto nível

Podemos usar os builders para criar uma linguagem de alto nível para os testes, tornando-os bem mais expressivos (DAMP). Vejamos alguns exemplos:

[Test]
public void Teste()
{
    // .......
    var carrinho = CarrinhoBuilder.Novo().ComItemEmPromocao().Build();
    // .......
}

[Test]
public void Teste()
{
    // .......
    var cliente = ClienteBuilder.Novo()
                                .ComEnderecosDeEntregaECobrancaIguais()
                                .Build();
    // .......
}

No último exemplo de builder, a implementação poderia ficar assim:

public class ClienteBuilder
{
    // outros métodos

    // notem que o método faz uso de outros métodos do builder,
    // além de outro builder (Endereco) para facilitar a criação
    // de um cliente com endereços iguais
    public ClienteBuilder ComEnderecosDeEntregaECobrancaIguais()
    {
        var endereco = EnderecoBuilder.Novo().Build();
        return ComEnderecoDeCobranca(endereco)
              .ComEnderecoDeEntrega(endereco);
    }

    public ClienteBuilder ComEnderecoDeCobranca(Endereco endereco)
    {
        _enderecoDeCobranca = endereco;
        return this;
    }

    public ClienteBuilder ComEnderecoDeEntrega(Endereco endereco)
    {
        _enderecoDeEntrega = endereco;
        return this;
    }
}

5. Escondendo poluição de stubs nos builders

Podemos dar um passo além e usarmos os builders para esconder a parte feia criada nos testes quando estamos configurando stubs.

Vamos usar como exemplo um cenário onde iremos testar que um cliente não pode ser criado caso já exista um cliente de mesmo CPF.

Uma versão inicial do teste ficaria assim:

[Test]
public void Nao_deve_criar_um_cliente_com_cpf_ja_existente()
{
    var cpf = "11111111111";
    var nome = "Jose da Silva";
    var clienteExistente = new Cliente(cpf, nome);
    var clienteRepositorioStub = new Mock<IClienteRepositorio>();
    clienteRepositorioStub.Setup(stub => stub.ObterPor(cpf)).Returns(clienteExistente);
    var clienteFactory = new ClienteFactory(clienteRepositorioStub.Object);

    TestDelegate criarNovoCliente = () => clienteFactory.CriarCom(cpf, nome);

    Assert.Throws<Exception>(criarNovoCliente);
}

Percebam que, antes de chegarmos ao método que será testado (“CriarCom”, de ClienteFactory), é preciso instanciar um cliente, um stub para o repositório, configurar o stub para retornar o cliente instanciado ao chamar o método “ObterPor” e, por fim, instanciar a própria SUT (ClienteFactory) passando o stub como dependência. Muita coisa, não? Além de deixar o teste “sujo” com a criação e configuração  do stub.

Vejamos agora uma versão refatorada, onde usamos um builder para ClienteFactory:

[Test]
public void Nao_deve_criar_um_cliente_com_cpf_ja_existente()
 {
    var cpf = "11111111111";
    var clienteFactory = ClienteFactoryBuilder.Novo()
                                              .ComClienteExistente(cpf)
                                              .Build();

    TestDelegate criarNovoCliente = () => clienteFactory.CriarCom(cpf, "Jose da Silva");

    Assert.Throws<Exception>(criarNovoCliente);
}

Toda a poluição do stub fica escondida no método “ComClienteExistente” do builder ClienteFactoryBuilder (*):

// demais métodos de ClienteFactoryBuilder

public ClienteFactoryBuilder ComClienteExistente(string cpf)
{
    // usando outro builder para criar o cliente existente sem usar o construtor
    var clienteExistente = ClienteBuilder.Novo().ComCpf(cpf).Build();
    var clienteRepositorioStub = new Mock<IClienteRepositorio>();
    clienteRepositorioStub.Setup(stub => stub.ObterPor(cpf)).Returns(clienteExistente);
    _clienteRepositorio = clienteRepositorioStub.Object;
    return this;
}

(*) eu sei, uma classe terminando com “FactoryBuilder” no nome parece bem redundante, para não dizer bizarro, mas neste caso é porque temos um “builder” para a “factory”. Podemos deixar para um próximo post essa salada de termos, que também aparecem ora em português, ora em inglês.

E ainda:

– Podemos criar dummies para as dependências que não serão utilizadas pelo método testado (valores não-nulos criados por default no builder apenas para o teste não “explodir”)

– Podemos passar mocks para o builder:

var clienteFactory = ClienteFactoryBuilder.Novo()
                     .ComNotificador(mockNotificador.Object)
                     .Build();
clienteFactory.Criar(/*parametros*/);

// asserção do teste usando o mock object
mockNotificador.Verify(mock => mock.Notificar());

6. Automação

Se você acha custoso implementar um builder para cada classe de sua aplicação, você pode considerar o uso de uma biblioteca pronta para esta finalidade.

Como não encontrei uma simples o suficiente para este fim – e também como aprendizado – resolvi implementar a minha: FluentBuilder. Dá um confere e veja o que achou. Lembrando que ainda está em beta!

Claro que com o uso “puro” de uma biblioteca, você perde o benefício da criação de uma DSL para sua suíte de testes. Neste caso, você pode querer intercalar o uso da lib e a implementação “do zero” ou ainda estender a lib escolhida.

MELHORANDO AS ASSERÇÕES DOS TESTES

Como dito antes, as ideias apresentadas acima são aplicáveis nas fases de arrange dos testes, onde precisamos construir o SUT e suas dependências.

No entanto, existem técnicas que podem ser aplicadas na fase final dos testes (assert), tais como as mostradas no artigo “Comparando objetos semanticamente”, publicado há alguns meses. Recomendo a leitura.

CONCLUINDO

Neste artigo, mostrei algumas estratégias que facilitam a manutenção de sua suíte de testes, melhorando as fases de “arrange” e “assert” dos testes. Embora focado em testes de unidade, podemos aproveitar ideias para testes de integração (no caso dos builders, por exemplo, podemos usar uma dependência concreta no mesmo ao invés de trabalharmos com stubs/mocks).

Além do que foi apresentado, existem outras técnicas que vocês podem conferir no recomendado treinamento da Pluralsight “Advanced Unit Testing”.

No entanto, acredito que o conteúdo apresentado no artigo seja bastante útil (e talvez o suficiente) para que se possa manter testes de qualidade para sua aplicação.

Então, preparados para escrever testes de forma profissional? Só começar!

Até a próxima!

Anúncios

2 comentários em “Deixando seus testes mais legíveis e robustos

Participe! Vamos trocar uma ideia sobre desenvolvimento de software!

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s