Ventaja / desventaja de tener todas las variables declaradas en una prueba JUnit

9

He estado escribiendo algunas pruebas unitarias para algún código nuevo en el trabajo, y lo envié para una revisión del código. Uno de mis compañeros de trabajo hizo un comentario sobre por qué estaba poniendo variables que se usan en varias de esas pruebas fuera del alcance de la prueba.

El código que publiqué fue esencialmente

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";
}

Sus cambios propuestos fueron esencialmente

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)));
    }
}

Donde todo lo que se requiere dentro del alcance de la prueba, se define dentro del alcance de la prueba.

Algunas de las ventajas que argumentó fueron

  1. Cada prueba es independiente.
  2. Cada prueba se puede ejecutar de forma aislada o agregada, con el mismo resultado.
  3. El revisor no tiene que desplazarse a donde se declaren estos parámetros para buscar cuál es el valor.

Algunas de las desventajas de su método que argumentaba eran

  1. Aumenta la duplicación de código
  2. Puede agregar ruido a la mente de los revisores si hay varias pruebas similares con diferentes valores definidos (es decir, prueba con doFoo(bar)resultados en un valor, mientras que la misma llamada tiene un resultado diferente porque barse define de manera diferente en ese método).

Además de la convención, ¿hay otras ventajas / desventajas de usar cualquiera de los métodos sobre el otro?

Zymus
fuente
El único problema que veo con su código es que enterró la declaración de VALID_NAME en la parte inferior, dejándonos preguntarnos de dónde viene. Arreglar eso se encargaría del "desplazamiento". Los nombres de ámbito local de sus compañeros de trabajo están violando DRY sin ningún motivo. Pregúntele por qué está bien usar declaraciones de importación que están fuera de cada método de prueba. Sheesh se llama contexto.
candied_orange
@CandiedOrange: ¿nadie aquí lee mi respuesta? El enfoque de los compañeros de trabajo podría violar el principio DRY, pero no en este ejemplo. No tiene sentido tener el mismo texto "nombre" en todos estos casos.
Doc Brown
Las variables adicionales no agregan claridad, sin embargo, señala que puede reescribir sus pruebas para usar las Teorías y los Puntos de datos de Junit, ¡lo que reduciría mucho sus pruebas!
Rosa Richter
1
@CandiedOrange: DRY tiene menos que ver con evitar la duplicación de código que con evitar la duplicación de conocimiento (hechos, reglas, etc.). Ver: hermanradtke.com/2013/02/06/misunderstanding-dry.html El principio Dry se establece como "Todo conocimiento debe tener una representación autoritaria, inequívoca y única dentro de un sistema". en.wikipedia.org/wiki/Don%27t_repeat_yourself
Marjan Venema
"Cada conocimiento debe tener una representación única, inequívoca y autorizada dentro de un sistema". ¿Entonces el hecho de que "nombre" es un VALID_NAME NO es uno de estos conocimientos? Es cierto que DRY es más que simplemente evitar la duplicación de código, pero le resultará difícil estar DRY con código duplicado.
candied_orange

Respuestas:

10

Deberías seguir haciendo lo que estás haciendo.

Repetirse es una idea tan mala en el código de prueba como en el código comercial, y por la misma razón. Su colega ha sido engañado por la idea de que cada prueba debe ser autónoma . Eso es bastante cierto, pero "autocontenido" no significa que debe contener todo lo que necesita dentro de su cuerpo de método. Solo significa que debería dar el mismo resultado si se ejecuta en isolaton o como parte de una suite, e independientemente del orden de las pruebas en la suite. En otras palabras, el código de prueba debe tener la misma semántica independientemente de qué otro código se haya ejecutado antes ; no tiene que tener todo el código requerido incluido textualmente .

La reutilización de constantes, el código de configuración y similares aumenta la calidad del código de prueba y no compromete su autocontención, por lo que es bueno que continúe haciéndolo.

Kilian Foth
fuente
+1 absolutamente, pero tenga en cuenta que DRY se trata menos de evitar la repetición de código que de no repetir el conocimiento. Ver hermanradtke.com/2013/02/06/misunderstanding-dry.html . El principio Dry se establece como "Todo conocimiento debe tener una representación autoritaria, inequívoca y única dentro de un sistema". en.wikipedia.org/wiki/Don%27t_repeat_yourself
Marjan Venema
3

Depende. En general, tener constantes repetidas declaradas en un solo lugar es algo bueno.

Sin embargo, en su ejemplo, no tiene sentido definir un miembro VALID_NAMEde la manera mostrada. Suponiendo que en la segunda variante un mantenedor cambia el texto nameen la primera prueba, la segunda prueba probablemente seguirá siendo válida y viceversa. Por ejemplo, supongamos que desea probar adicionalmente letras mayúsculas y minúsculas, pero también mantener un caso de prueba solo con letras minúsculas, con la menor cantidad posible de casos de prueba. En lugar de agregar una nueva prueba, puede cambiar la primera prueba a

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

y aún conservan las pruebas restantes.

De hecho, tener muchas pruebas en función de dicha variable y cambiar el valor de VALID_NAMEmás tarde puede interrumpir involuntariamente algunas de sus pruebas en un momento posterior. Y en tal situación, de hecho puede mejorar la autocontención de sus pruebas al no introducir una constante artificial con diferentes pruebas dependiendo de ello.

Sin embargo, lo que escribió @KilianFoth acerca de no repetirse también es correcto: siga el principio DRY en el código de prueba de la misma manera que lo hace en el código comercial. Por ejemplo, introduzca constantes o variables miembro cuando sea esencial para su código de prueba que su valor sea el mismo en todos los lugares.

Su ejemplo muestra cómo las pruebas tienden a repetir el código de inicialización, ese es un lugar típico para comenzar con la refactorización. Por lo tanto, puede considerar refactorizar la repetición de

  new Foo(...);

en una función separada

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

ya que le permitirá cambiar la firma del Fooconstructor en un momento posterior con mayor facilidad (porque hay menos lugares donde new Foorealmente se llama).

Doc Brown
fuente
Este fue solo un código de ejemplo muy corto. En el código de producción, la variable VALID_NAME se usa ~ 30 veces. Sin embargo, ese es un punto muy alimentario y uno que no tenía.
Zymus
@Zymus: la cantidad de veces que se usa VALID_NAME es irrelevante, lo que importa es si es importante que sus pruebas usen el mismo texto de "nombre" en todos los casos (por lo que tiene sentido introducir un símbolo), o si La introducción del símbolo crea una dependencia artificial innecesaria. Solo puedo ver su ejemplo, que es IMHO del segundo tipo, pero para su código "real" su kilometraje puede variar.
Doc Brown
1
+1 para el caso que hagas. Y el código de prueba debe ser un código de grado de producción. Dicho esto, DRY no se trata tanto de no repetirse en absoluto. Se trata de no repetir el conocimiento. El principio DRY se establece como "Todo conocimiento debe tener una representación autoritaria, inequívoca y única dentro de un sistema". ( en.wikipedia.org/wiki/Don%27t_repeat_yourself ). Los malentendidos surgen de "repetir", creo, al igual que: hermanradtke.com/2013/02/06/misunderstanding-dry.html
Marjan Venema
1

Yo en realidad ... no iría por ambos , pero elegiré el tuyo sobre tu compañero de trabajo por tu ejemplo simplificado.

Lo primero es lo primero, tiendo a favorecer valores en línea en lugar de hacer tareas únicas. Esto mejora la legibilidad del código para mí, ya que no tengo que dividir mi línea de pensamiento en múltiples declaraciones de asignación, luego leer la (s) línea (s) de código donde se usan. Por lo tanto, preferiré su enfoque sobre su compañero de trabajo, con el ligero inconveniente que testConstructorWithLeadingAndTrailingWhitespaceInName()probablemente pueda ser solo:

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

Eso me lleva a los nombres. Por lo general, si su prueba afirma Exceptionque se lanzará, su nombre también debe describir ese comportamiento para mayor claridad. Usando el ejemplo anterior, ¿qué sucede si tengo espacios en blanco adicionales en el nombre cuando construyo mi Fooobjeto? ¿Y cómo se relaciona eso con lanzar un IllegalArgumentException?

Con base en las pruebas nully "", supongo que Exceptionesta vez se lanza lo mismo para llamar getName(). Si ese es el caso, quizás sea mejor establecer el nombre del método de prueba como callingGetNameWithWhitespacePaddingThrows()( IllegalArgumentExceptionque creo que será parte de la salida de JUnit, por lo tanto opcional). Si tener espacios en blanco en el nombre durante la creación de instancias también arrojará lo mismo Exception, entonces también omitiré la afirmación por completo, para dejar en claro que está en el comportamiento del constructor.

Sin embargo, creo que hay una mejor manera de hacer las cosas aquí cuando obtienes más permutaciones para entradas válidas e inválidas, es decir, pruebas parametrizadas . Lo que está probando es exactamente eso: está creando un montón de Fooobjetos con diferentes argumentos de constructor, y luego afirmando que falla en la instanciación o en la llamada getName(). Por lo tanto, ¿por qué no dejar que JUnit realice la iteración y el andamiaje por usted? Puede, en términos simples, decirle a JUnit, "estos son los valores con los que quiero crear un Fooobjeto", y luego hacer que su método de prueba afirmeIllegalArgumentExceptionen los lugares correctos Desafortunadamente, dado que la forma de pruebas parametrizadas de JUnit no funciona bien con las pruebas para casos exitosos y excepcionales en la misma prueba (que yo sepa), es probable que tengas que saltar un poco de aro con la siguiente estructura:

@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);
            }
        }
    }
}

Es cierto que esto parece torpe para lo que es una prueba de cuatro valores, que de otro modo sería simple, y la implementación algo restrictiva de JUnit no lo ayuda mucho. En este sentido, considero que el@DataProvider enfoque de TestNG es más útil, y definitivamente vale la pena verlo más de cerca si está considerando pruebas parametrizadas.

Así es como uno podría usar TestNG para sus pruebas unitarias (usando Java 8 Streampara simplificar la Iteratorconstrucción). Algunos de los beneficios son, pero no se limitan a:

  1. Los parámetros de prueba se convierten en argumentos de método, no en campos de clase.
  2. Puede tener múltiples @DataProvidercorreos electrónicos que proporcionen diferentes tipos de entradas.
    • Podría decirse que es más claro y fácil agregar nuevas entradas válidas / inválidas para las pruebas.
  3. Todavía parece un poco exagerado para su ejemplo simplificado, pero en mi humilde opinión, es más elegante que el enfoque de 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
fuente