Back-End

18 jun, 2018

O mundo mágico do JUnit Runners

Publicidade

Algum dia vocês já se pegaram perguntando como o JUnit, o framework de testes mais popular na comunidade Java, consegue rodar todas as suas classes de teste? Ou se ele conseguiria montar uma suíte com alguns testes específicos e até mesmo criar seu próprio Runner? Te convido a conhecer o mundo dos Runners, no qual você terá acesso à um novo leque de conceitos e ferramentas para escrever testes.

Mas afinal, o que são Runners?

Do ponto de vista do JUnit, um Runner é o responsável por instanciar uma classe de testes e executar todos os seus respectivos métodos. Existem várias implementações de Runners, como Parameterized e Suite, nas quais podemos controlar a parte de execução dos testes, mas falamos disso mais tarde. Por hora, vamos dizer que um Runner está ligado ao começo do processo de execução de um teste.

Para definirmos um Runner em uma classe de teste, utilizamos a anotação RunWith:

@RunWith(BlockJUnit4ClassRunner.class)
public class MyClassTest{
  //doing some crazy tests
}

Neste exemplo, o BlockJUnit4ClassRunner está sendo atribuído como o Runner da classe MyClassTest. Vejamos agora como o JUnit chama esse Runner.

JUnitCore

Para entender melhor os Runners, temos que ir um pouco mais a fundo na arquitetura do JUnit.

Quando executamos uma classe de teste por linha de comando, acabamos com algo mais ou menos assim:

java -cp .:/usr/share/java/junit.jar org.junit.runner.JUnitCore [test class name]

Utilizamos uma classe que se chama JUnitCore, porque dentro dela está o método main da aplicação. Por sua vez, ela pega o parâmetro passado; no caso, o nome da classe, e instancia o Runner que aquele teste requer (ele sabe pela anotação RunWith).

Caso não haja um Runner anotado, o default (BlockJUnit4ClassRunner) é utilizado; então começa um novo processo de criação de classes, mas dessa vez comandado pelo Runner. As classes de teste são passadas e ele cuida de executar os métodos anotados como Test.

No caso de uma IDE, como o Intellij, existe uma classe que se chama JUnitStarter que em algum momento chama o JUnitCore e o mesmo processo começa.

Todo esse processo do JUnitCore é feito a base de reflection, tanto para conseguir identificar os Runners corretos de cada classe, como para conseguir executar os métodos dentro dos Runners.

Implementações de Runner

O JUnit oferece algumas implementações de Runners: Suite, Parameterized, Categories e Enclosed. Vamos analisar separadamente cada uma delas.

Parameterized

Vamos supor que você tenha uma classe que faça a validação de um determinado input e retorna true ou false. O teste dela seria algo mais ou menos assim:

public class NotParameterizedTest {

    @Test
    public void whenMyInputIsValid_shouldReturnTrue(){

        assertEquals(true, validator.validade("12345"));
    }

    @Test
    public void whenMyInputIsInvalid_shouldReturnFalse(){

        assertEquals(false, validator.validade("asdv"));
    }

    @Test
    public void whenMyInputIsEmpty_shouldReturnFalse(){

        assertEquals(false, validator.validade(""));
    }

    @Test
    public void whenMyInputIsNull_shouldReturnFalse(){

        assertEquals(false, validator.validade(null));
    }
}

Neste caso, temos que escrever um teste para cada cenário: válido, inválido, vazio, nulo e qualquer outro caso que quisermos validar. Se olharmos como os testes foram feitos, podemos reparar que o método que estamos testando é o mesmo, o que nos dá a sensação de como estivéssemos utilizando o famoso “copiar/colar”. Com a ajuda do Parameterized, conseguimos testar todos os cenários com um teste só:

@RunWith(Parameterized.class)
public class ParameterizedTest {

    @Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][]{
                {true, "12345"}, {false, "123"}, {false, ""}, {false, null}
        });
    }

    private boolean expected;
    private String input;

    public ParameterizedTest(boolean expected, String input) {
        this.expected = expected;
        this.input = input;
    }

    @Test
    public void forEachInput_shouldReturnExpectedResult() {
        assertEquals(expected, validator.validate(input));
    }

}

Note que primeiro anotamos a classe com o Runner Parameterized, depois criamos um método estático anotado como Parameters, que retorna todos os parâmetros que precisamos para executar nossos testes. Por último um construtor, que vai receber nossos parâmetros quando o Runner instanciar nossa classe. Podemos também, no lugar de um construtor, anotar os campos que serão parametrizados com Parameter, que seriam injetados pelo Runner:

@RunWith(Parameterized.class)
public class ParameterizedAnotateTest {

    @Parameterized.Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][]{
                {true, "12345"}, {false, "123"}, {false, ""}, {false, null}
        });
    }
    
    @Parameter
    private boolean expected;

    @Parameter(1)
    private String input;


    @Test
    public void forEachInput_shouldReturnExpectedResult() {
        assertEquals(expected, validator.validate(input));
    }

}

O número entre parênteses demonstra que o parâmetro será injetado naquele campo.

Suite

Com este Runner nós podemos definir uma série de classes de testes a serem executadas:

@RunWith(Suite.class)
@SuiteClasses({ExampleTest.class,MyClassTest.class})
public class SuiteTest {
}

Categories (experimental)

Este Runner identifica somente os testes anotados com uma categoria. As categorias servem para filtrar os testes que serão executados, fazendo com que rodemos somente os testes desejados.

Vamos supor que temos um tipo de teste “A” e ele ocorre em vários testes. Se criarmos uma interface “A” e anotarmos todos os testes com Category(A.class), o Runner consegue filtrar somente os testes anotados.

public class ExampleTest {

    @Category(A.class)
    @Test
    public void test() {
        //crazy tests
    }
}
public class ExampleBTest {

    @Test
    public void addition_isCorrect() {
        //crazy tests
    }

    @Category(A.class)
    @Test
    public void sub_isCorrect() {
        //crazy tests
    }
}
@RunWith(Categories.class)
@IncludeCategory(A.class)
@SuiteClasses({ExampleBrTest.class, ExampleTest.class})
public class RunTestCategorySuite {
}

Podemos incluir mais de uma categoria e excluir um tipo também. Mas por que excluir? Bom, os métodos podem ter mais de uma categoria no caso queiramos que somente os testes “A” e “B” sejam executados e o “C”, não. Teríamos algo como:

public class ExampleTest {

    @Category(A.class)
    @Test
    public void a() {
        //crazy test
    }

    @Category({A.class, B.class})
    public void ab() {
        //crazy test
    }

    @Category({A.class, B.class, C.class})
    public void abc(){
        //crazy test
    }
}
@RunWith(Categories.class)
@IncludeCategory({A.class,B.class})
@ExcludeCategory(C.class)
@SuiteClasses(ExampleTest.class)
public class RunTestSuit {
}

Enclosed (experimental)

Este Runner é utilizado quando temos uma classe de teste com inner classes. A classe “pai” precisa ser anotada como Enclosed e suas inner classes precisam ser estáticas para que funcione:

@RunWith(Enclosed.class)
public class EnclosedTest{
  public static class A {
    @Test
    public void testA(){
      //crazy tests
    }
  }
  
  public static class B {
    @Test
    public void testB(){
      //crazy tests
    }
    
    @Test
    public void testBA(){
      //crazy tests
    }
  }
}

Cada inner class também pode ser anotada com um Runner diferente. Vamos utilizar a classe do exemplo dado no Parameterized e uma outra com o Runner default. Neste caso, teríamos algo assim:

@RunWith(Enclosed.class)
public class EnclosedWithRunnersTest {

    public static class A {

        @Test
        public void a() {
            //crazy tests
        }

        @Test
        public void b() {
            //crazy tests
        }
    }

    @RunWith(Parameterized.class)
    public static class ParameterizedTest {

        @Parameterized.Parameters
        public static Collection<Object[]> data() {
            return Arrays.asList(new Object[][]{
                    {true, "12345"}, {false, "asd"}, {false, ""}, {false, null}
            });
        }

        private boolean expected;
        private String input;

        public ParameterizedTest(boolean expected, String input) {
            this.expected = expected;
            this.input = input;
        }

        @Test
        public void forEachInput_shouldReturnExpectedResult() {
            assertEquals(expected, Validator.validate(input));
        }

    }


}

Existem ainda as implementações customizadas de um Runner . Um bom exemplo é o IntermittentTestRule, um Runner para cuidar de testes intermitentes da biblioteca tempus-fugit. Podemos também criar nossas próprias implementações, mas este é um assunto para um próximo artigo, pois o mundo dos Runners customizados é vasto.

Para concluir, os Runners podem ser uma ferramenta poderosa na hora de criarmos nossos testes, nos ajudando a ter uma visão diferente na hora de escrever e ter controle de como serão executados. Se ficou alguma dúvida ou tem algo a acrescentar, aproveite os campos abaixo.

Até a próxima!

***

Este artigo foi publicado originalmente em: https://www.concrete.com.br/2018/05/28/o-mundo-magico-do-junit-runners