Testando duas implementações diferentes usando o mesmo teste no TestNG

Marcio Endo
May 7, 2023

Estou criando uma implementação parcial da linguagem AsciiDoc.

A linguagem AsciiDoc possui uma documentação. Esta faz o papel de especificação até o momento. A documentação dá a entender que a especificação de fato é a implementação Asciidoctor.

Na prática, então, eu preciso fazer uma engenharia reversa. Isto é, dada uma entrada específica, preciso comparar a saída produzida pelas duas implementações:

Neste artigo eu vou mostrar como estou fazendo isto com o TestNG.

Bora lá.

O nosso teste de exemplo

Para o nosso exemplo, ao invés de um parser AsciiDoc, vamos fazer algo bem menor.

O seguinte teste estabelece o que o nosso parser deve fazer:

public class Exemplo01 {
  @Test(description = """
  Testar o caminho feliz
  - entrada válida
  - entrada formatada c/ espaço depois da vírgula
  """)
  public void teste() {
    var entrada = "4, 8, 15, 16, 23, 42";

    var saida = new int[] {4, 8, 15, 16, 23, 42};

	int[] res = Referencia.parse(entrada);

    assertEquals(res, saida);
  }
}

Assim, nossa entrada é uma string formada por números inteiros separados por vírgulas:

var entrada = "4, 8, 15, 16, 23, 42";

E nosso parser deve retornar um array de inteiros que corresponde à entrada:

var saida = new int[] {4, 8, 15, 16, 23, 42};

Isto é, o array de saída deve:

Testando nossa implementação

No exemplo anterior, usamos o parser que serve de referência:

int[] res = Referencia.parse(entrada);

A ideia, no entanto, é conseguir utilizar o mesmo teste para:

Para isso, vamos adicionar uma fachada no nosso teste.

A nossa fachada

O código da nossa fachada é o seguinte:

public abstract class Tester {
  static final Tester OBJECTOS = new Tester() {
    @Override
    public final void test(String entrada, int[] saida) {
      var parser = new Objectos();

      int[] res = parser.parse(entrada);

      assertEquals(res, saida);
    }
  };

  static final Tester REFERENCIA = new Tester() {
    @Override
    public final void test(String entrada, int[] saida) {
      int[] res = Referencia.parse(entrada);

      assertEquals(res, saida);
    }
  };

  Tester() {}

  public abstract void test(String entrada, int[] saida);
}

A classe Tester possui um único método público test. O método recebe tanto:

A fachada provê duas implementações distintas OBJECTOS e REFERENCIA:

Usando a fachada no teste

Alteramos o nosso teste e introduzimos a fachada Tester:

public class Exemplo02 {

  Tester tester = Tester.OBJECTOS;

  @Test(description = """
  Testar o caminho feliz
  - entrada válida
  - entrada formatada c/ espaço depois da vírgula
  """)
  public void teste() {
    var entrada = "4, 8, 15, 16, 23, 42";

    var saida = new int[] {4, 8, 15, 16, 23, 42};

    tester.test(entrada, saida);
  }
  
}

Veja que o teste em si foi delegado à nossa fachada:

tester.test(entrada, saida);

E que, por padrão, o teste roda com a implementação que estamos desenvolvendo. Veja o atributo tester:

Tester tester = Tester.OBJECTOS;

Assim, é a nossa implementação que é testada quando executamos o teste no IDE, por exemplo.

Em outras palavras, o ciclo 'natural' de desenvolvimento é mantido.

Rodando o mesmo teste com a referência

Nosso teste passou. Precisamos, no entanto, garantir que, ao longo do desenvolvimento, ele continue gerando resultado igual ao da nossa referência.

Em outras palavras, é também necessário rodar o teste que passou com a implementação de referência.

Uma solução é usar a anotação Factory do TestNG.

Adicionando construtores ao teste

Vamos refatorar nosso teste e adicionar dois construtores. O 'cabeçalho' do teste fica assim:

public class Exemplo03 {

  Tester tester = Tester.OBJECTOS;

  public Exemplo03() {}

  Exemplo03(Tester tester) {
    this.tester = tester;
  }

  ...
}

O construtor público e sem argumentos permite que o nosso IDE rode o teste sem percalços.

O segundo construtor permite construir uma nova instância do teste especificando diretamente a fachada Tester.

Este passo de adicionar construtores não é estritamente necessário. Afinal, o atributo tester não é private e tampouco final. No entanto, os construtores facilitarão nossa vida no passo seguinte.

Usando a anotação Factory

Criamos, então, uma classe com a anotação Factory do TestNG:

public class Exemplo03Referencia {
  @Factory
  public Object[] factory() {
    var tester = Tester.REFERENCIA;

    return new Object[] {
        new Exemplo03(tester)
    };
  }
}

A anotação indica ao TestNG que todos os objetos retornados pelo método devem ser executados como testes.

Note que o método retorna um array contendo o nosso teste. O nosso teste, por sua vez, foi criado com a fachada Tester de referência.

Conseguimos, assim:

Integração contínua

Como dito no final da última seção, conseguimos rodar o teste com a nossa implementação no IDE.

Em outras palavras, no atual momento, o teste não rodará como parte do build Maven, por exemplo. No processo de integração contínua, somente o teste com a implementação de referência será executado.

Para que o teste rode com nossa implementação rode, é necessário criar uma segunda Factory:

public class Exemplo03Objectos {
  @Factory
  public Object[] factory() {
    var tester = Tester.OBJECTOS;

    return new Object[] {
        new Exemplo03(tester)
    };
  }
}

Isto cria um problema de ter manter a mesma lista de testes em duas classes separadas.

Factory e DataProvider

Como alternativa é possível utilizar Factory juntamente com a anotação DataProvider:

public class Exemplo03Alternativa {
  @Factory(dataProvider = "tester")
  public Object[] factory(Tester tester) {
    return new Object[] {
        new Exemplo03(tester)
    };
  }

  @DataProvider
  public Object[][] tester() {
    return new Object[][] {
        {Tester.OBJECTOS},

        {Tester.REFERENCIA}
    };
  }
}

Assim, quando for necessário adicionar mais um teste à lista, é necessário fazê-lo em apenas um lugar.

Projeto Objectos AsciiDoc

No projeto Objectos AsciiDoc eu utilizo a primeira técnica. Isto é, quando um teste é adicionado, eu preciso atualizar a lista em duas classes distintas.

A razão é que as classes distintas permitem que eu rode os grupos de testes separadamente no IDE. Isto é importante porque, no caso do Objectos AsciiDoc, a implementação de referência é consideravelmente mais lenta.

Até o próximo artigo

Por hoje é isto. Espero que tenha gostado.

O código para todos os exemplos pode ser encontrado no GitHub.

Se tiver comentários, dúvidas ou correções sobre este post, por favor, envie um email.

Siga-me no Twitter.