Vantagem / desvantagem de ter todas as variáveis ​​declaradas em um teste JUnit

9

Escrevi alguns testes de unidade para algum código novo no trabalho e o enviei para uma revisão de código. Um de meus colegas de trabalho fez um comentário sobre por que eu estava colocando variáveis ​​usadas em vários desses testes fora do escopo do teste.

O código que eu publiquei era essencialmente

import org.junit.Test;

public class FooUnitTests {

    @Test
    public void testConstructorWithValidName() {
        new Foo(VALID_NAME);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testConstructorWithNullName() {
        new Foo(null);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testConstructorWithZeroLengthName() {
        new Foo("");
    }

    @Test(expected = IllegalArgumentException.class)
    public void testConstructorWithLeadingAndTrailingWhitespaceInName() {
        final String name = " " + VALID_NAME + " ";
        final Foo foo = new Foo(name);

        assertThat(foo.getName(), is(equalTo(VALID_NAME)));
    }

    private static final String VALID_NAME = "name";
}

Suas mudanças propostas foram essencialmente

import org.junit.Test;

public class FooUnitTests {

    @Test
    public void testConstructorWithValidName() {
        final String name = "name";
        final Foo foo = new Foo(name);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testConstructorWithNullName() {
        final String name = null;
        final Foo foo = new Foo(name);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testConstructorWithZeroLengthName() {
        final String name = "";
        final Foo foo = new Foo(name);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testConstructorWithLeadingAndTrailingWhitespaceInName() {
        final String name = " name ";
        final Foo foo = new Foo(name);

        final String actual = foo.getName();
        final String expected = "name";

        assertThat(actual, is(equalTo(expected)));
    }
}

Onde tudo o que é necessário dentro do escopo do teste é definido dentro do escopo do teste.

Algumas das vantagens que ele argumentou foram

  1. Cada teste é independente.
  2. Cada teste pode ser executado isoladamente ou em agregação, com o mesmo resultado.
  3. O revisor não precisa rolar para onde esses parâmetros forem declarados para procurar qual é o valor.

Algumas das desvantagens de seu método que eu argumentei foram

  1. Aumenta a duplicação de código
  2. Pode adicionar ruído à mente dos revisores se houver vários testes semelhantes com valores diferentes definidos (por exemplo, teste com doFoo(bar)resultados em um valor, enquanto a mesma chamada tem um resultado diferente porque baré definido de maneira diferente nesse método).

Além da convenção, existem outras vantagens / desvantagens de usar um dos métodos sobre o outro?

Zymus
fonte
O único problema que vejo com seu código é que você enterrou a declaração de VALID_NAME na parte inferior, deixando-nos imaginar de onde ela vinha. Correção que cuidaria da "rolagem". Os nomes de escopo local de seus colegas de trabalho estão violando DRY sem motivo. Pergunte a ele por que não há problema em usar instruções de importação que estão fora de cada método de teste. Sheesh é chamado de contexto.
Candied_orange
@ CandiedOrange: ninguém aqui lê minha resposta? A abordagem dos colegas de trabalho pode violar o princípio DRY, mas não neste exemplo. Não faz sentido ter o mesmo texto "nome" em todos esses casos.
Doc Brown
As variáveis ​​adicionais não acrescentam clareza, mas indica que você pode reescrever seus testes para usar as teorias e os DataPoints da Junit, o que reduziria muito os testes!
Rosa Richter
1
@CandiedOrange: DRY tem menos a ver com evitar a duplicação de código do que com evitar a duplicação de conhecimento (fatos, regras, etc.). Veja: hermanradtke.com/2013/02/06/misunderstanding-dry.html O princípio Dry é declarado como "Todo conhecimento deve ter uma representação única, inequívoca e autorizada dentro de um sistema". en.wikipedia.org/wiki/Don%27t_repeat_yourself
Marjan Venema
"Todo conhecimento deve ter uma representação única, inequívoca e autoritária dentro de um sistema." Portanto, o fato de "nome" ser um VALID_NAME NÃO é uma dessas informações? É verdade que o DRY é mais do que apenas evitar a duplicação de código, mas você achará difícil usá-lo com código duplicado.
Candied_orange 20/05

Respostas:

10

Você deve continuar fazendo o que está fazendo.

Repetir a si mesmo é uma idéia tão ruim no código de teste quanto no código comercial e pelo mesmo motivo. Seu colega foi enganado pela idéia de que todo teste deve ser independente . Isso é verdade, mas "independente" não significa que ele deve conter tudo o que precisa dentro do corpo do método. Isso significa apenas que ele deve fornecer o mesmo resultado, seja executado isoladamente ou como parte de um conjunto, e independentemente da ordem dos testes no conjunto. Em outras palavras, o código de teste deve ter a mesma semântica, independentemente do outro código executado antes dele ; ele não precisa ter todo o código necessário agrupado textualmente .

Reutilizar constantes, código de configuração e similares aumenta a qualidade do código de teste e não compromete sua autossuficiência; portanto, é bom que você continue fazendo isso.

Kilian Foth
fonte
+1 com certeza, mas lembre-se de que DRY é menos evitar a repetição de código do que não repetir conhecimento. Consulte hermanradtke.com/2013/02/06/misunderstanding-dry.html . O princípio Dry é declarado como "Todo conhecimento deve ter uma representação única, inequívoca e autorizada dentro de um sistema". en.wikipedia.org/wiki/Don%27t_repeat_yourself
Marjan Venema
3

Depende. Em geral, ter constantes repetidas declaradas em apenas um lugar é uma coisa boa.

No entanto, no seu exemplo, não faz sentido definir um membro VALID_NAMEda maneira mostrada. Supondo que na segunda variante um mantenedor altere o texto nameno primeiro teste, o segundo teste provavelmente ainda será válido e vice-versa. Por exemplo, suponha que você queira testar adicionalmente letras maiúsculas e minúsculas, mas também mantenha um caso de teste apenas com letras minúsculas, com o menor número possível de casos de teste. Em vez de adicionar um novo teste, você pode alterar o primeiro teste para

public void testConstructorWithValidName() {
    final String name = "Name";
    final Foo foo = new Foo(name);
}

e ainda mantenha os testes restantes.

De fato, ter muitos testes dependendo dessa variável e alterar o valor de VALID_NAMEmais tarde podem interromper acidentalmente alguns de seus testes em um momento posterior. E, em tal situação, você pode realmente melhorar a auto-contenção de seus testes, não introduzindo uma constante artificial com diferentes testes, dependendo dela.

No entanto, o que o @KilianFoth escreveu sobre não se repetir também está correto: siga o princípio DRY no código de teste da mesma maneira que no código comercial. Por exemplo, introduza constantes ou variáveis-membro quando for essencial para o seu código de teste que o valor delas seja o mesmo em todos os lugares.

Seu exemplo mostra como os testes tendem a repetir o código de inicialização, que é um local típico para começar com a refatoração. Portanto, considere refatorar a repetição de

  new Foo(...);

em uma função separada

 public Foo ConstructFoo(String name) 
 {
     return new Foo(name);
 }

pois isso permitirá que você altere a assinatura do Fooconstrutor em um momento posterior com mais facilidade (porque há menos locais onde new Foorealmente é chamado).

Doc Brown
fonte
Este foi apenas um código de exemplo muito curto. No código de produção, a variável VALID_NAME é usada ~ 30 vezes. No entanto, esse é um ponto muito importante e que eu não tinha.
Zymus 18/05
@Zymus: o número de vezes que VALID_NAME é usado é IMHO irrelevante - o que importa é se é importante que seus testes usem o mesmo texto "nome" em todos os casos (por isso, faz sentido introduzir um símbolo para ele) ou se a introdução do símbolo cria uma dependência artificial e desnecessária. Só posso ver o seu exemplo, que é IMHO do segundo tipo, mas para o seu código "real", sua milhagem pode variar.
Doc Brown
1
+1 para o caso que você faz. E o código de teste deve ser um código de nível de produção. Dito isto, DRY não é tanto sobre não se repetir. Trata-se de não repetir o conhecimento. O princípio DRY é declarado como "Todo conhecimento deve ter uma representação única, inequívoca e autorizada dentro de um sistema". ( en.wikipedia.org/wiki/Don%27t_repeat_yourself ). Os mal-entendidos surgem de "repeat" Eu acho que, como o faz: hermanradtke.com/2013/02/06/misunderstanding-dry.html
Marjan Venema
1

Na verdade ... eu não iria para os dois , mas eu vou pegar vocês sobre o seu colega de trabalho para o seu exemplo simplificado.

Em primeiro lugar, eu tendem a preferir valores inline, em vez de fazer atribuições pontuais. Isso melhora a legibilidade do código para mim, pois não preciso dividir minha linha de raciocínio em várias instruções de atribuição e, em seguida , ler a (s) linha (s) de código em que são usadas. Portanto, prefiro sua abordagem ao invés de seu colega de trabalho, com o ligeiro nitpick que testConstructorWithLeadingAndTrailingWhitespaceInName()provavelmente pode ser apenas:

assertThat(new Foo(" " + VALID_NAME + " ").getName(), is(equalTo(VALID_NAME)));

Isso me leva à nomeação. Normalmente, se o seu teste está afirmando Exceptionque será lançado, o nome do teste deve descrever esse comportamento também para maior clareza. Usando o exemplo acima, e se eu tiver espaços em branco extras no nome quando construir meu Fooobjeto? E como isso está relacionado a jogar um IllegalArgumentException?

Com base nos nulle ""testes, estou presumindo aqui que o mesmo Exceptioné jogado desta vez para chamar getName(). Se esse for realmente o caso, talvez definir o nome do método de teste como callingGetNameWithWhitespacePaddingThrows()( IllegalArgumentException- que eu acho que fará parte da saída do JUnit, portanto opcional) seja melhor. Se o preenchimento de espaço em branco no nome durante a instanciação também gerar o mesmo Exception, pularemos a asserção também, para deixar claro que está no comportamento do construtor.

No entanto, acho que há uma maneira melhor de fazer as coisas aqui quando você obtém mais permutações para entradas válidas e inválidas, ou seja, testes parametrizados . O que você está testando é exatamente isso: você está criando um monte de Fooobjetos com diferentes argumentos do construtor e, em seguida, afirmando que ele falha na instanciação ou na chamada getName(). Portanto, por que não deixar o JUnit executar a iteração e o andaime para você? Você pode, em termos leigos, dizer à JUnit: "esses são os valores com os quais quero criar um Fooobjeto" e, em seguida, fazer com que seu método de teste afirmeIllegalArgumentExceptionnos lugares certos. Infelizmente, como o modo de teste parametrizado da JUnit não funciona bem com testes para casos bem-sucedidos e excepcionais no mesmo teste (pelo que sei), você provavelmente precisará pular um pouco de argola com a seguinte estrutura:

@RunWith(Parameterized.class)
public class FooUnitTests {

    private static final String VALID_NAME = "ABC";

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

    private String input;

    private boolean throwException;

    public FooUnitTests(String input, boolean throwException) {
        this.input = input;
        this.throwException = throwException;
    }

    @Test
    public void test() {
        try {
            Foo test = new Foo(input);
            // TODO any testing required for VALID_NAME?
            if (throwException) {
                assertEquals(VALID_NAME, test.getName());
            }
        } catch (IllegalArgumentException e) {
            if (!throwException) {
                throw new RuntimeException(e);
            }
        }
    }
}

É certo que isso parece desajeitado para o que é um teste de quatro valores simples, e não é ajudado muito pela implementação um tanto restritiva da JUnit. Nesse sentido, considero a@DataProvider abordagem do TestNG mais útil e, definitivamente, vale a pena examinar mais de perto se você está considerando testes parametrizados.

Veja como é possível usar o TestNG para seus testes de unidade (usando Java 8 Streampara simplificar a Iteratorconstrução). Alguns dos benefícios são, entre outros:

  1. Os parâmetros de teste se tornam argumentos de método, não campos de classe.
  2. Você pode ter vários @DataProviders fornecendo diferentes tipos de entradas.
    • É sem dúvida mais claro e fácil adicionar novas entradas válidas / inválidas para teste.
  3. Ainda parece um pouco exagerado para o seu exemplo simplificado, mas IMHO é melhor do que a abordagem da JUnit.
public class FooUnitTests {

    private static final String VALID_NAME = "ABC";

    @DataProvider(name="successes")
    public static Iterator<Object[]> getSuccessCases() {
        return Stream.of(VALID_NAME).map(v -> new Object[]{ v }).iterator();
    }

    @DataProvider(name="exceptions")
    public static Iterator<Object[]> getExceptionCases() {
        return Stream.of("", null, " " + VALID_NAME + " ")
                    .map(v -> new Object[]{ v }).iterator();
    }

    @Test(dataProvider="successes")
    public void testSuccesses(String input) {
        new Foo(input);
        // TODO any further testing required?
    }

    @Test(dataProvider="exceptions", expectedExceptions=IllegalArgumentException.class)
    public void testExceptions(String input) {
        Foo test = new Foo(input);
        if (input.contains(VALID_NAME)) {
            // TestNG favors assert(actual, expected).
            assertEquals(test.getName(), VALID_NAME);
        }
    }
}
hjk
fonte