Testes de Aceitação e suas boas práticas

Testar aplicações web não é lá tão simples. A maioria de nós apela para testes de unidade. Afinal, eles são fáceis e rápidos de serem escritos, e quando o sistema está bem modelado, eles te dão grande segurança no momento de alterar qualquer regra de negócio. Mas na prática, sabemos que algumas partes do sistema merecem um teste mais real, mais próximo do que o usuário faz. Precisamos garantir que toda a requisição e resposta, passando por todas as integrações do sistema, como banco de dados, serviços web e etc, funcionam.

Para escrever esses testes, muitas vezes chamados de testes de sistema ou testes funcionais, fazemos uso de Selenium. A API do Selenium evoluiu bastante ao longo do tempo, e o framework é hoje muito maduro. Com poucas linhas de código, você consegue abrir um browser e fazê-lo navegar na sua página.

Um teste de sistema é parecido com um teste de unidade. Você deve montar o cenário que quer testar, executar a ação, e conferir se ela comportou-se do jeito esperado. Veja, por exemplo, o teste abaixo, cuja ideia é verificar que os cursos que o aluno comprou realmente aparecem no dashboard do Alura:

@Test
public void deveVerCursosDoAluno() {
  WebDriver driver = new FirefoxDriver();
  
  driver.get("http://www.alura.com.br/dashboard");

  List<WebElement> cursos = driver.findElements(By.cssClass("cursos"));

  Assert.assertEquals(2, cursos.size());
  Assert.assertTrue(cursos.get(0).getText().contains("TDD"));
  Assert.assertEquals(cursos.get(1).getText().contains("Java"));
}

Mas, para que esse teste funcione, precisamos fazer muitas coisas antes: o usuário precisa estar logado, o usuário precisa ter somente os cursos de TDD e Java liberados.

Estar logado é um problema. Precisamos fazer isso antes de qualquer operação, afinal todas elas precisam do login antes. Ter o curso de TDD e Java liberado também é um problema, porque no teste de sistema, os dados ficam salvos no banco, então precisamos de uma maneira de limpar os dados. Veja só como ficaria nosso teste, em pseudo-código:

@Test
public void deveVerCursosDoAluno() {
  WebDriver driver = new FirefoxDriver();

  // limpa o banco inteiro
  // insere o usuario "mauricio" no banco

  driver.get("http://www.alura.com.br/login");
  driver.findElement(By.name("login")).sendKeys("Mauricio");
  driver.findElement(By.name("senha")).sendKeys("Aniche");
  driver.findElement(By.id("loginBtn")).submit();
  
  // espera o login funcionar

  driver.get("http://www.alura.com.br/dashboard");

  // insere matricula de TDD e Java para o aluno

  List<WebElement> cursos = driver.findElements(By.cssClass("cursos"));

  Assert.assertEquals(2, cursos.size());
  Assert.assertTrue(cursos.get(0).getText().contains("TDD"));
  Assert.assertEquals(cursos.get(1).getText().contains("Java"));
}

Veja que deixamos um monte de comentário no código, pois gastaríamos ainda mais linhas de código para fazer tudo aquilo. Conseguiu perceber como testes de aceitação podem ficar complicados? Pensar em como escrever esse tipo de teste com qualidade e facilidade de manutenção é então fundamental.

Nossa experiência acabou nos mostrando algumas boas práticas. Entre elas:

  • Tenha uma API para criação de cenários. Montar cenários é a parte mais chata de todo o teste. Tenha alguma API, disponível via serviço web, por exemplo, na qual seus testes conseguem criar e manipular esses dados de maneira fácil, sem precisar navegar pela interface para criar tudo (às vezes seu cenário é muito complexo, e ir tela a tela, criando tudo é demorado).
  • Tenha seu banco de dados sempre em um estado inicial. Dependência entre testes é complicado. Ter dados sujos no banco antes da execução do teste pode fazer com que seu teste falhe mesmo que a funcionalidade esteja funcionando. Para isso, antes de cada teste, resete seu banco para um estado inicial. Isso pode ser feito pela API de cenários, por exemplo.
  • Encontre elementos no HTML por meio de algum identificador estável. Elementos numa página HTML costumam mudar com frequência. Sempre que você for capturar algum elemento, opte pelo que seja mais estável (ou seja, tenda a mudar menos). IDs e Names em particular costumam mudar pouco. Já classes CSS e XPath mudam com mais frequência.
  • Use PageObjects. Faça uso de PageObjects em todas as páginas do seu sistema. Encapsular o funcionamento de cada página em uma classe facilita sua manutenção. Afinal, você mudará em apenas um lugar se a funcionalidade mudar.
  • Conheça só os detalhes das páginas que você realmente precisa. Às vezes, para se testar uma funcionalidade, precisamos navegar por várias outras. Imagine que para testar B, você precisa passar antes por A. Se você estiver escrevendo o teste A, seja detalhista. Vá etapa por etapa. Agora, se estiver escrevendo o teste de B, passe por A o mais rápido que puder, e faça seu código depender da menor quantidade de detalhes possível. Assim, se A mudar, você precisará corrigir apenas os testes de A, e nem vai mexer nos testes de B.
  • Nunca use o conteúdo completo do HTML da página. Nossos asserts são geralmente feitos em cima do conteúdo de elementos HTML que apareceram na página. Quando for fazer o assert, o faça no elemento específico que a mensagem apareceu. Evite pegar o HTML inteiro e verificar se o conteúdo existe lá. A chance do seu teste passar, mesmo que a funcionalidade não funcione, é maior.

Nós escrevemos sobre todos esses padrões de maneira muito mais profunda e detalhada. Você pode a versão estendida desse conjunto de boas práticas, que nós escrevemos em formato de padrão, e apresentamos no PLoP 2014, a maior conferência mundial sobre padrões na área de software. Discutimos muito sobre qualidade de código e boas práticas na escrita de testes automatizados isso na nossa formação de testes no Alura.

E você? O que faz para garantir que seus códigos de teste de sistema fiquem fáceis de serem mantidos?

13 Comentários

  1. Rodrigo Ferreira 20/01/2015 at 10:50 #

    Ótimas dicas Aniche!
    É sempre um desafio escrever testes de aceitação com qualidade e facilidade de manutenção.

    Acho que para esse tipo de teste que é bem chato de escrever/manter, e bem fácil de quebrar com alterações na view, o ideal é ter poucos testes.
    Acho que aqui entram os testes apenas das principais funcionalidades do sistema, e também das problemáticas/críticas.

    O que acha?

  2. Flávio Almeida 20/01/2015 at 11:05 #

    Olá Maurício,

    Não é incomum encontrar testes de aceitação difícil de se ler. Tanto isso é verdade que ensino no meu livro (http://www.casadocodigo.com.br/products/livro-mean) a criar testes de aceitação utilizando Protractor (uma camada sob o Webdriver) e aplicar o pattern PageObjects. A diferença da legibilidade é grotesca.

    O que me deixa mais contente é que aplico essas boas práticas que você citou!

    Parabéns pelo post.

  3. Henrique Luz 20/01/2015 at 14:08 #

    E aí Aniche,

    Se me permite gostaria de dar uma pequena contribuição:
    Evitar usar delays com timeout fixo. Ao invés disso esperar por algum elemento ficar visível, sumir da tela ou esperar o término de uma requisição Ajax.

    O post está ótimo!

    Abraço,

  4. Frederico Maia 21/01/2015 at 10:32 #

    Show de bola! Usamos todas estas práticas aqui em nosso projeto. No início é complicado criar toda essa infraestrutura e definir os padrões, mas depois fica bem fácil implementar os testes.
    Como temos bastante testes de aceitação com Cucumber, muitos testes de integração e alguns unitários, optamos por fazer testes de UI bem simples. Geralmente testando a funcionalidade com apenas um caso de sucesso e um de erro.

  5. Raphael Lacerda 21/01/2015 at 13:55 #

    Galera, lembrando que tem um artigo de PageObjects aqui na Caelum

    http://blog.caelum.com.br/organizacao-de-testes-de-aceitacao-com-pageobjects/

    “…Tenha seu banco de dados sempre em um estado inicial…”

    Isso é bem complicado! Bem mesmo!! Localmente, ok, mas no desenvolvimento, em um ambiente compartilhado, é complexo.

    O que eu acabo fazendo é, se eu precisar testar que o curso de Java estará lá, o teste vai passar pelo cenário de cadastro até o de consulta do curso.

    E sobre os padrões, eu já até tinha discutido com vc sobre isso.

    Nos testes não é uma boa ter dependência entre eles, mas eu abro uma exceção para os testes de aceitação.

    Em uma classe tenho 4 testes por exemplos.

    O teste 1 faz algo e deixa a tela em um determinado estado.
    O teste 2 precisa de estar no estado que o teste 1 deixou e deixa em outro estado

    e assim por diante

    Os testes vão andar mais rápido e vc não vai precisar abrir e fechar o navegador para cada teste da classe.

    O chato é fazer isso no JUnit, pq ele não garante a ordem que os testes serão executados

    Com exceção, do 4.11, que tem o http://junit.czweb.org/apidocs/org/junit/FixMethodOrder.html

    Mas acho que nesses casos o TestNG faz mais sentido.

    No mais, Aniche on fire no blog!

  6. Rafael Ponte 21/01/2015 at 16:24 #

    Excelente artigo, Aniche!

    Escrever testes de aceitação é muito difícil e bem propício a erros, falsos-positivos e muitas outras dores de cabeça.

    Concordo com o Lacerda, nem sempre resetar o banco é uma boa idéia, as vezes manter a dependência dos testes é uma boa abordagem, o que pode facilitar a vida de quem escreve o teste também. Mas enfim, é uma decisão que normalmente é tomada antes de escrever o primeiro teste e é seguida até o fim da vida.

    No mais, muito bom os padrões mencionados!

    Um abraço!

  7. Henrique Luz 22/01/2015 at 00:55 #

    Fala Raphael, beleza?

    Boas observações! Gostaria de comentar dois pontos:

    1) “Isso é bem complicado! Bem mesmo!! Localmente, ok, mas no desenvolvimento, em um ambiente compartilhado, é complexo.”

    Verdade, mas existe ferramentas que dá pra contornar esse problema. Sempre utilizei o DBUnit nestes casos e resolveu. Meu banco e dos devs sempre está preparado para os testes. Sejam units/integracao ou aceitação.

    2) Nos testes não é uma boa ter dependência entre eles, mas eu abro uma exceção para os testes de aceitação.

    É uma boa abordagem, economiza bastante ao incluir novas features na automatização.
    Mas há um grande problema: Quando a quantidade de testes de aceitação está bem grande e temos que paralelizar para executar mais rápido, não há como ter dependência entre testes. Por isso aconselho escrevê-los independentes desde o início, pois nunca se sabe quando teremos a necessidade de partir pra essa abordagem.

    Abração,

  8. Adriano Fukuda 22/01/2015 at 10:49 #

    Belo artigo Aniche.

    Eu tenho uma dúvida referente aos “assert” que foram utilizados.

    Por quê tem dois Equals e um True?

    Assert.assertEquals(2, cursos.size());
    Assert.assertTrue(cursos.get(0).getText().contains(“TDD”));
    Assert.assertEquals(cursos.get(1).getText().contains(“Java”));

  9. Stélio Moiane 22/01/2015 at 12:17 #

    Oi Maurício,

    Parabens pelo post. Muito bom.

    So uma questão, a ideia de usar WS para inicializar partes de cenários. seria mesmo boa prática eu disponibilizar recursos que na sua maioria são usados pelos testes que a propria integração com outros sistemas?

    Ficou meio na dúvida de ter muitos recursos web so usados por testes.

  10. Maurício Aniche 22/01/2015 at 12:44 #

    Rapha,

    Nesse caso, a minha solução seria ter um WS que popula os dados complicados. Lá dentro, vc abusaria de Test Data Builders e outras estruturas que te ajudariam a montar tudo isso.

    Faz sentido?

    Oi Henrique Luiz,

    Faz todo sentido! Pq não escreve isso em formato de padrão!? 🙂

    Um abraço,

  11. Nykolas Lima 22/01/2015 at 13:23 #

    Muito bom o post e eu concordo com o Aniche que é muito importante ter o banco de dados em um estado inicial, limpar o banco a cada teste para que um teste não influencie no outro.

    O jeito que eu utilizo para criar os meus dados antes de cada teste de integração é com o Fixture-Factory(https://github.com/six2six/fixture-factory) isso permite que o teste de integração fique pequeno e legível, e tira a responsabilidade de inserir os dados na base do setup do teste e passa essa responsabilidade para o framework.

    Um exemplo de teste de integração, utilizando a base de dados em um estado inicial e carregando os dados necessários ondemand:

    @Test
    public void shouldListInvites() {
    Invite invite = Fixture.from(Invite.class).uses(hibernateProcessor).gimme(“valid”); //Essa linha cria um Invite e utiliza o hibernateProcessor para inserir os dados na base de dados
    User user = Fixture.from(User.class).uses(hibernateProcessor).gimme(“valid”); //Essa linha cria um User e utiliza o hibernateProcessor para inserir os dados na base de dados

    String url = String.format(“/invite/action/filter?latitude=%f&longitude=%f”, -23.565993, -46.687862);
    Response response = given().headers(authenticatedHeaders(user)).post(url);
    response.then().statusCode(200);
    }

    O código do Fixture-Factory fica responsável por criar os objetos e persistir eles na base de dados. Isso faz com que o setup do código fique simples e legível, possibilitando utilizar a base sempre em um estado inicial e fazendo com que um teste não influencie em outro.

  12. Anderson Mangueira 22/07/2016 at 15:46 #

    Bom tarde a todos estou estudando a apostila FJ-11 Java e Orientação a Objetos da Caelum na pagina 73 exercício 4 alterar o laço para o novo for do Java 5.0, até o momento não consegui ter sucesso na chamada do método. segue toda a estrutura que estou desenvolvendo para resolver o exercício.

    Estou com problemas para chamar o método —> mostraempregados() da class Empresa a partir da class TestaEmpresa —> empresa.mostraEmpregados(empresa.empregados[])

    Alguem pode me ajudar a consertar este código

    class Empresa {

    String nome;
    String cnpj;
    Funcionario[] empregados;
    int a;
    void adiciona(Funcionario f) {
    a += 1;
    this.empregados[a – 1] = f;
    }
    // este metodo funciona normal
    /* void mostraEmpregados() {
    for (int i = 0; i < this.empregados.length; i++) {
    System.out.println("Funcionário na posição: " + i);
    //System.out.println("Salario: " + this.empregados[i].salario);
    empregados[i].mostra();
    }
    }
    */

    // porque o metodo não consigo converter o metodo acima para
    // o novo for do java 5.0
    void mostraEmpregados(int[] empregados) {
    for (int x : empregados) {
    System.out.println(empregados[x]);
    }
    }

    }

    class TestaEmpresa {
    public static void main(String[] args) {

    Empresa empresa = new Empresa();
    empresa.empregados = new Funcionario[10];
    for (int i = 0; i < 10; i++) {
    Funcionario f = new Funcionario();
    f.salario = 1000 + i * 100;
    f.contrata();
    empresa.adiciona(f);
    }
    // qual argumento devo passar aqui para solucionar o erro
    empresa.mostraEmpregados(empresa.empregados[]);

    }
    }

    class Funcionario {

    String nome;
    Data dataNascimento;
    String cpf;
    String rg;
    Data dataAdimicao;
    String departamento;
    String cargo;
    double salario;
    char estaAtivo;
    Data dataDemicao;

    void recebeAumento(double aumento) {
    salario += aumento;
    }

    double calculaGanhoAnual() {
    return this.salario * 12;
    }

    boolean contrata() {
    if (this.estaAtivo != 'S') {
    this.estaAtivo = 'S';
    this.dataAdimicao = new Data();
    this.dataAdimicao.dia = 15;
    this.dataAdimicao.mes = 07;
    this.dataAdimicao.ano = 2016;
    this.departamento = "Elétrica";
    this.cargo = "Téc. Eletroeletrônica I";
    this.salario = 3159.19;
    this.dataDemicao = new Data();
    this.dataDemicao.dia = 0;
    this.dataDemicao.mes = 0;
    this.dataDemicao.ano = 0;
    return true;
    }
    else {
    return false;
    }
    }

    boolean demite() {
    if (this.estaAtivo != 'N') {
    this.estaAtivo = 'N';
    this.dataAdimicao = new Data();
    this.dataAdimicao.dia = 0;
    this.dataAdimicao.mes = 0;
    this.dataAdimicao.ano = 0;
    this.dataDemicao = new Data();
    this.dataDemicao.dia = 16;
    this.dataDemicao.mes = 07;
    this.dataDemicao.ano = 2016;
    this.departamento = "";
    this.cargo = "";
    this.salario = 0.0;
    return true;
    }
    else {
    return false;
    }
    }

    boolean transfFuncDepar() {
    if (this.estaAtivo == 'S' && this.departamento !="Mecânico") {
    this.departamento = "Mecânico";
    return true;
    }
    else {
    return false;
    }
    }

    void mostra() {
    System.out.println("Nome: " + this.nome);
    System.out.println("Departamento: " + this.departamento);
    System.out.println("Salário: " + this.salario);
    System.out.println("RG: " + this.rg);
    System.out.println("Ganho Anual: " + this.calculaGanhoAnual());
    System.out.println("Cargo/Função: " + this.cargo);
    System.out.println("Data de Adimição: " + this.dataAdimicao.formataData());
    System.out.println("Data de Demição: " + this.dataDemicao.formataData());

    }

    }

    class Data {

    int dia;
    int mes;
    int ano;

    String formataData() {
    return dia + "/" + mes + "/" + ano;
    }
    }

  13. Paulo Silveira 23/07/2016 at 22:36 #

    Anderson, coloque essa duvida no http://www.guj.com.br que la poderemos te responder num ambiente melhor.

Deixe uma resposta