Pruebas unitarias en Java con JUnit 5

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

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/salidas de prueba también están separados de la salida del programa, lo que proporciona una estructura legible y conveniente.

Instalando JUnit 5

Instalar JUnit es tan sencillo como incluir las dependencias:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<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 - [nombre de clase]Prueba.

En general, una estructura de proyecto recomendada es:

Nota: Se recomienda encarecidamente que importe JUnit5 utilizando el modificador static, hará que el uso de los métodos proporcionados sea mucho más limpio y 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 fallara 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
  • Las anotaciones @Before, @BeforeClass, @After y @AfterClass ahora son más legibles que @BeforeEach, @BeforeAll, @AfterEach y @AfterAll anotaciones
  • @Ignorar ahora es @Disable
  • @Categoría ahora es @Etiqueta
  • Compatibilidad con clases de pruebas 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 clase ‘Calculadora’ se ve así:

1
2
3
4
5
6
7
8
9
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á seguir los pasos de las pruebas. Según las convenciones de nomenclatura, nace la clase CalculatorTest:

1
2
3
4
5
6
7
8
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 anotación @Test 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 todo lo que estamos comprobando es verdadero. 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, se satisface la afirmación y se pasa la prueba.

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 programas pequeños, pero es una buena práctica agregar estos mensajes para que quien trabaje con su código más tarde (o usted en el futuro) pueda descubrir fácilmente qué no funcionó.

Ejecutamos las pruebas simplemente ejecutando la clase CalculatorTest (podemos hacerlo aunque no tenga un método main):

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

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

Obtendremos un mensaje de falla de prueba adecuado:

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

Métodos de aserción

JUnit 5 viene con muchos métodos de aserción. Algunos de ellos son simplemente 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 con assertSame(null, object, message), pero se recomienda la forma anterior.

Echemos un vistazo a las afirmaciones a nuestra disposición. Por lo general, son bastante autoexplicativos:

  • afirmarEquals() y afirmarNoEquals()

  • assertSame() y assertNotSame()

  • afirmar Falso() y afirmar Verdadero()

  • assertThrows() afirma que el método lanzará una excepción dada, cuando se enfrente con el 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 son nulas, se consideran iguales.

  • assertIterableEquals(Iterable<?> esperado, Iterable<?> actual, OptionalMsg) se asegura de que los iterables esperados y reales sean profundamente iguales. Dado que este método toma un Iterable como los dos argumentos, los iterables que pasamos no necesitan ser del mismo tipo (podemos pasar un LinkedList y un ArrayList, por ejemplo). Sin embargo, sus iteradores deben devolver elementos iguales en el mismo orden que los demás. Nuevamente, si ambos son nulos, se consideran iguales.

  • assertLinesMatch(List<String> esperado, 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:

    1. It checks whether expected.equals(actual) returns true, if it does, it proceeds to the next entries.
    2. If Step 1 doesn't return true, the current expected string is treated like a regular expression, so the method checks whether actual.matches(expected) and if it does, it proceeds to the next entries.
    3. If neither of the two steps above return true, the last attempt the method makes is to check whether the next line is a fast-forward line. A fast-forward line starts and ends with ">>", between which are either an integer (skips the number of designated lines) or a string.
  • <T extends Throwable> T assertThrows(Class<T> tipoesperado, exec ejecutable, mensaje opcional) comprueba que la ejecución de Ejecutable arroja una excepción de tipoesperado y devuelve esa excepción. Si no se lanza ninguna excepción o si la excepción lanzada no es del “tipo esperado”, la prueba falla.

  • assertTimeout(Tiempo de espera de duración, exec ejecutable, mensaje opcional) comprueba que el exec completa su ejecución antes de que se exceda el tiempo de espera dado. Dado que exec se ejecuta en el mismo subproceso que el del código de llamada, la ejecución no se abortará de forma preventiva si se supera el tiempo de espera. En otras palabras, el exec finaliza su ejecución independientemente del tiempo de espera, el método simplemente verifica si se ejecutó lo suficientemente rápido.

  • assertTimeoutPreemptively(Duration timeout, Executable exec, OptionalMsg) comprueba que la ejecución de exec se completa antes de que se exceda el tiempo de espera dado, pero a diferencia del método assertTimeout, este método ejecuta el exec en un subproceso diferente y *será * abortar preventivamente la ejecución si se excede el “tiempo de espera” proporcionado.

  • assertAll(Executable... ejecutables) lanza MultipleFailuresError y assertAll(Stream<Executable> ejecutables) lanza MultipleFailuresError hace 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:

    1
    2
    3
    4
    5
    6
    7
    
    @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");
    }
    

    When the first assertion fails, we will not see how the other two went. Which can be especially frustrating, since you might fix the first assertion hoping that it would fix the entire test, only to find that the second assertion failed as well, only you didn't see it since the first assertion failing "hid" that fact:

    assertAll() solves this issue by executing all the assertions and then showing you the failure even if multiple assertions failed. The rewritten version would be:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    @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")
        );
    }
    

    Now we'll get a more informative testing result:

    It's good to understand that assertAll() basically checks whether any of the executables throw an exception, executing them all regardless, and all that do throw an exception are aggregated in the MultipleFailuresError that the method throws. However, for serious issues, like OutOfMemoryError the execution will halt immediately and the exception will be rethrown as is but masked as an unchecked (runtime) exception.

Nota: Es posible que haya notado que String OptionalMsg está excluido de las declaraciones de métodos. JUnit 5 proporciona una pequeña optimización para opcionalMsg. Por supuesto, podemos usar una Cadena simple como nuestro Mensaje opcional; sin embargo, independientemente de cómo vaya la prueba (ya sea que falle o no), Java aún generará esa Cadena, aunque es posible que nunca se imprima . Esto no importa cuando hacemos algo como:

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

Pero si tuviéramos algo como:

1
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 cargue algo como ese OptionalMsg 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 esta String ahora mismo? ¿No? Entonces no la crearé.". La evaluación perezosa aparece varias veces en Java.

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

1
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{target="_blank”} no se introdujeron en Java en ese momento y JUnit no pudo aprovechar su utilidad.

Anotaciones de prueba

En esta parte, presentaremos algunas otras anotaciones, además de la necesaria anotación @Test. 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 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 evitar eso pronto, pero antes de eso, echemos un vistazo a las anotaciones disponibles:

  • @BeforeEach: Un método con esta anotación se llama 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 tipo de retorno vacío, no deben ser privados y no deben ser estáticos.
  • @BeforeAll: un método con esta anotación se llama solo una vez, antes de ejecutar cualquiera de las pruebas, se usa principalmente en lugar de @BeforeEach cuando el código común es costoso, como establecer una conexión a la base de datos. ¡El método @BeforeAll debe ser estático por defecto! Tampoco debe ser “privado” y debe tener un tipo de retorno “vacío”.
  • @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. Usualmente se usa para cerrar conexiones establecidas por @BeforeAll. El método debe tener un tipo de retorno vacío, no debe ser privado y debe ser estático.
  • @AfterEach: Se llama a un método con esta anotación después de que cada método de prueba termine su ejecución. Los métodos deben tener un tipo de retorno vacío, no deben ser privados y no deben ser estáticos.

Para ilustrar cuándo se ejecuta cada uno de estos métodos, agregaremos algo de sabor a nuestra clase CalculatorTest, y mientras estamos en eso, demostraremos el uso del método assertThrows():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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 la salida de:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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 método additionTest() 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.

@Desactivado

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:

1
2
3
4
5
@Disabled
@Test
void additionTest() {
    // ...
}

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

1
void main.CalculatorTest.additionTest() is @Disabled
@Nombre para mostrar

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

1
2
3
4
5
@DisplayName("Testing addition")
@Test
void additionTest() {
    // ...
}
@Etiqueta

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class SomeTest {
    @Tag("a")
    @Test
    void test1() {
        // ...
    }
    @Tag("a")
    @Test
    void test2() {
        // ...
    }
    @Tag("b")
    @Test
    void test3() {
        // ...
    }
}

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

@PruebaRepetida

Esta anotación funciona igual que la anotación @Test pero ejecuta el método de prueba el número de veces especificado. Cada iteración de prueba puede tener su propio nombre, usando 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 método @RepeatedTest
  • {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}".

1
2
3
4
5
6
7
8
9
//@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 parámetro RepetitionInfo 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:

@PruebaParametrizada

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

Funciona de manera similar a @RepeatedTest, por lo que no revisaremos todo de nuevo, solo las diferencias.

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

1
2
3
4
5
@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:

@ValueSource es solo un tipo de anotación que va con @ParametrizedTest. Para obtener una lista de otras posibilidades, consulte la documentación.

@Anidado

Esta anotación nos permite agrupar pruebas donde tiene sentido hacerlo. Podríamos querer separar las pruebas que se ocupan de la suma de las pruebas que se ocupan de la división, la multiplicación, etc.; y nos brinda una manera fácil de @Deshabilitar ciertos grupos por completo. También nos permite intentar hacer oraciones completas en inglés como resultado de nuestra prueba, lo que lo hace extremadamente legible.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@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");
        }
    }
}

@PruebaInstancia

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 prueba método.

Esto nos permite usar variables de nivel de clase y compartirlas entre los métodos de prueba (generalmente no recomendado), como inicializar recursos fuera de un método @BeforeAll o @BeforeEach y @BeforeAll y @AfterAll Ya no es necesario que sea ’estático’. Por lo tanto, el modo "por clase" también permite utilizar los métodos @BeforeAll y @AfterAll en las clases de prueba @Nested.

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

Supuestos

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

  • assumeTrue(suposición booleana, mensaje opcional) y assumeFalse(suposición booleana, mensaje opcional) solo ejecutarán la prueba si la suposición proporcionada es verdadera y falsa, respectivamente. El OptionalMsg se mostrará solo si la suposición no es cierta.
  • assumingThat(suposición booleana, exec ejecutable) - si la suposición es verdadera, se ejecutará exec; de lo contrario, este método no hace nada.

Se puede usar un BooleanSupplier en lugar de un booleano normal.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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("\uD83D\uDC4C"));
        System.out.println("inside divisionTest");
        assertThrows(ArithmeticException.class, () -> calc.divide(2,0));
    }
}

Lo que nos daría la salida:

1
2
3
4
5
6
7
8
9
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 ejecutándolo manualmente, 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 inútil en proyectos más grandes. Probar un método en particular es particularmente malo: o bien System.out.println() el resultado y lo verificamos, o lo ejecutamos a través de algunas declaraciones if para ver si se ajusta a la expectativa, luego cambiamos el código cuando queramos para verificar qué sucede cuando pasamos otros argumentos al método. Realizamos un escaneo visual y manual en busca de cualquier cosa 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.

En general, desea probar el caso común de todo lo que pueda. Incluso métodos simples y directos, solo para asegurarse de que funcionan como deberían. Esta podría ser incluso 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 igual. funciona como antes de la "mejora". Por supuesto, los casos límite 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 vuelve a ocurrir, no tendrá que perder tiempo averiguando qué salió mal nuevamente. Una prueba simplemente fallará y sabrá dónde está el problema.