Os testes unitários nos possibilitam exercitar os métodos, classes e componentes que compõem o nosso sistema e verificar que se comportam da maneira esperada. Através de uma suite de testes eficiente e de execução rápida, temos uma camada de segurança que nos permite refatorar, acrescentar ou alterar componentes sem que sejam introduzidas falhas em outros pontos do sistema. Adicionalmente, a sua presença serve como uma documentação do funcionamento das partes que compõem a aplicação.
A ideia em si de exercitar e testar os comportamentos do código sendo escrito não é algo novo, porém, a sua consolidação no processo de desenvolvimento de software ocorreu com a criação dos chamados frameworks xUnit. Originalmente concebidos para a linguagem de programação Smalltalk por Kent Beck, um dos idealizadores do Extreme Programming, estas ferramentas permitem a execução de partes isoladas do código da aplicação a fim de verificar se estão implementadas corretamente.
O surgimento do JUnit, uma adaptação do framework xUnit para o Java, foi importante porque a comunidade desta linguagem, numerosa e ativa, promoveu a discussão aberta e frequente de práticas e técnicas a serem aplicadas nos testes, aperfeiçoando a qualidade de suas implementações.
Destas discussões surgiu a percepção de que os testes unitários, apesar de não fazerem parte do código utilizado pelo usuário final, devem receber os mesmos cuidados que temos com o desenvolvimento de software em geral, como reuso de código e nomenclatura adequada de métodos, classes e variáveis.
Outra inovação originada na comunidade é a de que os testes unitários podem ser utilizados para o design do código da aplicação. Esta prática, conhecida como Test Driven Development, ou simplesmente TDD, permite que os testes nos ajudem a definir como iremos modelar as classes que compõem o sistema, como elas interagem entre si, e quais as assinaturas de seus métodos. A sua utilização no desenvolvimento de software trouxe impactos significativos em como abordamos o design de classes e, por isto, a sua análise e estudo são importantes. Com base nisto, este artigo irá introduzi-lo e também apresentar técnicas que podem ser aplicadas nos testes unitários, tornando-os muito mais legíveis e de fácil manutenção. Além disto, iremos explicar o funcionamento dos frameworks de mocks e stubs, que podem ser empregados para facilitar os testes de classes que têm muitas dependências.
Testando dependências externas através de Mocks e Stubs
Quando desenvolvemos uma aplicação, eventualmente criamos e/ou utilizamos componentes responsáveis por tarefas específicas ou acessamos sistemas de terceiros que, em conjunto, proveem uma funcionalidade desejada. Para testar esta funcionalidade, assim como validá-la, às vezes é necessário integrar estas partes, porém isso nem sempre é algo fácil de conseguir.
Uma abordagem inicial seria a instanciação manual e configuração destas partes, sendo que, para isto, precisaríamos fazer a configuração de todas as dependências necessárias, como conexões com o banco de dados, interações com sistemas externos e outras instâncias de classes. Além da dificuldade em realizar estes passos, também lidamos com a possibilidade destes componentes demorarem muito para responder, aumentando assim o tempo de execução dos testes, ou falharem. Outro problema está em definir o comportamento exato destas dependências como, por exemplo, quando precisamos verificar um cenário em que o web service de uma empresa terceira retorna um erro HTTP 500. Estas características de imprecisão e incerteza são indesejáveis para uma suite de testes unitários.
Precisamos então de outra forma de testar estas integrações, e uma das soluções é simulá-las através de objetos falsos ou, como são mais conhecidos, mocks e stubs. Estes nos disponibilizam formas de, programaticamente, controlar o retorno que gostaríamos de ter de um componente que faz parte de nossa aplicação, como quando devem lançar um erro ou retornar um código de erro. Na linguagem Java, os frameworks mais utilizados desta categoria são o Mockito e o EasyMock.
Podemos observar nas Listagens 1 e 2 exemplos de testes empregando o EasyMock e o Mockito, respectivamente. Nele, descrevemos um sistema bancário simples, representado pela classe ServicoDePagamentos e seu método transferirDe(), em que o usuarioOrigem deseja transferir um valor monetário, simbolizado pela variável valorASerTransferido, para o usuarioDestino, sendo necessário para isto verificar se há saldo suficiente para realizar a operação.
Listagem 1. Usando o EasyMock para simular o comportamento de um sistema terceiro.
@Before
public void init(){
servicoDePagamentos = new ServicoDePagamentos();
// criando o mock de um componente, o repositorioDePagamentos
repositorioDePagamentos = createMock(RepositorioPagamentos.class);
//setando o mock na instância de ServicoDePagamentos
servicoDePagamentos.setRepositorioDePagamentos
(repositorioDePagamentos);
}
@Test
public void testTransferenciaComSucesso() throws Exception{
Usuario usuarioOrigem = new Usuario("aaa@bbb.com");
Usuario usuarioDestino = new Usuario("bbb@ccc.com");
BigDecimal valorASerTransferido = new BigDecimal("90");
// Informando ao framework de mock o comportamento do
// repositorioDePagamentos
expect(repositorioDePagamentos.procurarSaldo
(usuarioOrigem)).andReturn(
valorASerTransferido.add(BigDecimal.ONE));
repositorioDePagamentos.adicionarSaldo(usuarioOrigem,
valorASerTransferido.negate());
expectLastCall().once();
repositorioDePagamentos.adicionarSaldo(usuarioDestino,
valorASerTransferido);
expectLastCall().once();
// informando ao EasyMock que já configuramos todo o comportamento
// esperado na instância do mock;
replay(repositorioDePagamentos);
servicoDePagamentos.transferirDe(contaDeOrigem, contaDeDestino,
valorASerTransferido);
// verifica se todos os comportamentos configurados foram invocados
verify(repositorioDePagamentos);
}
Listagem 2. Usando o Mockito para simular o comportamento de um sistema terceiro.
@Before
public void init(){
servicoDePagamentos = new ServicoDePagamentos();
//criando o mock de um componente, o repositorioDePagamentos
repositorioDePagamentos = mock(RepositorioPagamentos.class);
//setando o mock na instância de ServicoDePagamentos
servicoDePagamentos.setRepositorioDePagamentos
(repositorioDePagamentos);
}
@Test
public void testTransferenciaComSucesso() throws Exception{
Usuario usuarioOrigem = new Usuario("aaa@bbb.com");
Usuario usuarioDestino = new Usuario("bbb@ccc.com");
BigDecimal valorASerTransferido = new BigDecimal("90");
// informando ao framework de mock o comportamento do
// repositorioDePagamentos
when(repositorioDePagamentos.procurarSaldo
(usuarioOrigem)).thenReturn(
valorASerTransferido.add(BigDecimal.ONE));
servicoDePagamentos.transferirDe(usuarioOrigem, usuarioDestino,
valorASerTransferido);
//verifica se os saldos foram corretamente transferidos
verify(repositorioDePagamentos).adicionarSaldo(usuarioOrigem,
valorASerTransferido.negate());
verify(repositorioDePagamentos).adicionarSaldo(usuarioDestino,
valorASerTransferido);
}
Analisando os exemplos, em ambos estamos criando um mock e armazenando-o na variável repositorioDePagamentos (observe o método init()). ...