Pruebas unitarias en Java con JUnit 5

    Introducción

    JUnit es un marco de prueba popular para Java. El uso simple es muy sencillo y JUnit 5 trajo algunas diferencias y comodidades en comparación con JUnit 4.

    El código de prueba está separado del código del programa real y, en la mayoría de los IDE, los resultados / salida de la prueba también están separados de la salida del programa, lo que proporciona una estructura legible y conveniente.

    Instalación de JUnit 5

    Instalar JUnit es tan simple como incluir las dependencias:

    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.4.0-RC1</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.4.0-RC1</version>
        <scope>test</scope>
    </dependency>
    

    Puede elegir simplemente crear las clases de prueba en la misma carpeta que el resto de su código, pero se recomienda tener un directorio separado para las pruebas. Otra cosa a tener en cuenta son las convenciones de nomenclatura. Si deseamos probar completamente nuestro código, cada clase debe tener una clase de prueba correspondiente llamada – [classname]Test.

    Generalmente, una estructura de proyecto recomendada es:

    Nota: Es muy recomendable que importe JUnit5 usando el staticmodificador, hará que el uso de los métodos proporcionados sea mucho más limpio y más legible.

    Diferencias entre JUnit 4 y JUnit 5

    Una de las ideas principales detrás de la nueva versión de JUnit es utilizar las características que Java 8 trajo a la mesa (principalmente lambdas) para facilitar la vida de todos. Se han cambiado algunas cosas menores: el mensaje opcional de que se imprimiría una aserción si fallaba es ahora el último argumento «opcional», en lugar de ser inconvenientemente el primero.

    JUnit 5 consta de tres proyectos (JUnit Platform, JUnit Jupiter y JUnit Vintage), por lo que habrá varias importaciones diferentes, aunque JUnit Jupiter será nuestro enfoque principal.

    Algunas otras diferencias incluyen:

    • El JDK mínimo para JUnit 4 era JDK 5, mientras que JUnit 5 requiere al menos JDK 8
    • El @Before, @BeforeClass, @After, y @AfterClasslas anotaciones son ahora el más fácil de leer como los @BeforeEach, @BeforeAll, @AfterEach, y @AfterAllanotaciones
    • @Ignore es ahora @Disable
    • @Category es ahora @Tag
    • Soporte para clases de prueba anidadas y una fábrica de pruebas adicional para pruebas dinámicas

    La anotación @Test

    Usaremos una clase de calculadora simple para demostrar las capacidades básicas de JUnit. Por ahora, nuestra Calculatorclase se ve así:

    public class Calculator {
        float add(float a, float b) {
            return a + b;
        }
    
        int divide(int a, int b) {
            return a/b;
        }
    }
    

    No hace nada especial, pero nos permitirá realizar las pruebas. Según las convenciones de nomenclatura, CalculatorTestnace la clase:

    class CalculatorTest {
    
        @Test
        void additionTest() {
            Calculator calc = new Calculator();
            assertEquals(2, calc.add(1,1), "The output should be the sum of the two arguments");
        }
    }
    

    La @Testanotación le dice a la JVM que el siguiente método es una prueba. Esta anotación es necesaria antes de cada método de prueba.

    El método assertEquals()y todos los métodos de «afirmación» funcionan de manera similar: afirman (es decir, se aseguran) de que lo que estamos comprobando es true. En este caso, estamos afirmando que los dos argumentos que pasamos son iguales (consulte la Nota a continuación), en caso de que no lo sean, la prueba fallará.

    El primer argumento es generalmente el valor de retorno esperado y el segundo es el valor de retorno real del método que estamos probando. Si estos dos son iguales, la afirmación está satisfecha y la prueba pasa.

    El tercer argumento es opcional pero muy recomendable: es el mensaje personalizado que aparecerá cuando una prueba no salga como debería. Puede que no importe con los programas pequeños, pero es una buena práctica agregar estos mensajes para que quien trabaje con su código más adelante (o un futuro usted) pueda descubrir fácilmente lo que no funcionó.

    Ejecutamos las pruebas simplemente ejecutando la CalculatorTestclase (podemos hacerlo aunque no tenga un mainmétodo):

    Si cambiamos la assertEquals()línea a algo que no es correcto, como:

    assertEquals(1, calc.add(1,1), "The output should be the sum of the two arguments");
    

    Recibiremos un mensaje de falla de prueba adecuado:

    Nota: Es muy importante comprender que en assertEquals()realidad usa el .equals()método y no el ==operador. Hay un método JUnit separado llamado assertSame()que usa en ==lugar de .equals().

    Métodos de afirmación

    JUnit 5 viene con muchos métodos de afirmación . Algunos de ellos son solo métodos de conveniencia que se pueden reemplazar fácilmente por un método assertEquals()o assertSame(). Sin embargo, se recomienda utilizar estos métodos de conveniencia en su lugar, para facilitar la lectura y el mantenimiento.

    Por ejemplo, la llamada assertNull(object, message)se puede reemplazar por assertSame(null, object, message), pero se recomienda la forma anterior.

    Echemos un vistazo a las afirmaciones a nuestra disposición. En general, se explican por sí mismos:

    • assertEquals() y assertNotEquals()
    • assertSame() y assertNotSame()
    • assertFalse() y assertTrue()
    • assertThrows() afirma que el método generará una excepción dada, cuando se enfrente al valor de retorno del método probado
    • assertArrayEquals(expectedArray, actualArray, optionalMsg)compara las dos matrices y pasa solo si tienen los mismos elementos en las mismas posiciones; de lo contrario, falla. Si ambas matrices lo son null, se consideran iguales.
    • assertIterableEquals(Iterable<?> expected, Iterable<?> actual, optionalMsg)se asegura de que los iterables esperados y reales sean profundamente iguales. Dado que este método toma an Iterablecomo los dos argumentos, los iterables que pasamos no necesitan ser del mismo tipo (podemos pasar ay LinkedListan ArrayList, por ejemplo). Sin embargo, sus iteradores deben devolver elementos iguales en el mismo orden que los demás. Nuevamente, si ambos lo son null, se consideran iguales.
    • assertLinesMatch(List<String> expected, List<String> actual, optionalMsg)es un método un poco más complejo, ya que toma varios pasos antes de declarar que los argumentos pasados ​​no son iguales y funciona solo con Strings:
    • Verifica si expected.equals(actual)regresa true, si lo hace, pasa a las siguientes entradas.
    • Si el Paso 1 no regresa true, la expectedcadena actual se trata como una expresión regular, por lo que el método verifica actual.matches(expected)si lo hace y, si lo hace, pasa a las siguientes entradas.
    • Si ninguno de los dos pasos anteriores regresa true, el último intento que hace el método es verificar si la siguiente línea es una línea de avance rápido. Una línea de avance rápido comienza y termina con «>>», entre los cuales hay un número entero (omite el número de líneas designadas) o una cadena.
    • <T extends Throwable> T assertThrows(Class<T> expectedType, Executable exec, optionalMsg)comprueba que la ejecución de Executablearroja una excepción de expectedTypey devuelve esa excepción. Si no se lanza ninguna excepción o si la excepción lanzada no es del expectedType, la prueba falla.
    • assertTimeout(Duration timeout, Executable exec, optionalMsg)comprueba que execcompleta su ejecución antes de que se exceda el tiempo de espera dado. Dado que execse ejecuta en el mismo hilo que el del código de llamada, la ejecución no se interrumpirá de forma preventiva si se excede el tiempo de espera. En otras palabras, execfinaliza su ejecución independientemente de timeout, el método simplemente verifica después si se ejecutó lo suficientemente rápido.
    • assertTimeoutPreemptively(Duration timeout, Executable exec, optionalMsg)comprueba que la ejecución de execcompleta antes de que se exceda el tiempo de espera dado, pero a diferencia del assertTimeoutmétodo, este método se ejecuta execen un subproceso diferente y abortará preventivamente la ejecución si timeoutse excede el proporcionado .
    • assertAll(Exectutable... executables) throws MultipleFailuresErrory assertAll(Stream<Executable> executables) throws MultipleFailuresErrorhace algo muy útil. Es decir, si quisiéramos usar varias afirmaciones en una prueba (no es necesariamente malo si lo hacemos), sucedería algo muy molesto si todas salieran mal. A saber:
      @Test
      void additionTest() {
          Calculator calc = new Calculator();
          assertEquals(100, calc.add(1,1), "Doesn't add two positive numbers properly");
          assertEquals(100, calc.add(-1,1), "Doesn't add a negative and a positive number properly");
          assertNotNull(calc, "The calc variable should be initialized");
      }
      

      Cuando falla la primera afirmación, no veremos cómo fueron las otras dos. Lo cual puede ser especialmente frustrante, ya que puede corregir la primera afirmación con la esperanza de que solucione toda la prueba, solo para descubrir que la segunda afirmación también falló, solo que no la vio ya que la primera afirmación que falló «ocultó» ese hecho. :

      assertAll()resuelve este problema ejecutando todas las afirmaciones y luego mostrándole el error incluso si fallaron varias afirmaciones. La versión reescrita sería:

      @Test
      void additionTest() {
          Calculator calc = new Calculator();
          assertAll(
              () -> assertEquals(100, calc.add(1,1), "Doesn't add two positive numbers properly"),
              () -> assertEquals(100, calc.add(-1,1), "Doesn't add a negative and a positive number properly"),
              () -> assertNotNull(calc, "The calc variable should be initialized")
          );
      }
      

      Ahora obtendremos un resultado de prueba más informativo:

      Es bueno entender que assertAll()básicamente verifica si alguno de los ejecutables arroja una excepción, ejecutándolos todos independientemente, y todos los que arrojan una excepción se agregan en el MultipleFailuresErrorque arroja el método. Sin embargo, para problemas serios, como OutOfMemoryErrorla ejecución se detendrá inmediatamente y la excepción se volverá a lanzar tal cual, pero enmascarada como una excepción no verificada (tiempo de ejecución).

    Nota: Es posible que haya notado que String optionalMsgse excluye de las declaraciones del método. JUnit 5 proporciona una pequeña optimización al optionalMsg. Por supuesto, podemos usar un simple Stringcomo nuestro optionalMsg; sin embargo, independientemente de cómo vaya la prueba (si falla o no), Java seguirá generando eso String, aunque es posible que nunca se imprima. Esto no importa cuando hacemos algo como:

    assertEquals(expected, actual, "The test failed for some reason");
    

    Pero si tuviéramos algo parecido a:

    assertEquals(expected, actual, "The test failed because " + (Math.sqrt(50) + Math.scalb(15,7) + Math.cosh(10) + Math.log1p(23)) + " is not a pretty number");
    

    Realmente no desea que se optionalMsgcargue algo así, independientemente de si Java planea imprimirlo.

    La solución es usar un Supplier<String>. De esta manera podemos utilizar los beneficios de la evaluación perezosa, si nunca ha oído hablar del concepto, es básicamente Java diciendo «No calcularé nada que no necesite. ¿Necesito esto Stringahora? ¿No? Entonces no lo creará «. La evaluación perezosa aparece varias veces en Java.

    Esto se puede hacer simplemente agregando () ->antes de nuestro mensaje opcional. Para que se convierta en:

    assertEquals(expected, actual, () -> "The test failed because " + (Math.sqrt(50) + Math.scalb(15,7) + Math.cosh(10) + Math.log1p(23)) + " is not a pretty number");
    

    Esta es una de las cosas que no eran posibles antes de JUnit 5, porque Lambdas no se introdujo en Java en ese momento y JUnit no pudo aprovechar su utilidad.

    Prueba de anotaciones

    En esta parte, presentaremos algunas otras anotaciones, además de la @Testanotación necesaria . Una cosa que debemos entender es que para cada método de prueba, Java crea una nueva instancia de la clase de prueba.

    Es una mala idea declarar variables globales que se cambian dentro de diferentes métodos de prueba, y es una idea especialmente mala esperar cualquier tipo de orden de prueba, ¡no hay garantías de en qué orden se ejecutarán los métodos de prueba!

    Otra mala idea es tener que inicializar constantemente la clase que queremos probar si no es necesario. Veremos cómo evitarlo pronto, pero antes de eso, echemos un vistazo a las anotaciones disponibles:

    • @BeforeEach: Se llama a un método con esta anotación antes de cada método de prueba, muy útil cuando queremos que los métodos de prueba tengan algún código en común. Los métodos deben tener un voidtipo de retorno, no deben ser privatey no deben ser static.
    • @BeforeAll: Un método con esta anotación se llama solo una vez, antes de que se ejecute cualquiera de las pruebas, se usa principalmente en lugar de @BeforeEachcuando el código común es costoso, como establecer una conexión de base de datos. ¡El @BeforeAllmétodo debe ser el staticpredeterminado! Tampoco debe ser privatey debe tener un voidtipo de retorno.
    • @AfterAll: Un método con esta anotación se llama solo una vez, después de que se haya llamado a cada método de prueba. Normalmente se utiliza para cerrar conexiones establecidas por @BeforeAll. El método debe tener un voidtipo de retorno, no debe ser privatey debe ser static.
    • @AfterEach: Se llama a un método con esta anotación después de que cada método de prueba finaliza su ejecución. Los métodos deben tener un voidtipo de retorno, no deben ser privatey no deben ser static.

    Para ilustrar cuándo se ejecuta cada uno de estos métodos, agregaremos algo de sabor a nuestra CalculatorTestclase y, mientras lo hacemos, demostraremos el uso del assertThrows()método:

    class CalculatorTest {
    
        Calculator calc;
    
        @BeforeAll
        static void start() {
            System.out.println("inside @BeforeAll");
        }
    
        @BeforeEach
        void init() {
            System.out.println("inside @BeforeEach");
            calc = new Calculator();
        }
    
        @Test
        void additionTest() {
            System.out.println("inside additionTest");
            assertAll(
                () -> assertEquals(2, calc.add(1,1), "Doesn't add two positive numbers properly"),
                () -> assertEquals(0, calc.add(-1,1), "Doesn't add a negative and a positive number properly"),
                () -> assertNotNull(calc, "The calc variable should be initialized")
            );
        }
    
        @Test
        void divisionTest() {
            System.out.println("inside divisionTest");
            assertThrows(ArithmeticException.class, () -> calc.divide(2,0));
        }
    
        @AfterEach
        void afterEach() {
            System.out.println("inside @AfterEach");
        }
    
        @AfterAll
        static void close() {
            System.out.println("inside @AfterAll");
        }
    }
    

    Lo que nos da el resultado de:

    inside @BeforeAll
    
    inside @BeforeEach
    inside divisionTest
    inside @AfterEach
    
    
    inside @BeforeEach
    inside additionTest
    inside @AfterEach
    
    inside @AfterAll
    

    Esto también nos muestra que, a pesar de que el additionTest()método se declara primero, no garantiza que se ejecutará primero.

    Otras anotaciones

    Antes de JUnit 5, los métodos de prueba no podían tener ningún parámetro, pero ahora sí. Los usaremos mientras demostramos las nuevas anotaciones.

    @Discapacitado

    Una anotación simple y útil que simplemente deshabilita cualquier método de prueba, es decir, la prueba no se ejecutará y el resultado de la prueba mostrará que la prueba en particular fue deshabilitada:

    @Disabled
    @Test
    void additionTest() {
        // ...
    }
    

    Da el siguiente resultado para ese método de prueba:

    void main.CalculatorTest.additionTest() is @Disabled
    
    @DisplayName

    Otra anotación simple que cambia el nombre mostrado del método de prueba.

    @DisplayName("Testing addition")
    @Test
    void additionTest() {
        // ...
    }
    
    @Etiqueta

    La @Taganotación es útil cuando queremos crear un «paquete de prueba» con las pruebas seleccionadas. Las etiquetas se utilizan para filtrar qué pruebas se ejecutan:

    class SomeTest {
        @Tag("a")
        @Test
        void test1() {
            // ...
        }
        @Tag("a")
        @Test
        void test2() {
            // ...
        }
        @Tag("b")
        @Test
        void test3() {
            // ...
        }
    }
    

    Entonces, si quisiéramos ejecutar solo pruebas que tengan la etiqueta «a», iríamos a Ejecutar -> Editar configuraciones y cambiaríamos los siguientes dos campos antes de ejecutar la prueba:

    @RepetidoPrueba

    Esta anotación funciona igual que la @Testanotación, pero ejecuta el método de prueba el número de veces especificado. Cada iteración de prueba puede tener su propio nombre, mediante el uso de una combinación de marcadores de posición dinámicos y texto estático. Los marcadores de posición disponibles actualmente son:

    • {displayName}: nombre para mostrar del @RepeatedTestmétodo
    • {currentRepetition}: el recuento de repeticiones actual
    • {totalRepetitions}: el número total de repeticiones

    El nombre predeterminado de cada iteración es «repetición {currentRepetition} de {totalRepetitions}».

    //@RepeatedTest(5)
    @DisplayName("Repeated Test")
    @RepeatedTest(value = 5, name = "{displayName} -> {currentRepetition}")
    void rptdTest(RepetitionInfo repetitionInfo) {
        int arbitrary = 2;
        System.out.println("Current iteration: " + repetitionInfo.getCurrentRepetition());
    
        assertEquals(arbitrary, repetitionInfo.getCurrentRepetition());
    }
    

    El RepetitionInfoparámetro no es necesario, pero podemos acceder a él si necesitamos esos datos. Obtenemos una pantalla limpia con respecto a cada iteración cuando ejecutamos esto:

    @ParametrizedTest

    Las pruebas parametrizadas también permiten ejecutar una prueba varias veces, pero con diferentes argumentos.

    Funciona de manera similar, por @RepeatedTestlo que no volveremos a pasar por todo, solo por las diferencias.

    Debe agregar al menos una fuente que proporcionará los argumentos para cada iteración y luego agregar un parámetro del tipo requerido al método.

    @ParameterizedTest
    @ValueSource(ints = {6,8,2,9})
    void lessThanTen(int number) {
        assertTrue(number < 10, "the number isn't less than 10");
    }
    

    El método recibirá los elementos de la matriz uno por uno:

    @ValueSourcees solo un tipo de anotación que acompaña @ParametrizedTest. Para obtener una lista de otras posibilidades, consulte la documentación .

    @Anidado

    Esta anotación nos permite agrupar las pruebas donde tenga sentido hacerlo. Es posible que deseemos separar las pruebas que tratan de la suma de las pruebas que tratan de la división, la multiplicación, etc. y nos proporciona un camino fácil para @Disableciertos grupos por completo. También nos permite probar y hacer oraciones completas en inglés como resultado de nuestra prueba, haciéndolo extremadamente legible.

    @DisplayName("The calculator class: ")
    class CalculatorTest {
        Calculator calc;
    
        @BeforeEach
        void init() {
            calc = new Calculator();
        }
    
        @Nested
        @DisplayName("when testing addition, ")
        class Addition {
            @Test
            @DisplayName("with positive numbers ")
            void positive() {
                assertEquals(100, calc.add(1,1), "the result should be the sum of the arguments");
            }
    
            @Test
            @DisplayName("with negative numbers ")
            void negative() {
                assertEquals(100, calc.add(-1,-1), "the result should be the sum of the arguments");
            }
        }
    
        @Nested
        @DisplayName("when testing division, ")
        class Division {
            @Test
            @DisplayName("with 0 as the divisor ")
            void throwsAtZero() {
                assertThrows(ArithmeticException.class, () -> calc.divide(2,0), "the method should throw and ArithmeticException");
            }
        }
    }
    
    @TestInstance

    Esta anotación se usa solo para anotar la clase de prueba con @TestInstance(Lifecycle.PER_CLASS)para decirle a JUnit que ejecute todos los métodos de prueba en una sola instancia de la clase de prueba, y no cree una nueva instancia de la clase para cada método de prueba.

    Esto nos permite usar variables de nivel de clase y compartirlas entre los métodos de prueba (generalmente no se recomienda), como inicializar recursos fuera de un método @BeforeAllo @BeforeEachy @BeforeAllya @AfterAllno es necesario static. Por tanto, el modo «por clase» también hace posible el uso de métodos @BeforeAlly @AfterAllen @Nestedclases de prueba.

    La mayoría de las cosas que podemos hacer @TestInstance(Lifecycle.PER_CLASS)se pueden hacer con staticvariables. Tenemos que tener cuidado de restablecer todas las variables que necesitaban restablecerse a un cierto valor en @BeforeEach, que generalmente se restablecían cuando la clase se reiniciaba cada vez.

    Supuestos

    Además de las afirmaciones antes mencionadas, tenemos supuestos. Cuando una suposición no es cierta, la prueba no se ejecuta en absoluto. Las suposiciones se utilizan normalmente cuando no tiene sentido seguir ejecutando una prueba si no se cumplen determinadas condiciones, y la mayoría de las veces la propiedad que se está probando es algo externo, no directamente relacionado con lo que estamos probando. Hay algunos métodos de suposición sobrecargados:

    • assumeTrue(boolean assumption, optionalMsg)y assumeFalse(boolean assumption, optionalMsg)solo ejecutará la prueba si lo proporcionado assumptiones verdadero y falso, respectivamente. Se optionalMsgmostrará solo si la suposición no es cierta.
    • assumingThat(boolean assumption, Executable exec)– si assumptiones verdadero, execse ejecutará; de lo contrario, este método no hace nada.

    Se BooleanSupplierpuede usar A en lugar de regular boolean.

    class CalculatorTest {
    
        Calculator calc;
        boolean bool;
    
        @BeforeEach
        void init() {
            System.out.println("inside @BeforeEach");
            bool = false;
            calc = new Calculator();
        }
    
        @Test
        void additionTest() {
            assumeTrue(bool, "Java sees this assumption isn't true -> stops executing the test.");
            System.out.println("inside additionTest");
            assertAll(
                    () -> assertEquals(2, calc.add(1,1), "Doesn't add two positive numbers properly"),
                    () -> assertEquals(0, calc.add(-1,1), "Doesn't add a negative and a positive number properly"),
                    () -> assertNotNull(calc, "The calc variable should be initialized"));
        }
    
        @Test
        void divisionTest() {
            assumeFalse(0 > 5, "This message won't be displayed, and the test will proceed");
            assumingThat(!bool, () -> System.out.println("uD83DuDC4C"));
            System.out.println("inside divisionTest");
            assertThrows(ArithmeticException.class, () -> calc.divide(2,0));
        }
    }
    

    Lo que nos daría la salida:

    inside @BeforeEach
    👌
    inside divisionTest
    
    
    inside @BeforeEach
    
    
    org.opentest4j.TestAbortedException: Assumption failed: Java sees this assumption isn't true -> stops executing the test.
    

    Conclusión y consejos

    La mayoría de nosotros probamos el código ejecutando manualmente el código, ingresando alguna entrada o haciendo clic en algunos botones y verificando la salida. Estas «pruebas» suelen ser un escenario de caso común y un montón de casos extremos en los que podemos pensar. Esto está relativamente bien con proyectos pequeños, pero se vuelve completamente un desperdicio en algo más grande. Probar un método en particular es particularmente malo: o System.out.println()el resultado y lo verificamos, o lo ejecutamos a través de algunas ifdeclaraciones para ver si se ajusta a la expectativa, luego cambiamos el código cada vez que queremos verificar qué sucede cuando pasamos otros argumentos al método . Analizamos visual y manualmente en busca de algo inusual.

    JUnit nos brinda una forma limpia de administrar nuestros casos de prueba y separa la prueba del código del código en sí. Nos permite realizar un seguimiento de todo lo que debe probarse y nos muestra lo que no funciona de manera ordenada.

    Generalmente, desea probar el caso común de todo lo que pueda. Incluso los métodos simples y directos, solo para asegurarse de que funcionan como deberían. Esta podría incluso ser la parte más importante de las pruebas automatizadas, ya que cada vez que cambia algo en su código o agrega un nuevo módulo, puede ejecutar las pruebas para ver si ha roto el código o no, para ver si todo sigue funcionando. como lo hizo antes de la «mejora». Por supuesto, los casos extremos también son importantes, especialmente para métodos más complejos.

    Siempre que encuentre un error en su código, es una muy buena idea escribir una prueba antes de solucionar el problema. Esto asegurará que si el error se repite, no necesitará perder tiempo averiguando qué salió mal nuevamente . Una prueba simplemente fallará y sabrá dónde está el problema.

     

    Etiquetas:

    Deja una respuesta

    Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *