Por que eu devo ler este artigo:Este artigo é útil por explicitar e analisar as diferentes formas de adotar testes automatizados. Para isso, serão analisados aspectos teóricos, como os conceitos relacionados aos diferentes tipos de testes, e práticos, como a automatização de tais conceitos utilizando técnicas e frameworks.

Além disso, o papel dos testes automatizados em times ágeis e em sistemas legados também será explorado. O objetivo com isso é fornecer uma base conceitual, que permita ao leitor aplicar testes automatizados alinhados à necessidade de forma eficaz.


Guia do artigo:

A automatização de testes é um tema de grande relevância quando se fala em qualidade de software e por isso, o uso desta disciplina deve ser considerado em todos os projetos de software. Com testes automatizados consegue-se entender melhor os problemas, já que o desenvolvedor, pela prática, valida sua hipótese considerando diferentes cenários.

Além disso, reduz-se o stress e aumenta-se a satisfação, pois com um bom conjunto – ou suíte – de testes, bugs são detectados mais cedo no ciclo de desenvolvimento e menos problemas chegam ao cliente, diminuindo com isso o custo na criação de novos produtos, visto que o código com testes automatizados é construído com mais cuidado, o que sugere menos bugs e, consequentemente, menos gastos com manutenção.

Consequentemente, reduz-se também o custo com evoluções no sistema, já que uma alteração que possa causar efeitos colaterais é rapidamente evidenciada pelos testes, permitindo identificar os pontos falhos de forma clara e objetiva, alcançando assim correções ágeis e entregas com menos erros. Constata-se, portanto, que a adoção de testes automatizados oferece ganhos em diversas etapas da construção de um sistema.

Contudo, sua aplicação eficaz é uma atividade longe do trivial. Não apenas pelo uso de ferramentas e frameworks, mas principalmente por causa do entendimento dos conceitos envolvidos. Sabe-se que existem diferentes tipos de testes automatizados, específicos para cada situação (testes unitários para testar unidade, testes de integração para testar componentes, testes de aceitação para testar funcionalidades), mas comumente observa-se que a distinção nem sempre é considerada. O problema com isso é que um mal entendimento dos conceitos relacionados pode levar à lentidão desnecessária na execução dos testes, dificuldades na otimização do processo de integração contínua, falhas na comunicação sobre os testes dentro do próprio time, entre outros.

A partir dessa contextualização, este artigo analisará os diferentes tipos de testes automatizados, considerando algumas técnicas e práticas.

Entre quadrantes e pirâmides

Idealmente, o desenvolvedor deveria garantir que todo código-fonte entregue possui testes automatizados que o validam. Essa proposta parte do princípio de que não se sabe se algo realmente funciona como esperado até que seja efetivamente testado.

Diante disso, nos cenários onde o desenvolvimento de software é estruturado de acordo com métodos ágeis e onde os times fazem uso de User Stories (vide BOX 1), espera-se que cada tarefa de codificação possua também um esforço para criação dos respectivos testes automatizados. Dessa forma, garante-se a construção de código com melhor qualidade.

Nota: User Stories

User Stories, ou Histórias de Usuário, são uma prática comum em times que fazem uso de métodos ágeis, como XP e Scrum. Elas podem ser encaradas como uma forma clara, direcionada, menos ambígua e mais voltada à construção de software do que a escrita tradicional de requisitos. A User Story define uma funcionalidade de forma abrangente, e sobre ela são criadas as tarefas necessárias para sua realização. Por exemplo, para uma funcionalidade de login de usuário, a técnica de User Story poderia apresentar alguns dos requisitos no seguinte formato:

Usuário efetua login no sistema com sucesso:

  • Dado um usuário com permissão
  • QUANDO o mesmo efetua login no sistema
  • ENTAO é redirecionado para a página principal

Usuário efetua login no sistema com falha:

  • Dado um usuário com permissão
  • QUANDO tenta efetuar login com credenciais inválidas
  • ENTAO recebe mensagem indicando erro na autenticação

E não consegue efetuar login.

A noção de que cada requisito de software precisa ter um teste associado aumenta consideravelmente a cobertura de código sendo testado. Contudo, também é importante identificar qual tipo de teste é mais adequado para cada situação, pois não se deve utilizar o mesmo para tudo. Para que se saiba que teste automatizado escrever, um passo fundamental é conhecer as opções existentes.

No livro Agile Testing: A Practical Guide for Testers and Agile Teams, as autoras Lisa Crisping e Janet Gregory apresentam um quadrante organizando os diferentes tipos de testes (vide Figura 1).

Quadrante de testes de Crispin e Gregory
Figura 1. Quadrante de testes de Crispin e Gregory

O enfoque deste quadrante é direcionado ao trabalho com times ágeis, mas não significa que os tipos dispostos são relevantes apenas a times com esta organização. Como pode-se verificar, cada setor da figura é numerado, de Q1 a Q4, de acordo com sua característica (e cada setor do quadrante possui um balão, que define a forma de realização dos testes).

O quadrante Q1, com o balão Automated, apresenta tipos de testes que podem ser feitos de forma automatizada, enquanto Q2, com o balão automated & manual, apresenta tipos que podem ser implementados tanto de forma manual quanto automatizada. Já o quadrante Q3 lida com testes que são feitos manualmente, e Q4 está relacionado a testes que devem ser feitos com ferramentas especializadas (tools).

Visto que este artigo trata especificamente de testes automatizados, focaremos nossa análise nos tipos apresentados nos quadrantes Q1 e Q2. Iniciemos, então, nossa análise, sobre os tipos de testes presentes em Q1, os testes unitários (Unit Tests) e os testes de componentes (ou Component Tests, também conhecidos como testes de integração).

Testes unitários

Testes unitários são aqueles que, como o nome indica, testam uma unidade. Esta afirmação, contudo, não é muito precisa, levantando algumas dúvidas. Afinal, o que é uma unidade? É uma classe? Um método? É uma tela?

Certamente não é uma tela, e também não se trata, necessariamente, de uma única classe.

No livro Continuous Integration: Improving Software Quality and Reducing Risk, os autores Duvall, Matyas e Glover afirmam que testes unitários validam o comportamento de pequenos elementos em um sistema, elementos esses que, na orientação a objetos, costumam ser chamados de métodos. Assim sendo, o teste unitário é aquele que testa os métodos de uma classe de produção (ver BOX 2).

Mas não é só isto. A definição de teste unitário deve considerar ainda o nível de acoplamento das dependências do código de produção.

Quando um código de produção está acoplado a recursos externos (como banco de dados, web services e disco rígido), o teste deixa de ser unitário e passa a ser de integração (analisado posteriormente).

Em suma, para um teste ser unitário, os métodos da classe sendo testada (e suas dependências) não podem ter relação com recursos externos.

BOX 2: Classe de produção e classe de testes

Uma classe de produção é aquela que é efetivamente testada pela classe de teste. Ela recebe esse nome porque é a classe que é entregue junto com a aplicação (diferentemente da classe de testes, que existe apenas durante o desenvolvimento e não entra no empacotamento do artefato JAR ou WAR a ser executado em produção).

A classe de produção também é chamada de classe sobre teste, ou classe sendo testada (CUD – Class Under Test). Uma classe de testes, por sua vez, tem a função exclusiva de testar uma CUD.

Em suma, um teste unitário testa uma unidade, e uma unidade é uma classe de produção que pode ou não possuir dependências. Caso as possua, tais dependências devem ser desacopladas de recursos externos.

Por exemplo, se uma classe precisa de um contêiner para injeção de dependência (como o Spring), ou se faz acesso direto a um banco de dados, ou implementa um cliente para consumo de um web service, ela depende de recursos externos e, segundo a definição, não pode ser testada de forma unitária.

Contudo, vale ressaltar que é possível isolar esta dependência, possibilitando que a classe seja testada de forma unitária. Para isso, algumas técnicas podem ser consideradas:

  • Refatoração: Extract Method/Class – Se uma classe ou método sobre teste possui dependência de recursos externos, pode-se realizar o Extract Method ou Extract Class.

Estes padrões, descritos no Livro Refactoring: Improving the Design of Existing Code, de Martin Fowler, sugerem formas de isolar o código para que seja mais facilmente testável.

Os padrões podem ser usados para separar o código que possui dependência de recursos externos do código que não possui. Assim, o que não possui esse tipo de dependência pode ser testado de forma unitária;

  • Mock - O conceito de mock (simular) também trata de isolar a dependência externa, mas de forma mais intrusiva. Para tanto, programaticamente, define-se como determinado código deve se comportar, simulando seu comportamento quando ele é chamado.

Por exemplo, se alguém deseja testar de forma unitária uma classe que depende de uma injeção de dependência em um atributo ProdutoService, é possível simular este atributo, de modo que quando o mesmo for referenciado, em vez de realizar seu comportamento padrão, realize um comportamento específico. Assim, o teste pode rodar sem a necessidade de um contêiner para injeção de dependência, como o Spring.

A escolha por uma técnica ou outra pauta-se numa decisão de design: deseja-se alterar o código de forma a tornar as dependências a recursos externos mais isoladas do código Java puro? Se a resposta for sim, a refatoração faz-se adequada.

Caso contrário, pode-se utilizar mock. Apenas uma ressalva ao uso de mock: seu ponto negativo é que ele expõe a implementação da classe sobre teste. Para simular um comportamento, é preciso saber como a classe sobre teste se comporta por dentro, e levar esta lógica para o teste.

Note que deste modo o teste está programando o comportamento interno da classe. O problema é que, quando a classe mudar internamente, o teste também precisará ser alterado. No entanto, isso não deveria ser necessário, afinal, o teste deveria validar o comportamento da classe sem saber como a mesma funciona por dentro, preservando o encapsulamento.

Uma ótima opção para elevar o nível dos testes unitários é adotar o TDD (Test Driven Development). Nesta técnica, muito utilizada principalmente em ambientes ágeis, a classe de testes é criada antes da classe de produção, de forma que os testes guiem o código a ser implementado, testado e posteriormente colocado em produção.

Além de oferecer alta cobertura aos testes (ao deixar para fazer os testes depois, podemos esquecer de implementá-lo ou ficar sem tempo para isso), essa abordagem também pode ser vista como uma técnica de design emergente, onde o design evolui de acordo com a construção do software, em vez de ser uma etapa anterior à construção.

Algo bem diferente do tradicional modelo Big Design Up Front, que sugere uma análise de todos os cenários do sistema antes que o mesmo seja construído. Mais detalhes sobre TDD podem ser encontrados no livro de Kent Beck, intitulado Test Drive Development: By Example.

Outro conceito importante a se considerar: os testes unitários são testes de caixa branca. Isto significa que eles testam o comportamento interno do sistema e, portanto, devem ser escritos por desenvolvedores.

Analistas de requisitos ou testadores podem realizar outros tipos de testes, mas não têm o perfil para escrever testes unitários. Lembre-se que os testes unitários são classes escritas em Java com o propósito de realizar testes especificamente sobre classes Java de produção. Em outro artigo, demonstraremos como utilizar os frameworks de testes unitários JUnit e Hamcrest.

Em suma, as principais vantagens dos testes unitários são: ser simples de construir; rápidos de rodar (quando o conceito aqui descrito é entendido e aplicado corretamente); fáceis de executar via integração contínua, o que permite que funcionem como testes de regressão, apontando rapidamente problemas de efeitos colaterais por alterações indevidas; e viabilizam a prática do TDD, o que possibilita um design claro e uma grande cobertura de testes.

Testes de Integração/Componente

Os testes de integração são caracterizados, segundo Duvall, Matyas e Glover, pela verificação de partes maiores do sistema que dependem de recursos externos, como banco de dados, sistemas de arquivos ou endpoints de rede, para citar alguns. Estes testes verificam se os componentes em análise realmente produzem o comportamento esperado.

Um teste de integração comum a muitas aplicações é aquele que valida as ações executadas contra um banco de dados. Considerando que o acesso a recursos externos sempre demanda mais tempo do que um acesso direto à memória (por questões diversas, como latência de rede, leitura de disco, dentre outras), é comum que os testes de integração demorem mais do que os unitários.

Esse tipo de teste também é mais difícil (e demorado) de implementar. O principal motivo para isso está relacionado ao estado das dependências externas, que deve ser preparado e garantido.

Um teste, por definição, precisa ser independente e determinístico, ou seja, ao ser executado múltiplas vezes, deve apresentar o mesmo resultado. Por exemplo, um teste que dependa de um banco de dados precisa que o banco de dados esteja sempre no mesmo estado consistente no qual o teste se baseia.

De outra forma, o teste falharia pelo motivo errado (não por identificar um problema na classe de produção, mas porque os dados esperados para a realização do teste não estavam consistentes). Neste cenário, é comum a necessidade de preparar o banco de dados antes da execução do teste e garantir que o mesmo volte ao estado original após a execução.

A maior dificuldade na implementação dos testes de integração encontra-se exatamente neste ponto: o uso adequado de recursos externos. Para demonstrar essa questão, imagine que desejamos testar uma funcionalidade de alteração de uma entidade que dependa de mais cinco (todas mapeadas em tabelas distintas).

Neste cenário, precisamos de seis tabelas populadas da forma correta para que possamos realizar o teste e ter a certeza de que a classe de produção funciona conforme o esperado. Agora, imagine que entre uma execução e outra um desenvolvedor da equipe altere uma dessas tabelas.

Com isso, o teste que até então estava funcionando, para de funcionar sem motivo aparente. O que ocorreu é que a mudança de outra pessoa provocou um efeito colateral, que levou o teste a falhar – apesar do código Java não ter sido alterado. A alteração da entidade continua a ocorrer corretamente, mas o teste falha mesmo assim. Isto é, o teste não falhou porque a alteração da entidade tem um bug, mas porque o pré-requisito para a realização do teste parou de ser atendido.

Para que este problema não aconteça, o teste automatizado precisa garantir o estado do banco de dados antes e após sua execução. O teste precisa inserir o dado do qual depende (pré-condição), realizar o teste e então remover os dados alterados (pós-condição). Este requisito de atrelar massa de dados ao teste pode ser realizado pelo uso de alguns frameworks, como o DBUnit ou mesmo o Spring-Test, com alguma configuração, conforme será demonstrado ainda neste artigo.

Martin Fowler analisa problemas relacionados a este de forma clara e sucinta em seu artigo Eradicating Non-Determinism in Tests, onde sugere que os testes deixam de ser confiáveis quando param de ser determinísticos, o que leva a serem abandonados posteriormente. Para ele, o não-determinismo surge por cinco causas principais, que precisam ser evitadas, a saber:

  1. Falta de isolamento - Trata da questão já exemplificada, onde o estado de um banco de dados (ou outros recursos externos) interfere na correta execução do teste;
  2. Comportamento assíncrono - A utilização de sleep em threads pode tanto tornar os testes lentos quanto interromper a execução do método por causa do timeout. Portanto, em cenários com processamento assíncrono, isto é, com threads executando em paralelo, use callbacks para garantir que o teste seja executado no momento correto;
  3. Serviços remotos - Em alguns casos é comum o uso de serviços remotos reais para testes de integração entre sistemas, já que nem sempre certos serviços estarão disponíveis para teste. No entanto, sistemas reais podem não prover as respostas determinísticas do teste, pois mudam frequentemente. Por exemplo, um serviço que retorna a temperatura de uma cidade pode retornar resultados diferentes a cada consulta. Desta forma, é preciso fazer uso de um Test Double (vide BOX 3), simulando o comportamento do serviço real;
  4. Tempo - Depender do relógio é algo claramente não-determinístico. Testes que dependem de tempo (hora corrente) não irão gerar resultados iguais. Portanto, não se deve depender de horário em cenários de teste;
  5. Vazamento de recursos - Quando os recursos são mal gerenciados, os testes podem passar a falhar por motivos errados, apresentando comportamento não-determinístico.

Falta de memória é um dos problemas mais comuns. Para lidar com vazamentos, uma solução comumente adotada é a implementação de um pool de recursos (um mecanismo para reaproveitar objetos, de forma que novos objetos não precisem ser criados constantemente, o que leva ao potencial vazamento). Deste modo, sempre utilize um pool de recursos em cenários relevantes.

BOX 3: Test Double

Gerard Meszaros, em seu livro xUnit Test Patterns, se refere ao termo Test Double como um conjunto de objetos que pode ser utilizado para substituir uma classe de produção ou um conjunto delas durante os testes.

Os Test Doubles são categorizados em tipos, a saber:

  • Dummy: São objetos que servem simplesmente para preencher parâmetros de métodos. Atendem aos cenários onde deseja-se chamar dado método, mas os objetos sendo passados por parâmetro são irrelevantes, não precisando ser corretamente construídos.

    Por exemplo, considere o cenário onde se quer chamar um método calcular(usuario), mas, por causa de um design ruim, o objeto usuario não é relevante para o cálculo. Neste caso, criamos um objeto usuario qualquer (Dummy), apenas para passá-lo por parâmetro, de forma a atender os objetivos do teste. Objetos dummy são passados por parâmetro, mas nunca são utilizados;

  • Fake: É uma implementação do código real, mas que não atende aos propósitos de produção, servindo apenas para teste. Por exemplo, considere um serviço que obtenha uma lista de todas as contas de um usuário.

    O serviço real vai até o banco de dados obter as contas. O serviço Fake, por sua vez, simplesmente traz uma lista pré-determinada de contas. Desta forma, realiza uma ação básica e simplificada, diferente da ação real, sofisticada, que é realizada pelo código de produção. Esta opção é útil principalmente para simular a ação de serviços de terceiros quando os mesmos não estão disponíveis para testes de forma determinística;

  • Stub: Provê retorno determinístico de chamadas a métodos. Para isso, retorna objetos que possuem sempre o mesmo valor, eliminando a necessidade de construir objetos que dependam de recursos externos, como bancos de dados e web services;
  • Spy: Também provê retorno determinístico, mas, diferentemente do Stub (que sempre retorna o mesmo valor), apresenta valores diferentes dependendo da forma pela qual foi chamado;
  • Mock: Esta opção permite que dado método em teste possa ser programado para, durante a execução do teste, realizar um comportamento diferente do original (simular um comportamento). Isso é útil, por exemplo, para ignorar chamadas a recursos externos quando se está testando um código que não faz uso de tais recursos, mas cujo acoplamento existe por questões de design da classe.

Com o uso de Test Double o desenvolvedor pode remover a dependência a recursos externos. Isto significa que o uso de Test Doubles permite que funcionalidades que até então deveriam ser avaliadas por testes de integração sejam avaliadas por testes unitários. Esta afirmação é especialmente relevante dentro do contexto da integração contínua, onde a execução de testes unitários e de integração precisa ser separada, por conta, principalmente, do tempo de execução.

Contudo, dúvidas podem surgir a partir dessa constatação: se estou testando dois módulos de um sistema, isto não é um teste de integração, independentemente de utilizar recursos externos ou não?

Para responder a esta questão é preciso considerar a arquitetura da aplicação: estes dois módulos são duas aplicações que dependem de um contêiner para executar, efetuam sua integração via web services ou via banco de dados? Caso positivo, certamente trata-se de um teste de integração, mas se a dependência entre dois módulos for puramente de biblioteca (como um módulo que referencia uma classe POJO de outro módulo), um teste unitário dá conta do recado.

E se um módulo A depende de um web service de um módulo B, mas o web service do módulo B é simulado por meio de um Test Double (um mock, por exemplo), observa-se que o módulo A passa a não depender mais do módulo B para o teste, pois a função do Test Double é justamente a de viabilizar este desacoplamento. Temos, então, um único módulo, que pode ser testado de forma unitária, sem depender do segundo.

Para que testes automatizados sejam confiáveis, precisam gerar o mesmo resultado independentemente do número de vezes que forem executados. Esse determinismo é mais fácil de atingir quando a aplicação é construída respeitando as boas práticas da orientação a objetos, como a alta coesão e o baixo acoplamento. Quando a aplicação não considera esses valores, a implementação de testes de qualquer tipo pode se tornar muito difícil. Em cenários onde existe código acoplado e há interesse no uso de testes automatizados que o validem, além do uso de Test Doubles, outra alternativa para simplificar os testes é proposta por Meszaros: o Humble Object Pattern. Este padrão sugere que um código altamente acoplado precisa ser desacoplado do seu ambiente através da criação de objetos menores, de forma a se tornar mais testável.

Por exemplo, se deseja-se testar um serviço que possui muitas dependências a outros serviços e muitos métodos que realizam cálculos diversos, sugere-se com este padrão que os métodos de cálculos sejam extraídos para classes à parte (o mesmo princípio de Refatoração:Extract Method/Class, descrito na seção de testes unitários), especializadas e que podem ser testadas de forma isolada e unitária.

Desta forma, reduz-se o acoplamento, o que apresenta duas vantagens:

  1. Menos código será testado como teste de integração e mais código será testado como teste unitário;
  2. Simplifica-se a escrita dos testes (testes de integração são mais difíceis e demorados de escrever).

Garantido o determinismo, os testes de integração apresentam-se como uma categoria muito útil na verificação do correto funcionamento do sistema. Quando planejados e implementados adequadamente, os testes entre diferentes componentes apresentam ganhos valiosos na identificação prematura de bugs no sistema.

Inclusive, observa-se que testes unitários e de integração se complementam neste sentido: enquanto o primeiro ajuda na detecção de bugs em aspectos pontuais de classes, o segundo identifica bugs nestas mesmas classes avaliando a execução das mesmas em grupo.

Ademais, ambos os testes são de caixa branca, o que significa que são construídos por meio de código Java, exigindo perfil de desenvolvedor. No próximo artigo, adotaremos o Spring Test como solução para criar os testes de integração.

Testes e critérios de aceitação

O quadrante Q2 também é conhecido como o quadrante dos testes de aceitação, e assim será referenciado neste artigo. Nele, os testes que podem ser automatizados são os funcionais e de histórias; os demais são tratados como manuais. Em nosso estudo, no entanto, optamos por analisar apenas os testes de histórias.

Como o leitor deve lembrar, as user stories já foram mencionadas, mas nos testes de aceitação ganham uma nova dimensão: podem ser vinculadas a testes automatizados, pelo uso da técnica de BDD (Behavior Driven Development, ou desenvolvimento voltado a comportamento).

Isso ocorre porque o BDD define um padrão de escrita de histórias que é pragmático o suficiente para ser automatizável. JBehave e Concordion são alguns dos frameworks que fazem uso direto do conceito de BDD para automatizar as histórias.

O modelo de escrita de histórias do BDD é o seguinte:

  • DADO [uma pré-condição] (opcional)
  • QUANDO [um problema ocorrer]
  • E [ outro problema ocorrer ] (opcional)
  • ENTAO [uma ação deve ser realizada]
  • E [ outra ação deve ser realizada ] (opcional)

Por exemplo, suponha que o cliente solicitou uma funcionalidade para cadastro de CPFs. Algumas histórias para este cenário podem ser escritas da seguinte forma:

User Story: Usuário cadastra CPF com sucesso

  • DADO um usuário editando seus dados pessoais
  • QUANDO o CPF informado é válido
  • ENTAO o CPF é devidamente atualizado
  • E o sistema informa que o CPF foi cadastrado com sucesso

User Story: Usuário informa CPF incorreto no cadastro

  • DADO um usuário editando seus dados pessoais
  • QUANDO o CPF informado é inválido
  • ENTAO o sistema informa que o CPF não pode ser cadastrado

Este é o formato utilizado no BDD. De forma simples, rápida, sucinta e não-ambígua, foram escritos requisitos que atendem à necessidade de entendimento tanto por parte do cliente quanto por parte dos desenvolvedores, sem o overhead conceitual típico dos documentos de muitas páginas.

Os detalhes não devem estar escritos nas histórias, pois elas servem para guiar o desenvolvimento dizendo o que deve ser feito em um nível suficientemente genérico. A partir disso, espera-se que o time ágil se organize para obter os detalhes complementares, o que é feito dependendo do contexto da empresa, utilizando meios relevantes ao ambiente e à cultura organizacional.

Ainda sobre a escrita de histórias, vale ressaltar que existem diferentes formas de fazê-la. Uma opção diferente e igualmente válida de escrever o mesmo requisito através de User Stories é apresentada a seguir:

User Story: Usuário cadastra CPF

  • O CPF pode ser alterado na edição de dados pessoais;
  • O CPF deve ser validado;
  • Quando o CPF é válido, o sistema deve informar que a atualização ocorreu com sucesso;
  • Quando o CPF é inválido, o sistema deve informar que a atualização não ocorreu.

Neste formato, a história é apresentada por meio de um conjunto de critérios de aceitação organizados em tópicos. Quando todos os tópicos são atendidos, então a história está cumprida e a funcionalidade pode ser entregue para o usuário. Embora possa ser considerada uma forma mais natural de apresentar requisitos ao cliente do que a primeira (do BDD), o formato do BDD possui a vantagem de suportar automatização mais naturalmente.

Mas qual o ganho de automatizar uma User Story, afinal? Por que isto é importante?

A automatização de uma User Story garante que o teste seja capaz de validar o requisito do cliente de forma focada e assertiva.

Quando um teste de aceitação falha, sua falha é relacionada ao requisito, e não a um detalhe de implementação, que é o que os testes unitários e de integração detectam.

Trata-se de uma forma diferente de validação. Ao mesmo tempo, o uso dessa prática aumenta as chances de o desenvolvedor programar exatamente aquilo que o cliente espera, pois reduz possíveis falhas de comunicação. O BDD é a técnica comumente empregada nestes casos.

Dan North, criador do BDD, em seu artigo Introducing BDD, explica como sua técnica evoluiu do TDD apoiada no DDD, consolidando-se como um tipo de representação de requisitos que pode ser automatizado com testes de aceitação.

Esse mesmo autor, em outro artigo (What's in a Story?, também de leitura obrigatória àqueles que desejam se aprofundar nesta abordagem), apresenta o modelo de histórias do BDD aqui citado.

Na parte prática deste artigo, a ser publicada na próxima edição, será apresentado como as histórias são vinculadas aos testes automatizados pelo uso dos conceitos de BDD e do framework Concordion.

Quando e quanto utilizar?

Uma dúvida natural ao considerar os diferentes tipos de teste é: quando devo usar cada teste e quanto de cada teste devo utilizar? Para responder a esta pergunta, considere inicialmente a pirâmide da Figura 2.

Pirâmide de automação de testes de Crispin e Gregory
Figura 2. Pirâmide de automação de testes de Crispin e Gregory

Em relação às proporções de escrita de testes automatizados, esta imagem sugere que deve-se construir mais testes unitários e de componentes, seguidos pelos testes de aceitação e, em menor escala, testes funcionais.

Isto porque os testes na base da pirâmide são mais simples e fáceis de implementar, e permitem que problemas sejam encontrados mais cedo durante o desenvolvimento.

Além disso, considerando as características de ambos, entende-se que os testes unitários devem ser feitos em maior escala, pois são mais simples de se criar e mais rápidos de executar. Em suma:

  1. Testes unitários devem ser utilizados quando se deseja testar objetos sem dependência com recursos externos (no caso de haver objetos com tais dependências, as mesmas podem ser simuladas por meio de Test Doubles);
  2. Testes de componentes ou integração devem ser utilizados quando se deseja testar conjuntos de objetos que possuem dependências com o ambiente, como a relação entre uma rotina e o banco de dados, ou entre uma rotina e um web service; e
  3. Testes de aceitação devem ser escritos quando se deseja validar se uma User Story está implementada corretamente.

Saiba que nenhum dos três tipos de teste exclui os demais, pois são complementares. E se a proporção aqui apresentada for seguida, a tendência é que se alcance uma suíte de testes confiável, pois esta cobrirá problemas em diferentes níveis, e eficaz, pois os ganhos observados na aplicação poderão justificar o tempo gasto na escrita dos testes.

Ao escolher o tipo de teste a implementar, também deve-se considerar o tempo investido para construir e executá-lo. Se houver pouco tempo para criar testes em um código muito acoplado, sugere-se aplicar o Humble Object Pattern como técnica de refatoração para isolar o código dependente de recursos externos daquele que pode ser executado sem tais dependências, e testar de forma unitária o que for possível.

Caso haja maior oportunidade para melhoria, sugere-se, além de aplicar Humble Object Pattern e criar os testes unitários, investir na preparação de um teste de integração. Por fim, se houver histórias e tempo, sugere-se escrever também os testes de aceitação.

Para melhor descrever a relação entre testes automatizados apresentada neste artigo, foi elaborada uma segunda pirâmide, derivada do trabalho de Crispin e Gregory, pelo autor deste artigo (vide Figura 3).

Pirâmide de testes simplificada para adequar-se aos conceitos deste artigo
Figura 3. Pirâmide de testes simplificada para adequar-se aos conceitos deste artigo

Observe que os testes de aceitação (posicionados no meio da pirâmide) são um tipo especial de teste de integração, com a diferença de que o primeiro tipo é atrelado a histórias, enquanto o segundo não. Já no topo da pirâmide, observa-se que testes funcionais podem ser automatizados como testes de aceitação, por meio de ferramentas como Selenium.

Nestes casos, o teste valida o sistema por meio do conceito de caixa preta, guiado por histórias. A validação funcional é feita pela navegação nas telas da aplicação, de forma que detalhes internos de implementação não são considerados.

Como visto até agora, diversas vantagens dos testes automatizados já foram apresentadas. Contudo, existem outras cuja menção faz-se importante. São elas:

  • Testes automatizados de regressão trazem mais confiança ao realizar alterações;
  • A automatização libera o tempo das pessoas para atuar em atividades menos repetitivas;
  • Testes automatizados são mais rápidos (e baratos);
  • Testes automatizados estão menos sujeitos a erros.

Automatização de testes em diferentes casos

Até aqui, os conceitos relacionados à automatização de testes apresentados foram analisados apenas na teoria. Neste tópico vamos começar a mudar essa perspectiva e analisar como os testes automatizados poderiam ser aplicados em dois cenários comuns do mundo real.

Testes automatizados em sistemas legados

Grande parte do trabalho de desenvolvimento reside na evolução de sistemas existentes, que muitas vezes não foram construídos com testes automatizados e nem mesmo consideraram as boas práticas da orientação a objetos. Nestes cenários, as seguintes práticas podem ser adotadas:

  • Sempre que for alterar um código sem testes, lembre-se do Humble Object Pattern. Extraia um trecho de código de dentro de um método acoplado para uma classe ou método isolado (IDEs como IntelliJ e Eclipse fazem isso automaticamente) e escreva o teste unitário para ele.

Vale ressaltar que no momento da extração do código você pode encontrar dificuldades por conta da dependência de atributos que recebem seu valor por injeção de dependência. Neste caso, considere a passagem de objetos por parâmetro para esta nova classe ou método sendo criado. Ainda na escrita de testes unitários, procure evitar o uso de mocks, o que é recomendado apenas em casos onde não é possível isolar o código;

  • Se houver tempo, analise se é possível preparar uma massa de dados para o teste. Caso positivo, implemente o teste de integração;
  • Caso a prática de User Stories exista e haja tempo disponível, implemente os testes de aceitação com BDD.

Testes automatizados em times ágeis

Times ágeis, atuando com métodos baseados em iterações (como Scrum), geralmente possuem autonomia para definir sua forma de trabalho e fazem uso de User Stories. Num cenário como esse, algumas ações podem ser realizadas visando alcançar um maior nível de qualidade no software em construção, a saber:

  • Insista para que as histórias sejam escritas no formato do BDD (ou que a tradução para o formato do BDD seja tarefa do time durante a iteração). Dessa forma torna-se mais fácil adotar testes de aceitação automatizados, já que tais testes dependem da existência de histórias no formato do BDD;
  • Insista para que a definição de pronto do time considere a criação de testes para cada história a ser entregue. Com isso, o time será capaz de planejar e estimar a escrita de testes durante a iteração.

Os testes unitários devem ser adotados sempre que possível, e os testes de integração devem ser utilizados para testar as dependências mais importantes. Já os testes de aceitação vinculados às histórias devem ser criados com maior atenção aos cenários mais importantes para o cliente, de modo a aplicar o esforço do teste de forma eficaz.

Testes automatizados e a integração contínua

Uma prática comum em empresas que desenvolvem software é o uso de ferramentas para Integração Contínua. Estas ferramentas automatizam a geração de builds e a execução de testes automatizados em servidores específicos, integrando o código constantemente.

A integração contínua é especialmente útil em projetos com muitos desenvolvedores, viabilizando um feedback rápido a todos os envolvidos sobre a saúde do código em construção. Neste ambiente, a execução dos testes unitários, de integração e de aceitação pode ser separada por motivos diversos, a saber:

  • Como testes unitários são mais rápidos, podem ser executados a cada build. Já os testes de integração e execução são mais lentos, podendo ser agendados para rodar uma vez ao dia;
  • O servidor de integração contínua pode não ter acesso aos recursos externos que os testes de integração dependem. Com o intuito de evitar erros identificados pelo fato do ambiente não estar preparado para o teste, entre outros motivos, a ferramenta pode “desativar” os testes de integração/aceitação.

A análise apresentada neste artigo foi idealizada com o intuito de aprimorar a prática de desenvolvimento de software nas empresas. Diretamente relacionada a ela, sabe-se que o entendimento dos diferentes tipos de teste é de grande utilidade para as tarefas do dia a dia, onde o tempo é escasso, mas a qualidade é fundamental.

Essa distinção permite elaborar uma estratégia que possibilita montar uma suíte de testes adequada às necessidades do projeto, assim como pode ajudar àqueles que trabalham com desenvolvimento ágil a automatizar suas histórias e àqueles que lidam com integração contínua e visam a redução da lentidão de processos de build.

No próximo artigo, esta análise será apresentada na prática, por meio da criação de um projeto em Java que utilizará os três tipos de teste abordados neste artigo: unitário, de integração e de aceitação.


Confira também