Módulo 19: Pruebas y depuración

Objetivos del módulo

  • Distinguir errores de compilación, de ejecución y lógicos, y leer los mensajes y las trazas de pila
  • Depurar con IntelliJ IDEA: puntos de ruptura, ejecución paso a paso (Step Over, Step Into, Step Out), variables, Evaluate Expression, watches y puntos de ruptura condicionales
  • Localizar y corregir un error lógico con el depurador, paso a paso
  • Diseñar casos de prueba de caja negra (clases de equivalencia y valores límite) y de caja blanca (caminos y cobertura)
  • Escribir pruebas unitarias con JUnit 6: @Test, aserciones, assertThrows, assertAll, ciclo de vida, @DisplayName y pruebas parametrizadas
  • Ejecutar las pruebas en IntelliJ y con Maven, e interpretar los resultados
  • Aplicar el desarrollo guiado por pruebas (TDD) con el ciclo rojo-verde-refactorizar
  • Medir la cobertura de código con JaCoCo e IntelliJ

1. Los tres tipos de errores

Todo programador comete errores: la diferencia entre un principiante y un profesional no es que el segundo no se equivoque, sino que encuentra y corrige los errores con método. El primer paso es saber de qué tipo es el error.

Comparativa de errores de compilación, de ejecución y lógicos Figura 1. Los tres tipos de errores, quién los detecta y cuándo.

1.1. Errores de compilación

📘 Concepto: Un error de compilación se produce cuando el código no respeta la sintaxis o las reglas de tipos de Java. El compilador (javac) se niega a generar el bytecode y te dice el fichero, la línea y la causa. En IntelliJ lo verás antes de compilar: el código aparece subrayado en rojo.

public class ErrorCompilacion {
    public static void main(String[] args) {
        int edad = "veinte";
        System.out.println("Edad: " + edad)
        String nombre = "Ana";
        System.out.println(nombre.lenght());
    }
}

Al compilarlo con javac ErrorCompilacion.java (JDK 25):

ErrorCompilacion.java:4: error: ';' expected
        System.out.println("Edad: " + edad)
                                           ^
1 error

Solo aparece un error, aunque el código tiene tres. El compilador trabaja en fases: primero comprueba la sintaxis y, si falla, se detiene. Al añadir el punto y coma aparecen los errores semánticos (de tipos y de nombres):

ErrorCompilacion.java:3: error: incompatible types: String cannot be converted to int
        int edad = "veinte";
                   ^
ErrorCompilacion.java:6: error: cannot find symbol
        System.out.println(nombre.lenght());
                                 ^
  symbol:   method lenght()
  location: variable nombre of type String
2 errors
Mensaje (en inglés, como lo muestra el JDK) Significado Causa típica
';' expected Falta un punto y coma Olvido al final de una sentencia
incompatible types: X cannot be converted to Y Asignas un valor de un tipo a una variable de otro int x = "5";, falta un cast o un parseInt
cannot find symbol El nombre no existe Erratas (lenght), variable fuera de ámbito, falta un import
missing return statement Un camino del método no devuelve nada return dentro de un if sin else
variable x might not have been initialized Usas una variable local sin valor Declararla sin asignarle valor en todos los caminos
unreachable statement Código que nunca se ejecutará Sentencias después de un return o break
class X is public, should be declared in a file named X.java Nombre de fichero y clase no coinciden Renombrar uno de los dos

💡 Consejo: Corrige siempre el primer error primero y vuelve a compilar. Un solo error de sintaxis puede provocar una cascada de mensajes falsos más abajo.

1.2. Errores de ejecución

📘 Concepto: Un error de ejecución ocurre cuando el programa compila pero, al ejecutarse, se encuentra una situación imposible: acceder a un objeto null, salirse de un array, dividir un entero entre cero… La JVM lanza una excepción y, si nadie la captura (módulo 15), el programa termina mostrando la traza de pila (stack trace).

public class ErrorEjecucion {
    public static void main(String[] args) {
        String[] nombres = {"Ana", "Luis", null};
        imprimirIniciales(nombres);
    }

    static void imprimirIniciales(String[] nombres) {
        for (int i = 0; i < nombres.length; i++) {
            System.out.println(inicial(nombres[i]));
        }
    }

    static char inicial(String nombre) {
        return nombre.charAt(0);       // falla si nombre es null
    }
}
A
L
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.charAt(int)" because "nombre" is null
	at ErrorEjecucion.inicial(ErrorEjecucion.java:14)
	at ErrorEjecucion.imprimirIniciales(ErrorEjecucion.java:9)
	at ErrorEjecucion.main(ErrorEjecucion.java:4)

Cómo leer una traza de pila:

  1. Primera línea: el tipo de excepción (NullPointerException) y el mensaje. Desde Java 14 los mensajes de NullPointerException explican qué era null (helpful NullPointerExceptions).
  2. Líneas at: la pila de llamadas en el momento del fallo, de la más reciente (arriba) a la más antigua (abajo). Se lee: «falló en inicial, línea 14, que fue llamado por imprimirIniciales, línea 9, que fue llamado por main, línea 4».
  3. Busca la primera línea que sea de tu código: en programas reales habrá muchas líneas de bibliotecas (java.base/...) por encima; el error casi siempre está en tu código, no en el de Java.

⚠️ Importante: Si ejecutas un fichero suelto con java Fichero.java, el mensaje puede decir "<parameter1>" en lugar de "nombre", porque ese modo compila sin información de depuración. IntelliJ y Maven compilan con ella (javac -g) y muestran el nombre real de la variable.

1.3. Errores lógicos

📘 Concepto: Un error lógico (bug) es el más peligroso: el programa compila, se ejecuta sin excepciones y termina… pero el resultado es incorrecto. Ni el compilador ni la JVM pueden avisarte, porque el programa hace exactamente lo que le dijiste, no lo que querías.

Ejemplos típicos: usar > en lugar de >=, la división entera (7 / 2 es 3), un bucle que se ejecuta una vez de más o de menos (off-by-one), comparar String con ==, olvidar inicializar un acumulador o inicializarlo con un valor erróneo.

Para encontrarlos hay dos herramientas complementarias que estudias en este módulo:

  • El depurador: ejecuta el programa a cámara lenta y te deja ver el valor de cada variable.
  • Las pruebas: comprueban automáticamente que, para unas entradas conocidas, la salida es la esperada.

2. Depurar: ejecutar a cámara lenta

📘 Concepto: Depurar (debugging) es el proceso de localizar la causa de un error y corregirla. Un depurador (debugger) es una herramienta que permite detener el programa en una línea concreta, ejecutarlo instrucción a instrucción e inspeccionar (y modificar) el valor de las variables en cada momento.

Muchos principiantes depuran llenando el código de System.out.println("aquí llego"). Funciona para casos sencillos, pero tiene inconvenientes:

System.out.println Depurador
Hay que modificar el código y recompilar cada vez No se toca el código
Solo ves lo que decidiste imprimir Ves todas las variables, objetos y la pila de llamadas
Hay que acordarse de borrar las trazas No deja rastro
No puedes cambiar valores sobre la marcha Puedes modificar variables y evaluar expresiones
Útil en servidores sin interfaz o para registros permanentes (logging) Útil en desarrollo

La analogía es la de un vídeo de una jugada polémica: verla a velocidad normal no basta para saber si fue fuera de juego; hay que pausar en el fotograma exacto y avanzar fotograma a fotograma. El depurador es tu moviola.


3. El depurador de IntelliJ IDEA

3.1. Puntos de ruptura e inicio de la depuración

Un punto de ruptura (breakpoint) marca una línea en la que el programa se detendrá antes de ejecutarla.

  • Para ponerlo o quitarlo: clic en el canal izquierdo (gutter), junto al número de línea, o sitúa el cursor en la línea y pulsa Ctrl+F8. Aparece un círculo rojo.
  • Para empezar a depurar: en lugar de Run (▶, Shift+F10), usa Debug (el icono del insecto, Shift+F9), o clic en el ▶ verde del canal junto a main → Debug ‘Estadisticas.main()’.
  • El programa se ejecuta normalmente hasta llegar al primer punto de ruptura. Ahí se detiene, la línea se resalta y se abre la ventana Debug.

⚠️ Importante: Si pulsas Run en lugar de Debug, los puntos de ruptura se ignoran. Es el error más común la primera vez.

3.2. La ventana Debug

Esquema de IntelliJ IDEA detenido en un punto de ruptura con la ventana Debug Figura 2. Esquema de IntelliJ IDEA (interfaz nueva, tema claro) detenido en la línea 9 de Estadisticas.java.

Nº Elemento Para qué sirve
① Punto de ruptura en el canal El programa se detiene antes de ejecutar esa línea
② Punto de ejecución (línea resaltada en azul) La próxima línea que se va a ejecutar
③ Valores en línea (inline values) IntelliJ muestra en gris, al final de cada línea, el valor actual de las variables
④ Barra de herramientas de depuración Continuar, detener y avanzar paso a paso (sección 3.3)
⑤ Frames (marcos de la pila) La pila de llamadas: mediaAprobados fue llamado desde main, línea 18. Haz clic en un marco para ver sus variables
⑥ Campo Evaluate expression / add a watch Escribe cualquier expresión y pulsa Enter para evaluarla, o Ctrl+Shift+Enter para añadirla como watch
⑦ Variables del marco seleccionado Parámetros y variables locales con su valor. Los objetos y arrays se despliegan con ▸
⑧ Watch Una expresión que se reevalúa automáticamente en cada paso

La ventana tiene dos pestañas principales: Threads & Variables (la de la figura) y Console, donde ves la salida del programa y escribes la entrada de teclado si usa Scanner.

3.3. Ejecución paso a paso

Diferencia entre Step Over, Step Into y Step Out y tabla de atajos Figura 3. Con el programa detenido en la línea 3, Step Over salta a la línea 4, Step Into entra en doble() y, dentro de doble(), Step Out vuelve a main.

Acción Atajo (Windows/Linux) Qué hace
Step Over F8 Ejecuta la línea actual completa. Si contiene una llamada a un método, la ejecuta entera sin entrar
Step Into F7 Si la línea llama a un método tuyo, entra en él y se detiene en su primera línea
Smart Step Into Shift+F7 Si la línea tiene varias llamadas (a(b(c()))), te deja elegir en cuál entrar
Step Out Shift+F8 Termina de ejecutar el método actual y se detiene al volver al método que lo llamó
Run to Cursor Alt+F9 Continúa hasta la línea donde tienes el cursor (como un punto de ruptura temporal)
Resume Program F9 Continúa la ejecución normal hasta el siguiente punto de ruptura o el final
Stop Ctrl+F2 Termina la sesión de depuración
Rerun Ctrl+F5 Vuelve a empezar la depuración desde el principio

💡 Consejo: Step Into no entra por defecto en las clases de la biblioteca estándar (String, ArrayList…): IntelliJ las salta para que no te pierdas. Si de verdad quieres entrar, usa Force Step Into (en el menú de acciones de depuración).

3.4. Inspeccionar y modificar variables

  • En el panel Variables ves todas las variables locales y parámetros del marco seleccionado. Los objetos se despliegan para ver sus atributos (incluidos los privados) y los arrays muestran sus elementos.
  • Pasa el ratón por encima de una variable en el editor para ver su valor en una ventana emergente.
  • Set Value (F2 sobre una variable, o clic derecho → Set Value): cambia el valor de una variable en caliente. Muy útil para probar «¿qué pasaría si nota valiera 5?» sin reiniciar.
  • Los valores que acaban de cambiar en el último paso se resaltan en otro color: fíjate en ellos, suelen ser la pista.

3.5. Evaluate Expression

Evaluate Expression (Alt+F8) abre una ventana en la que puedes escribir cualquier expresión Java válida en ese punto y ver su resultado: una operación, una llamada a un método, una condición…

Esquema de la ventana Evaluate con la expresión (double) suma / aprobados Figura 4. Esquema de la ventana Evaluate: ① expresión, ② resultado y ③ botones. Aquí, evaluada al terminar el bucle (suma = 22, aprobados = 3).

También puedes seleccionar una expresión en el editor y pulsar Alt+F8 para evaluarla directamente, o mantener Alt pulsado y hacer clic sobre ella para una evaluación rápida.

⚠️ Importante: Evaluar una expresión ejecuta el código. Si evalúas lista.remove(0) o cuenta.retirar(50), el objeto se modifica de verdad y el programa seguirá con ese cambio.

3.6. Watches

Un watch es una expresión que IntelliJ reevalúa automáticamente cada vez que el programa se detiene. Se añade escribiéndola en el campo ⑥ de la figura 2 y pulsando Ctrl+Shift+Enter, con el botón Add to Watches de la ventana Evaluate, o con clic derecho sobre una variable → Add to Watches. Es perfecto para seguir expresiones que no son variables, como (double) suma / aprobados, notas[i] >= 5 o lista.size().

3.7. Puntos de ruptura condicionales

Si el error aparece en la iteración 9.000 de un bucle, pulsar F9 9.000 veces no es una opción. Un punto de ruptura condicional solo detiene el programa cuando una condición booleana es verdadera.

Esquema del panel de propiedades de un punto de ruptura con la condición nota == 5 Figura 5. Esquema de las propiedades de un punto de ruptura: clic derecho sobre el círculo rojo.

  1. Pon un punto de ruptura normal.
  2. Haz clic derecho sobre el círculo rojo: aparece un panel con Enabled, Suspend y Condition.
  3. Escribe una expresión Java booleana, por ejemplo nota == 5 o i == 9000 o nombre == null, y pulsa Done. El círculo muestra ahora una marca (?).

En More (Ctrl+Shift+F8, ventana View Breakpoints) hay más opciones útiles:

Opción Qué hace
Pass count Se detiene solo cuando la línea se ha alcanzado N veces
Evaluate and log No se detiene: escribe en la consola el valor de una expresión (un println sin tocar el código)
Remove once hit El punto de ruptura se borra tras detenerse una vez
Java Exception Breakpoints Detiene el programa justo cuando se lanza una excepción (por ejemplo, cualquier NullPointerException), aunque no sepas en qué línea

4. Caso práctico: depurar un error lógico paso a paso

Este método debería devolver la media de las notas aprobadas (nota mayor o igual que 5):

public class Estadisticas {

    // Debe devolver la media de las notas aprobadas (nota >= 5)
    static double mediaAprobados(int[] notas) {
        int suma = 0;
        int aprobados = 0;
        for (int i = 0; i < notas.length; i++) {
            int nota = notas[i];
            if (nota > 5) {
                suma += nota;
                aprobados++;
            }
        }
        return suma / aprobados;
    }

    public static void main(String[] args) {
        int[] notas = {7, 4, 5, 9, 6};
        System.out.println("Media de aprobados: " + mediaAprobados(notas));
        // Esperado: (7 + 5 + 9 + 6) / 4 = 6.75
    }
}
Media de aprobados: 7.0

Esperábamos 6.75 y obtenemos 7.0. No hay excepción ni aviso: es un error lógico. Vamos a cazarlo con el depurador.

Paso 1. Formula una hipótesis y elige dónde parar. El resultado depende de suma y aprobados, que se calculan en el bucle. Pon un punto de ruptura en la línea del if (línea 9) y arranca con Debug (Shift+F9).

Paso 2. Avanza con Step Over (F8) y observa. En cada vuelta mira el panel Variables (o los valores en línea). Esta es la evolución real de las variables al final de cada iteración (la generamos ejecutando una copia del método que imprime las variables; con un punto de ruptura Evaluate and log obtendrías lo mismo sin tocar el código):

i=0 nota=7 (nota > 5)=true suma=7 aprobados=1
i=1 nota=4 (nota > 5)=false suma=7 aprobados=1
i=2 nota=5 (nota > 5)=false suma=7 aprobados=1
i=3 nota=9 (nota > 5)=true suma=16 aprobados=2
i=4 nota=6 (nota > 5)=true suma=22 aprobados=3
suma / aprobados = 7   (double) suma / aprobados = 7.333333333333333
Iteración nota nota > 5 suma aprobados ¿Es lo esperado?
0 7 true 7 1 Sí
1 4 false 7 1 Sí
2 5 false 7 1 No: un 5 es un aprobado, debería sumarse
3 9 true 16 2 Arrastra el error anterior
4 6 true 22 3 Arrastra el error anterior

Paso 3. Primer error encontrado. En la iteración 2 (la de la figura 2), con nota = 5, el depurador salta el bloque del if. La condición debería ser nota >= 5. Un error típico de valor límite (lo verás en la sección 6).

Paso 4. Sigue investigando: evalúa la expresión del return. Detente en return suma / aprobados; (con Run to Cursor, Alt+F9) y abre Evaluate (Alt+F8). suma / aprobados vale 7, pero (double) suma / aprobados vale 7.333... (figura 4): hay un segundo error, la división entera. Aunque el método devuelve double, la división se hace entre dos int y pierde los decimales antes de convertirse.

Paso 5. Piensa en otros casos. ¿Y si no hay ningún aprobado? aprobados valdría 0 y suma / aprobados lanzaría ArithmeticException: / by zero. Tercer problema.

Paso 6. Corrige y comprueba.

public class EstadisticasOk {

    static final int NOTA_APROBADO = 5;

    // Devuelve la media de las notas aprobadas (nota >= 5); 0 si no hay ninguna
    static double mediaAprobados(int[] notas) {
        int suma = 0;
        int aprobados = 0;
        for (int nota : notas) {
            if (nota >= NOTA_APROBADO) {          // corrección 1: >= en lugar de >
                suma += nota;
                aprobados++;
            }
        }
        if (aprobados == 0) {                     // corrección 3: evitar dividir entre 0
            return 0;
        }
        return (double) suma / aprobados;         // corrección 2: división real
    }

    public static void main(String[] args) {
        System.out.println("Media de aprobados: " + mediaAprobados(new int[]{7, 4, 5, 9, 6}));
        System.out.println("Sin aprobados: " + mediaAprobados(new int[]{1, 2}));
    }
}
Media de aprobados: 6.75
Sin aprobados: 0.0

💡 Consejo: El método para depurar siempre es el mismo: 1) reproduce el error con una entrada concreta; 2) formula una hipótesis sobre dónde está; 3) detén el programa justo antes y comprueba los valores; 4) si los valores son correctos, el error está más adelante; si no, más atrás; 5) corrige y escribe una prueba que lo demuestre, para que nunca vuelva (sección 8).


5. Pruebas de software

📘 Concepto: Probar es ejecutar un programa con la intención de encontrar errores. Una prueba compara el resultado obtenido con el resultado esperado para unas entradas concretas. Como dijo Edsger Dijkstra, las pruebas pueden demostrar la presencia de errores, pero nunca su ausencia: por eso hay que elegir bien qué probar.

Dos términos que conviene distinguir:

  • Verificación: ¿estamos construyendo el producto correctamente? (¿el código hace lo que dice la especificación?)
  • Validación: ¿estamos construyendo el producto correcto? (¿es lo que necesita el cliente?)

5.1. Niveles de prueba

Pirámide de pruebas con unitarias, integración, sistema y aceptación Figura 6. La pirámide de pruebas: muchas pruebas unitarias rápidas en la base y pocas pruebas de sistema y aceptación en la cima.

Nivel Qué se prueba Quién y cómo
Unitarias Un método o una clase, aislados del resto El programador, con JUnit, en milisegundos
Integración Varias piezas juntas (tu código + base de datos, dos módulos…) El programador o el equipo, de forma automática
Sistema La aplicación completa, de principio a fin El equipo de pruebas (QA)
Aceptación Que cumple lo que pidió el cliente El cliente o usuario final
Regresión Que lo que funcionaba sigue funcionando tras un cambio Reejecutando automáticamente todas las anteriores

5.2. Caja negra y caja blanca

Comparación entre pruebas de caja negra y de caja blanca Figura 7. En caja negra solo conoces la especificación; en caja blanca diseñas las pruebas mirando el código.

5.3. El caso de prueba

Un caso de prueba documenta qué se prueba, con qué datos y qué se espera:

Campo Ejemplo
Identificador CP-03
Descripción Una nota de exactamente 5 es un aprobado (valor límite inferior)
Precondiciones Ninguna
Entrada nota = 5.0
Resultado esperado "Aprobado"
Resultado obtenido (se rellena al ejecutar)
Estado Superada / Fallida

En JUnit, cada caso de prueba se convierte en un método @Test o en una fila de una prueba parametrizada.


6. Caja negra: clases de equivalencia y valores límite

Vamos a probar este método sin mirar su código, solo con su especificación: recibe una nota entre 0 y 10 y devuelve “Suspenso” si es menor que 5, “Aprobado” si es menor que 7, “Notable” si es menor que 9 y “Sobresaliente” en otro caso; si la nota está fuera de rango lanza IllegalArgumentException.

package es.curso.pruebas;

public class Calificador {

    /**
     * Convierte una nota numérica en su calificación.
     * [0, 5) Suspenso, [5, 7) Aprobado, [7, 9) Notable, [9, 10] Sobresaliente.
     */
    public static String calificar(double nota) {
        if (nota < 0 || nota > 10) {
            throw new IllegalArgumentException("Nota fuera de rango: " + nota);
        }
        if (nota < 5) {
            return "Suspenso";
        } else if (nota < 7) {
            return "Aprobado";
        } else if (nota < 9) {
            return "Notable";
        }
        return "Sobresaliente";
    }
}

6.1. Clases de equivalencia

📘 Concepto: Una clase de equivalencia es un conjunto de entradas que el programa debería tratar de la misma forma. Si el programa funciona con un valor de la clase, se supone que funciona con todos; así basta un caso de prueba por clase en lugar de infinitos.

Se identifican clases válidas (entradas correctas) y no válidas (entradas que deben rechazarse):

Clase Condición Tipo Valor representativo Resultado esperado
CE1 nota < 0 No válida -3 IllegalArgumentException
CE2 0 <= nota < 5 Válida 3 "Suspenso"
CE3 5 <= nota < 7 Válida 6 "Aprobado"
CE4 7 <= nota < 9 Válida 8.5 "Notable"
CE5 9 <= nota <= 10 Válida 9.5 "Sobresaliente"
CE6 nota > 10 No válida 11 IllegalArgumentException

6.2. Valores límite

📘 Concepto: La experiencia demuestra que los errores se concentran en las fronteras entre clases (> en lugar de >=, como en la sección 4). El análisis de valores límite añade casos justo en cada frontera y a ambos lados de ella.

Recta numérica con las clases de equivalencia de calificar y sus valores límite Figura 8. Clases de equivalencia de calificar(nota) y valores límite que hay que probar.

Frontera Valores a probar Esperado
0 -0.01, 0 Excepción, "Suspenso"
5 4.99, 5 "Suspenso", "Aprobado"
7 6.99, 7 "Aprobado", "Notable"
9 8.99, 9 "Notable", "Sobresaliente"
10 10, 10.01 "Sobresaliente", excepción

Con 6 clases y 10 valores límite tienes un conjunto de 16 pruebas, pequeño pero muy eficaz. En la sección 8 lo convertirás en código JUnit.


7. Caja blanca: caminos y cobertura

En las pruebas de caja blanca miras el código para asegurarte de que las pruebas recorren todas sus partes.

package es.curso.pruebas;

public class Taquilla {

    public static double precioEntrada(int edad, boolean estudiante) {
        if (edad < 0) {                                     // decisión 1
            throw new IllegalArgumentException("Edad negativa: " + edad);
        }
        double precio = 10.0;
        if (edad < 12 || edad >= 65) {                      // decisión 2
            precio = 5.0;
        } else if (estudiante) {                            // decisión 3
            precio = 7.5;
        }
        return precio;
    }
}

7.1. Grafo de flujo y complejidad ciclomática

  1. Dibuja el grafo de flujo: un nodo por cada bloque de sentencias o decisión, y una arista por cada posible paso de uno a otro.
  2. Calcula la complejidad ciclomática de McCabe, V(G), que es el número de caminos independientes (y por tanto el número mínimo de casos de prueba para recorrerlos todos). Hay varias fórmulas equivalentes:
    • V(G) = número de decisiones + 1
    • V(G) = aristas - nodos + 2
    • V(G) = número de regiones cerradas del grafo + 1
  3. Obtén un conjunto de caminos independientes (cada uno añade al menos una arista no recorrida) y diseña un caso de prueba para cada uno.

Grafo de flujo de precioEntrada con siete nodos y cálculo de V(G) = 4 Figura 9. Grafo de flujo de precioEntrada: tres decisiones, V(G) = 4 y cuatro caminos independientes.

Camino Recorrido Entrada Esperado
C1 1 → 2 → fin edad = -1, estudiante = false IllegalArgumentException
C2 1 → 3 → 4 → 7 edad = 8, estudiante = false 5.0
C3 1 → 3 → 5 → 6 → 7 edad = 30, estudiante = true 7.5
C4 1 → 3 → 5 → 7 edad = 30, estudiante = false 10.0

💡 Consejo: La decisión 2 es compuesta (edad < 12 || edad >= 65). Si cuentas cada condición simple como una decisión, V(G) sube a 5 y añadirías un caso para la segunda condición (por ejemplo, edad = 70). Es lo que se llama cobertura de condiciones, más exigente que la de decisiones.

7.2. Tipos de cobertura

Cobertura Exige que las pruebas… Con nuestros 4 casos
De sentencias (líneas) Ejecuten cada sentencia al menos una vez 100 %
De decisiones (ramas) Hagan cada decisión verdadera y falsa al menos una vez 100 %
De condiciones Hagan cada condición simple verdadera y falsa No: edad >= 65 nunca es verdadera (falta edad = 70)
De caminos Recorran todos los caminos posibles Inviable en general (los bucles generan infinitos)

8. Pruebas unitarias con JUnit

📘 Concepto: JUnit es el framework de pruebas unitarias estándar en Java. Permite escribir las pruebas como métodos Java que se ejecutan automáticamente y comprueban los resultados con aserciones. La versión actual es JUnit 6 (la 6.0 se publicó en septiembre de 2025; en octubre de 2026 la última es la 6.1.3). Requiere Java 17 o superior y unifica la numeración de sus módulos: Platform, Jupiter (la API que usas al escribir pruebas) y Vintage comparten versión.

Una prueba automática es como el control de calidad de una fábrica: cada pieza (método) pasa por una máquina que comprueba sus medidas. Si mañana cambias el proceso de fabricación (refactorizas), la máquina te avisa en segundos de si alguna pieza ha salido mal.

8.1. El proyecto Maven

JUnit no forma parte del JDK: es una dependencia que Maven descarga por ti (verás Maven con más calma en el módulo 20). La estructura estándar separa el código de la aplicación del de las pruebas:

Árbol de carpetas de un proyecto Maven con src/main/java y src/test/java Figura 10. Estructura de un proyecto Maven. Las pruebas de Calificador van en CalificadorTest, en el mismo paquete pero bajo src/test/java.

Este es el pom.xml mínimo del proyecto (puedes crearlo en IntelliJ con New Project → Java → Build system: Maven):

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>es.curso</groupId>
    <artifactId>pruebas</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- JUnit Jupiter: API, pruebas parametrizadas y motor de ejecución -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>6.1.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.16.0</version>
            </plugin>
            <plugin>
                <!-- Ejecuta las pruebas en la fase test -->
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.6.0</version>
            </plugin>
        </plugins>
    </build>
</project>
  • El artefacto junit-jupiter incluye la API (@Test, Assertions), las pruebas parametrizadas (junit-jupiter-params) y el motor que las ejecuta.
  • <scope>test</scope>: JUnit solo está disponible para el código de src/test; no se incluye en la aplicación final.
  • Surefire es el plugin de Maven que ejecuta las pruebas. Detecta JUnit automáticamente.

💡 Consejo: Si no usas Maven, IntelliJ también puede añadir JUnit: escribe @Test en una clase, pulsa Alt+Enter sobre la anotación en rojo y elige Add ‘JUnit’ to classpath. Pero en cuanto el proyecto crezca, usa Maven.

8.2. Anatomía de una prueba

@Test                                              // 1. marca el método como prueba
void seisYMedioEsAprobado() {                      // 2. nombre que describe el comportamiento
    // Preparar (Arrange): los datos de entrada
    double nota = 6.5;
    // Actuar (Act): llamar al método que se prueba
    String resultado = Calificador.calificar(nota);
    // Comprobar (Assert): comparar con lo esperado
    assertEquals("Aprobado", resultado);           // 3. aserción: esperado, obtenido
}
  • La clase de prueba y sus métodos no necesitan ser public en JUnit 5/6 (basta la visibilidad de paquete).
  • Un método de prueba es void y no recibe parámetros (salvo en las parametrizadas).
  • Sigue el patrón AAA (Arrange, Act, Assert): preparar, actuar, comprobar.
  • Si todas las aserciones se cumplen, la prueba pasa (verde). Si una falla, la prueba falla (rojo) y se detiene en esa aserción. Si se lanza una excepción no esperada, la prueba termina en error.

8.3. Aserciones

Todas están en la clase org.junit.jupiter.api.Assertions y se suelen importar de forma estática (import static org.junit.jupiter.api.Assertions.*;).

Aserción Comprueba que…
assertEquals(esperado, obtenido) Son iguales (con equals). El esperado va primero
assertEquals(esperado, obtenido, delta) Dos double difieren menos que delta (nunca compares double con ==)
assertNotEquals(a, b) Son distintos
assertTrue(condicion) / assertFalse(condicion) La condición es verdadera / falsa
assertNull(x) / assertNotNull(x) Es / no es null
assertSame(a, b) Son el mismo objeto (==)
assertArrayEquals(esperado, obtenido) Dos arrays tienen los mismos elementos en el mismo orden
assertIterableEquals(esperado, obtenido) Dos colecciones iterables tienen los mismos elementos en orden
assertThrows(Tipo.class, () -> codigo) El código lanza una excepción de ese tipo; la devuelve para inspeccionarla
assertDoesNotThrow(() -> codigo) El código no lanza ninguna excepción
assertAll(() -> ..., () -> ...) Ejecuta todas las aserciones y informa de todos los fallos juntos
assertTimeout(Duration.ofMillis(100), () -> codigo) El código termina antes del tiempo indicado
fail("mensaje") Hace fallar la prueba directamente

Todas aceptan un último parámetro opcional con un mensaje que se muestra si fallan: assertEquals(10, total, "El total debe incluir el IVA").

⚠️ Importante: assertThrows recibe una lambda (() -> Calificador.calificar(-1)), no la llamada directa. Si escribieras assertThrows(IllegalArgumentException.class, Calificador.calificar(-1)), la excepción saltaría antes de entrar en assertThrows y la prueba fallaría con error. Las lambdas las verás a fondo en el curso avanzado; de momento basta con la forma () -> expresión.

8.4. Primer conjunto de pruebas: CalificadorTest

package es.curso.pruebas;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("Calificador: de nota numérica a calificación")
class CalificadorTest {

    @Test
    @DisplayName("Un 6,5 es un Aprobado")
    void seisYMedioEsAprobado() {
        assertEquals("Aprobado", Calificador.calificar(6.5));
    }

    @Test
    void notasNegativasLanzanExcepcion() {
        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
                () -> Calificador.calificar(-0.5));
        assertTrue(e.getMessage().contains("fuera de rango"));
    }

    @Test
    @DisplayName("Valores límite de cada tramo")
    void valoresLimite() {
        assertAll(
                () -> assertEquals("Suspenso", Calificador.calificar(0)),
                () -> assertEquals("Suspenso", Calificador.calificar(4.99)),
                () -> assertEquals("Aprobado", Calificador.calificar(5)),
                () -> assertEquals("Notable", Calificador.calificar(7)),
                () -> assertEquals("Sobresaliente", Calificador.calificar(9)),
                () -> assertEquals("Sobresaliente", Calificador.calificar(10)));
    }

    @ParameterizedTest(name = "{0} -> {1}")
    @CsvSource({
            "3.0,  Suspenso",
            "5.0,  Aprobado",
            "6.99, Aprobado",
            "8.5,  Notable",
            "9.0,  Sobresaliente"
    })
    void calificacionesRepresentativas(double nota, String esperada) {
        assertEquals(esperada, Calificador.calificar(nota));
    }

    @ParameterizedTest(name = "nota {0} no válida")
    @ValueSource(doubles = {-1, -0.01, 10.01, 11})
    void fueraDeRango(double nota) {
        assertThrows(IllegalArgumentException.class, () -> Calificador.calificar(nota));
    }
}
  • @DisplayName pone un nombre legible (con espacios, tildes…) a la clase o al método, que es el que aparece en los informes.
  • assertAll agrupa varias aserciones: si fallan dos, verás las dos en el informe, en lugar de detenerse en la primera.
  • assertThrows devuelve la excepción capturada, así puedes comprobar también su mensaje.

8.5. Pruebas parametrizadas

Cuando quieres ejecutar la misma prueba con distintos datos, en lugar de copiar y pegar métodos usa @ParameterizedTest junto con una fuente de argumentos:

Fuente Ejemplo Uso
@ValueSource @ValueSource(ints = {1, 2, 3}) Un solo parámetro: ints, doubles, strings, booleans…
@CsvSource @CsvSource({"3.0, Suspenso", "8.5, Notable"}) Varios parámetros por fila, separados por comas. Las cadenas con comas o vacías van entre comillas simples: 'a, b', ''
@CsvFileSource @CsvFileSource(resources = "/notas.csv", numLinesToSkip = 1) Igual, pero leyendo un CSV de src/test/resources
@NullSource, @EmptySource, @NullAndEmptySource   Añaden null y/o la cadena vacía como casos
@EnumSource @EnumSource(DiaSemana.class) Un caso por cada valor de un enum
@MethodSource @MethodSource("datos") Los argumentos los genera un método estático

El atributo name de @ParameterizedTest define cómo se muestra cada caso: {0}, {1}… son los argumentos. Cada fila cuenta como una prueba distinta: por eso CalificadorTest tiene 5 métodos pero ejecuta 12 pruebas (3 + 5 + 4).

8.6. Ciclo de vida: @BeforeEach, @AfterEach, @BeforeAll y @AfterAll

Muchas pruebas necesitan los mismos objetos de partida (el fixture). En lugar de crearlos en cada prueba:

Anotación Se ejecuta… Uso típico
@BeforeEach Antes de cada prueba Crear objetos nuevos para que cada prueba empiece limpia
@AfterEach Después de cada prueba Liberar recursos, borrar ficheros temporales
@BeforeAll Una vez, antes de todas (método static) Operaciones costosas: abrir una conexión, cargar datos
@AfterAll Una vez, después de todas (método static) Cerrar lo abierto en @BeforeAll
@Disabled("motivo") Nunca: la prueba se salta Pruebas pendientes (indica siempre el motivo)

Esta es la clase Cuenta que vamos a probar:

package es.curso.pruebas;

public class Cuenta {
    private final String titular;
    private double saldo;

    public Cuenta(String titular, double saldoInicial) {
        if (titular == null || titular.isBlank()) {
            throw new IllegalArgumentException("El titular es obligatorio");
        }
        if (saldoInicial < 0) {
            throw new IllegalArgumentException("Saldo inicial negativo");
        }
        this.titular = titular;
        this.saldo = saldoInicial;
    }

    public void ingresar(double cantidad) {
        if (cantidad <= 0) {
            throw new IllegalArgumentException("Cantidad no válida: " + cantidad);
        }
        saldo += cantidad;
    }

    public void retirar(double cantidad) {
        if (cantidad <= 0) {
            throw new IllegalArgumentException("Cantidad no válida: " + cantidad);
        }
        if (cantidad > saldo) {
            throw new IllegalStateException("Saldo insuficiente");
        }
        saldo -= cantidad;
    }

    public double getSaldo() { return saldo; }
    public String getTitular() { return titular; }
}

Y sus pruebas, con mensajes por consola para que veas el orden de ejecución:

package es.curso.pruebas;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("Cuenta bancaria")
class CuentaTest {

    private Cuenta cuenta;          // "fixture": se recrea antes de cada prueba

    @BeforeAll
    static void antesDeTodas() {
        System.out.println(">> Empiezan las pruebas de Cuenta");
    }

    @BeforeEach
    void setUp() {
        cuenta = new Cuenta("Ana", 100.0);
        System.out.println("   nueva cuenta con 100 €");
    }

    @AfterEach
    void tearDown() {
        System.out.println("   saldo final: " + cuenta.getSaldo());
    }

    @AfterAll
    static void despuesDeTodas() {
        System.out.println("<< Terminan las pruebas de Cuenta");
    }

    @Test
    @DisplayName("Ingresar suma al saldo")
    void ingresarSumaAlSaldo() {
        cuenta.ingresar(50.5);
        assertEquals(150.5, cuenta.getSaldo(), 0.001);   // delta para comparar double
    }

    @Test
    @DisplayName("Retirar más del saldo lanza IllegalStateException y no cambia el saldo")
    void retirarDemasiado() {
        assertThrows(IllegalStateException.class, () -> cuenta.retirar(500));
        assertEquals(100.0, cuenta.getSaldo(), 0.001);
    }

    @Test
    @DisplayName("Una cuenta nueva guarda titular y saldo")
    void estadoInicial() {
        assertAll("cuenta nueva",
                () -> assertEquals("Ana", cuenta.getTitular()),
                () -> assertEquals(100.0, cuenta.getSaldo(), 0.001),
                () -> assertNotNull(cuenta));
    }

    @Test
    @DisplayName("No se admite un titular vacío")
    void titularVacio() {
        assertThrows(IllegalArgumentException.class, () -> new Cuenta("  ", 10));
    }

    @Test
    @Disabled("Pendiente: las transferencias se implementan en la versión 2")
    void transferir() {
        fail("Aún no implementado");
    }
}

⚠️ Importante: JUnit no garantiza el orden en que se ejecutan los métodos de prueba, y crea un objeto nuevo de la clase de prueba para cada método. Por eso cada prueba debe ser independiente: nunca hagas que una prueba dependa de lo que dejó otra. Fíjate en la salida de la sección 9.2: ingresarSumaAlSaldo deja el saldo en 150.5, pero la siguiente prueba vuelve a empezar con 100 € gracias a @BeforeEach.

8.7. Pruebas de caja blanca en JUnit

Los cuatro caminos de la sección 7 se traducen directamente en cuatro pruebas:

package es.curso.pruebas;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

// Un caso de prueba por cada camino independiente del grafo de flujo
class TaquillaTest {

    @Test
    void camino1EdadNegativa() {
        assertThrows(IllegalArgumentException.class, () -> Taquilla.precioEntrada(-1, false));
    }

    @Test
    void camino2NinoOJubilado() {
        assertEquals(5.0, Taquilla.precioEntrada(8, false));
    }

    @Test
    void camino3AdultoEstudiante() {
        assertEquals(7.5, Taquilla.precioEntrada(30, true));
    }

    @Test
    void camino4AdultoNoEstudiante() {
        assertEquals(10.0, Taquilla.precioEntrada(30, false));
    }
}

9. Ejecutar las pruebas

9.1. En IntelliJ IDEA

  • Junto a la clase de prueba y a cada método @Test aparece en el canal un ▶ verde: clic → Run ‘CalificadorTest’. También Ctrl+Shift+F10 con el cursor dentro de la clase o del método.
  • Para ejecutar todas las pruebas: clic derecho sobre src/test/java → Run ‘All Tests’.
  • Para depurar una prueba: clic en el ▶ → Debug. Es la forma más cómoda de investigar por qué falla.

Esquema de la ventana Run con el árbol de pruebas superadas y la consola Figura 11. Esquema de la ventana de resultados de pruebas de IntelliJ IDEA.

Nº Elemento Descripción
① Barra de herramientas Volver a ejecutar, Rerun Failed Tests (solo las que fallaron), detener, Show Passed / Show Ignored (filtros), opciones de ordenación y más (Export Test Results, historial…)
② Barra de estado Resumen: verde si todo pasa (Tests passed: 20, ignored: 1 of 21 tests), rojo si algo falla (Tests failed: 1, passed: 19...)
③ Árbol de pruebas Una rama por clase (con su @DisplayName) y una hoja por prueba, con su duración. Doble clic → va al código
④ Prueba ignorada Las @Disabled aparecen con un icono gris
⑤ Consola Salida de la prueba seleccionada; si falla, el mensaje expected: <...> but was: <...> con un enlace Click to see difference

9.2. Con Maven

Desde la carpeta del proyecto, mvn test compila el código y las pruebas y las ejecuta con Surefire. Esta es la parte relevante de la salida real (JDK 25, Maven 3.9, JUnit 6.1.3):

[INFO] --- surefire:3.6.0:test (default-test) @ pruebas ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] 
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running Calificador: de nota numérica a calificación
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 16.03 s -- in Calificador: de nota numérica a calificación
[INFO] Running Cuenta bancaria
>> Empiezan las pruebas de Cuenta
   nueva cuenta con 100 €
   saldo final: 100.0
   nueva cuenta con 100 €
   saldo final: 150.5
   nueva cuenta con 100 €
   saldo final: 100.0
   nueva cuenta con 100 €
   saldo final: 100.0
<< Terminan las pruebas de Cuenta
[WARNING] Tests run: 5, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 3.024 s -- in Cuenta bancaria
[INFO] Running es.curso.pruebas.TaquillaTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.411 s -- in es.curso.pruebas.TaquillaTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 1
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

(Los tiempos se midieron en un contenedor Docker con el equipo muy cargado; en un ordenador normal estas pruebas tardan milisegundos.)

Con mvn -q test (quiet) Maven solo muestra los errores. Si todo va bien, la única salida es la que imprimen las propias pruebas:

>> Empiezan las pruebas de Cuenta
   nueva cuenta con 100 €
   saldo final: 100.0
   nueva cuenta con 100 €
   saldo final: 150.5
   nueva cuenta con 100 €
   saldo final: 100.0
   nueva cuenta con 100 €
   saldo final: 100.0
<< Terminan las pruebas de Cuenta

¿Y cuando una prueba falla? Introducimos un error a propósito en Calificador: cambiamos nota < 7 por nota <= 7. Ejecutando solo esa clase con mvn test -Dtest=CalificadorTest:

[ERROR] Tests run: 12, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 17.25 s <<< FAILURE! -- in Calificador: de nota numérica a calificación
[ERROR] es.curso.pruebas.CalificadorTest.valoresLimite -- Time elapsed: 0.327 s <<< FAILURE!
org.opentest4j.MultipleFailuresError: 
Multiple Failures (1 failure)
	org.opentest4j.AssertionFailedError: expected: <Notable> but was: <Aprobado>
	at org.junit.jupiter.api.AssertAll.assertAll(AssertAll.java:80)
	[...]
	at es.curso.pruebas.CalificadorTest.valoresLimite(CalificadorTest.java:30)

[INFO] Results:
[INFO] 
[ERROR] Failures: 
[ERROR]   CalificadorTest.valoresLimite:30 Multiple Failures (1 failure)
	org.opentest4j.AssertionFailedError: expected: <Notable> but was: <Aprobado>
[INFO] 
[ERROR] Tests run: 12, Failures: 1, Errors: 0, Skipped: 0
[INFO] 
[INFO] BUILD FAILURE

Observa que solo la prueba de valores límite detectó el error: las pruebas con valores representativos (6.99, 8.5…) siguieron pasando. Es la demostración práctica de por qué hay que probar las fronteras.

En el informe Significa
Failures Una aserción no se cumplió: el código da un resultado incorrecto
Errors Se lanzó una excepción inesperada (por ejemplo, NullPointerException)
Skipped Pruebas @Disabled
BUILD FAILURE Maven se detiene: no generará el .jar mientras haya pruebas en rojo

10. TDD: desarrollo guiado por pruebas

📘 Concepto: En el desarrollo guiado por pruebas (Test-Driven Development, TDD), popularizado por Kent Beck, la prueba se escribe antes que el código. Se avanza en ciclos muy cortos de tres fases: rojo (escribe una prueba que falle), verde (escribe el código mínimo para que pase) y refactorizar (mejora el código manteniendo las pruebas en verde).

Ciclo rojo, verde, refactorizar de TDD Figura 12. El ciclo de TDD.

Ventajas: el diseño nace pensando en cómo se usa el código; todo el código tiene pruebas desde el principio; y puedes refactorizar sin miedo. Vamos a verlo con un ejemplo completo: un validador de DNI español. La letra del DNI es la posición numero % 23 en la cadena TRWAGMYFPDXBNJZSQVHLCKE.

Paso 1 (rojo): la primera prueba

Escribimos la prueba sin que exista la clase. Así decidimos cómo queremos usarla: ValidadorDni.calcularLetra(12345678) debe devolver 'Z'.

package es.curso.dni;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ValidadorDniTest {

    @Test
    void laLetraDe12345678EsZ() {
        assertEquals('Z', ValidadorDni.calcularLetra(12345678));
    }
}

mvn -q test falla porque ni siquiera compila. En TDD, no compilar cuenta como rojo:

[ERROR] COMPILATION ERROR : 
[ERROR] /tmp/p/src/test/java/es/curso/dni/ValidadorDniTest.java:[11,27] cannot find symbol
  symbol:   variable ValidadorDni
  location: class es.curso.dni.ValidadorDniTest
[...]

Paso 2 (verde): el código mínimo

Escribimos lo mínimo para pasar la prueba, aunque parezca una trampa (fake it):

package es.curso.dni;

public class ValidadorDni {

    public static char calcularLetra(int numero) {
        return 'Z';      // lo mínimo para pasar la prueba ("fake it")
    }
}

mvn -q test no muestra nada: todo en verde. ¿Por qué hacer esta trampa? Porque nos obliga a escribir otra prueba que la desmonte. Las pruebas, no nuestra intuición, son las que fuerzan el código.

Paso 3 (rojo): triangular con una segunda prueba

Añadimos a ValidadorDniTest un segundo caso:

@Test
void laLetraDeCeroEsT() {
    assertEquals('T', ValidadorDni.calcularLetra(0));
}
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 5.862 s <<< FAILURE! -- in es.curso.dni.ValidadorDniTest
[ERROR] es.curso.dni.ValidadorDniTest.laLetraDeCeroEsT -- Time elapsed: 2.459 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <T> but was: <Z>
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1091)
	at es.curso.dni.ValidadorDniTest.laLetraDeCeroEsT(ValidadorDniTest.java:16)

[ERROR] Failures: 
[ERROR]   ValidadorDniTest.laLetraDeCeroEsT:16 expected: <T> but was: <Z>
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0

Paso 4 (verde): la implementación real

Ahora la constante ya no sirve y escribimos el algoritmo:

package es.curso.dni;

public class ValidadorDni {

    public static char calcularLetra(int numero) {
        return "TRWAGMYFPDXBNJZSQVHLCKE".charAt(numero % 23);
    }
}

mvn -q test → sin salida: las dos pruebas pasan.

Paso 5 (rojo): validar un DNI completo

Siguiente requisito: esValido(String dni) comprueba un DNI con su letra. Especificamos con pruebas el comportamiento deseado, incluidos los casos raros (letra en minúscula, null, formatos incorrectos):

package es.curso.dni;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.*;

class ValidadorDniTest {

    @Test
    void laLetraDe12345678EsZ() {
        assertEquals('Z', ValidadorDni.calcularLetra(12345678));
    }

    @Test
    void laLetraDeCeroEsT() {
        assertEquals('T', ValidadorDni.calcularLetra(0));
    }

    @Test
    void unDniConSuLetraCorrectaEsValido() {
        assertTrue(ValidadorDni.esValido("12345678Z"));
    }

    @Test
    void unDniConOtraLetraNoEsValido() {
        assertFalse(ValidadorDni.esValido("12345678A"));
    }

    @Test
    void laLetraPuedeIrEnMinuscula() {
        assertTrue(ValidadorDni.esValido("12345678z"));
    }

    @ParameterizedTest(name = "\"{0}\" no es válido")
    @NullAndEmptySource
    @ValueSource(strings = {"1234567Z", "123456789Z", "1234A678Z", "12345678", "12345678-Z"})
    void formatosIncorrectosNoSonValidos(String dni) {
        assertFalse(ValidadorDni.esValido(dni));
    }
}

Una primera implementación ingenua, pensando solo en el caso feliz:

public static boolean esValido(String dni) {
    int numero = Integer.parseInt(dni.substring(0, 8));
    return dni.charAt(8) == calcularLetra(numero);
}

Las pruebas la destrozan (salida resumida):

[ERROR] Tests run: 12, Failures: 1, Errors: 5, Skipped: 0, Time elapsed: 17.67 s <<< FAILURE! -- in es.curso.dni.ValidadorDniTest
[...]
[ERROR] Failures: 
[ERROR]   ValidadorDniTest.laLetraPuedeIrEnMinuscula:34 expected: <true> but was: <false>
[ERROR] Errors: 
[ERROR]   ValidadorDniTest.formatosIncorrectosNoSonValidos:41 » NullPointer Cannot invoke "String.substring(int, int)" because "dni" is null
[ERROR]   ValidadorDniTest.formatosIncorrectosNoSonValidos:41 » StringIndexOutOfBounds Range [0, 8) out of bounds for length 0
[ERROR]   ValidadorDniTest.formatosIncorrectosNoSonValidos:41 » NumberFormat For input string: "1234567Z"
[ERROR]   ValidadorDniTest.formatosIncorrectosNoSonValidos:41 » NumberFormat For input string: "1234A678"
[ERROR]   ValidadorDniTest.formatosIncorrectosNoSonValidos:41 » StringIndexOutOfBounds Index 8 out of bounds for length 8
[ERROR] Tests run: 12, Failures: 1, Errors: 5, Skipped: 0

Un Failure (la minúscula da false) y cinco Errors (excepciones inesperadas con entradas mal formadas). Sin estas pruebas, esos errores habrían llegado al usuario.

Paso 6 (verde + refactorizar): versión final

Validamos el formato con una expresión regular (módulo 9), normalizamos la letra y, ya en verde, refactorizamos: extraemos constantes, usamos LETRAS.length() en lugar del número mágico 23 y hacemos privado el constructor de esta clase de utilidad.

package es.curso.dni;

/** Valida DNI españoles: 8 cifras y una letra de control. */
public class ValidadorDni {

    private static final String LETRAS = "TRWAGMYFPDXBNJZSQVHLCKE";
    private static final String FORMATO = "\\d{8}[A-Za-z]";   // 8 dígitos y 1 letra

    private ValidadorDni() { }      // clase de utilidad: no se instancia

    public static char calcularLetra(int numero) {
        return LETRAS.charAt(numero % LETRAS.length());
    }

    public static boolean esValido(String dni) {
        if (dni == null || !dni.matches(FORMATO)) {
            return false;
        }
        int numero = Integer.parseInt(dni.substring(0, 8));
        char letra = Character.toUpperCase(dni.charAt(8));
        return letra == calcularLetra(numero);
    }
}
[INFO] Running es.curso.dni.ValidadorDniTest
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.45 s -- in es.curso.dni.ValidadorDniTest
[INFO] 
[INFO] Results:
[INFO] 
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] BUILD SUCCESS

💡 Consejo: Las tres reglas de TDD según Robert C. Martin: 1) no escribas código de producción salvo para hacer pasar una prueba que falla; 2) no escribas más prueba de la necesaria para que falle (no compilar también es fallar); 3) no escribas más código del necesario para pasar la prueba. Al principio parece lento; con la práctica, ahorra muchas horas de depuración.


11. Cobertura de código

📘 Concepto: La cobertura de código mide qué porcentaje del código (líneas, instrucciones, ramas) se ejecuta durante las pruebas. No dice si las pruebas son buenas, pero sí qué partes no se han probado nunca.

11.1. Con JaCoCo y Maven

JaCoCo (Java Code Coverage) es la herramienta de cobertura libre más usada. Se añade al pom.xml como un plugin más (dentro de <plugins>):

<plugin>
    <!-- Mide la cobertura de las pruebas y genera un informe HTML -->
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.15</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>informe</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Al ejecutar mvn test, tras las pruebas aparece:

[INFO] --- jacoco:0.8.15:report (informe) @ pruebas ---
[INFO] Loading execution data file /tmp/p/target/jacoco.exec
[INFO] Analyzed bundle 'pruebas' with 3 classes

y el informe queda en target/site/jacoco/index.html. Estas son capturas reales del informe de nuestro proyecto abierto en el navegador:

Informe de JaCoCo con la cobertura de Cuenta, Calificador y Taquilla Figura 13. Resumen de cobertura por clase: Cuenta solo tiene el 58 % de las ramas cubiertas.

Código de Cuenta.java coloreado por JaCoCo: líneas verdes, amarillas y rojas Figura 14. Detalle de Cuenta.java: verde = ejecutada, rojo = nunca ejecutada, amarillo = decisión con alguna rama sin probar.

El informe delata justo lo que nos faltó probar en CuentaTest: un saldo inicial negativo, ingresar o retirar cantidades no positivas y una retirada correcta (¡la línea saldo -= cantidad nunca se ejecutó!). Calificador y Taquilla aparecen con una línea sin cubrir: es el constructor por defecto, que nunca se llama porque sus métodos son static.

11.2. En IntelliJ IDEA

Clic en el ▶ junto a la clase de prueba → Run ‘CalificadorTest’ with Coverage. IntelliJ ejecuta las pruebas y muestra la ventana Coverage con el porcentaje de clases, métodos, líneas y ramas, y colorea el canal del editor en verde y rojo igual que JaCoCo.

⚠️ Importante: Una cobertura del 100 % no significa que no haya errores: una prueba puede ejecutar una línea sin comprobar su resultado (sin aserciones). Úsala como un detector de zonas sin probar, no como un objetivo en sí mismo. En muchos equipos se considera razonable un 70-80 % de cobertura de líneas en la lógica de negocio.


12. Buenas prácticas y errores comunes

Las buenas pruebas unitarias cumplen el acrónimo FIRST:

Letra Principio Significado
F Fast (rápidas) Milisegundos: si tardan, nadie las ejecuta
I Independent (independientes) No dependen del orden ni unas de otras
R Repeatable (repetibles) Mismo resultado siempre y en cualquier máquina (cuidado con fechas actuales, aleatorios, ficheros…)
S Self-validating (autoverificables) Pasan o fallan solas, sin que tengas que mirar la consola
T Timely (oportunas) Se escriben a la vez que el código (o antes, con TDD)
Error Consecuencia Solución
assertEquals(obtenido, esperado) (al revés) El mensaje de fallo confunde: expected: <lo que dio> but was: <lo correcto> El esperado primero
Comparar double sin delta Falla por errores de redondeo (0.1 + 0.2 != 0.3) assertEquals(0.3, x, 1e-9)
Pruebas sin aserciones Siempre pasan, no prueban nada Al menos una aserción por prueba
Un método @Test que lo prueba todo Si falla, no sabes qué Una prueba = un comportamiento, con un nombre que lo describa
Usar System.out.println para «ver» si funciona No es automático Aserciones
Pruebas que dependen de LocalDate.now() o Random No son repetibles Pasa la fecha o la semilla como parámetro
Probar métodos privados Acoplas las pruebas a la implementación Prueba a través de los métodos públicos
Comentar una prueba que falla «para que compile» Escondes un error Arregla el código o márcala con @Disabled("motivo")
Ejecutar con Run esperando que pare en un breakpoint No se detiene Usa Debug (Shift+F9)

13. Ejercicios

Ejercicio 1: Clasifica los errores

Indica si cada fragmento produce un error de compilación, de ejecución o lógico, y corrígelo:

  1. String s = null; System.out.println(s.length());
  2. int media = (8 + 7) / 2; // se esperaba 7.5
  3. for (int i = 0; i <= v.length; i++) suma += v[i];
  4. boolean iguales = nombre1 == nombre2; // dos String leídos con Scanner
  5. double d = 3.5; int n = d;
  6. if (edad > 18) System.out.println("Mayor de edad"); // con edad = 18
Ver solución
Nº Tipo Explicación Corrección
1 Ejecución NullPointerException al llamar a un método sobre null Comprobar if (s != null) o inicializar s
2 Lógico División entera: da 7 (8 + 7) / 2.0 y variable double
3 Ejecución En la última vuelta i == v.length: ArrayIndexOutOfBoundsException i < v.length
4 Lógico == compara referencias, no el contenido nombre1.equals(nombre2)
5 Compilación incompatible types: possible lossy conversion from double to int int n = (int) d; (si de verdad quieres truncar)
6 Lógico Con 18 años no muestra nada: error de valor límite edad >= 18

Ejercicio 2: Diseña casos de caja negra

El método double precioEnvio(double kilos) cobra 5 € hasta 2 kg (incluidos), 8 € de más de 2 kg hasta 10 kg (incluidos) y 8 € + 1 € por cada kg (o fracción) por encima de 10, hasta un máximo de 30 kg. Pesos menores o iguales que 0 o mayores que 30 lanzan IllegalArgumentException. Obtén las clases de equivalencia y los valores límite.

Ver solución
Clase Condición Tipo Representativo Esperado
CE1 kilos <= 0 No válida -1 Excepción
CE2 0 < kilos <= 2 Válida 1.5 5.0
CE3 2 < kilos <= 10 Válida 6 8.0
CE4 10 < kilos <= 30 Válida 12.5 8 + 3 = 11.0
CE5 kilos > 30 No válida 45 Excepción

Valores límite: 0 (excepción), 0.01 (5 €), 2 (5 €), 2.01 (8 €), 10 (8 €), 10.01 (9 €: una fracción de kg cuenta como kg entero), 30 (28 €) y 30.01 (excepción). Fíjate en que el enunciado obliga a decidir qué pasa con «fracción»: las pruebas de valores límite ayudan a descubrir ambigüedades en la especificación.

Ejercicio 3: Prueba una pila genérica

Escribe las pruebas JUnit de esta clase Pila<T>: una pila nueva está vacía; desapila en orden inverso (LIFO); cima() devuelve el último sin quitarlo; desapilar una pila vacía lanza NoSuchElementException con el mensaje «La pila está vacía».

package es.curso.ejercicios;

import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;

/** Pila genérica LIFO (el último en entrar es el primero en salir). */
public class Pila<T> {
    private final List<T> elementos = new ArrayList<>();

    public void apilar(T elemento) {
        elementos.add(elemento);
    }

    public T desapilar() {
        if (estaVacia()) {
            throw new NoSuchElementException("La pila está vacía");
        }
        return elementos.removeLast();      // removeLast(): Java 21
    }

    public T cima() {
        if (estaVacia()) {
            throw new NoSuchElementException("La pila está vacía");
        }
        return elementos.getLast();
    }

    public boolean estaVacia() { return elementos.isEmpty(); }

    public int tamano() { return elementos.size(); }
}
Ver solución
package es.curso.ejercicios;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.NoSuchElementException;

import static org.junit.jupiter.api.Assertions.*;

class PilaTest {

    private Pila<String> pila;

    @BeforeEach
    void setUp() {
        pila = new Pila<>();
    }

    @Test
    @DisplayName("Una pila nueva está vacía")
    void nuevaEstaVacia() {
        assertTrue(pila.estaVacia());
        assertEquals(0, pila.tamano());
    }

    @Test
    @DisplayName("Desapila en orden inverso al de entrada (LIFO)")
    void ordenLifo() {
        pila.apilar("a");
        pila.apilar("b");
        pila.apilar("c");
        assertAll(
                () -> assertEquals("c", pila.desapilar()),
                () -> assertEquals("b", pila.desapilar()),
                () -> assertEquals("a", pila.desapilar()),
                () -> assertTrue(pila.estaVacia()));
    }

    @Test
    @DisplayName("cima() devuelve el último sin quitarlo")
    void cimaNoQuita() {
        pila.apilar("x");
        assertEquals("x", pila.cima());
        assertEquals(1, pila.tamano());
    }

    @Test
    @DisplayName("Desapilar una pila vacía lanza NoSuchElementException")
    void desapilarVacia() {
        NoSuchElementException e = assertThrows(NoSuchElementException.class, () -> pila.desapilar());
        assertEquals("La pila está vacía", e.getMessage());
    }
}
[INFO] Running es.curso.ejercicios.PilaTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.846 s -- in es.curso.ejercicios.PilaTest

@BeforeEach garantiza una pila vacía nueva para cada prueba, de modo que el orden de ejecución no influye.

Ejercicio 4: Años bisiestos con una prueba parametrizada

Implementa Fechas.esBisiesto(int anio) (divisible entre 4, salvo los divisibles entre 100 que no lo sean entre 400) y pruébalo con una sola prueba parametrizada que cubra todos los casos de la regla.

Ver solución
package es.curso.ejercicios;

public class Fechas {

    /** Bisiesto: divisible entre 4, salvo los divisibles entre 100 que no lo sean entre 400. */
    public static boolean esBisiesto(int anio) {
        return (anio % 4 == 0 && anio % 100 != 0) || anio % 400 == 0;
    }
}
package es.curso.ejercicios;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;

class FechasTest {

    @ParameterizedTest(name = "{0} -> bisiesto: {1}")
    @CsvSource({
            "2024, true",    // divisible entre 4
            "2026, false",   // no divisible entre 4
            "1900, false",   // divisible entre 100 pero no entre 400
            "2000, true",    // divisible entre 400
            "2100, false",
            "4,    true"
    })
    void esBisiesto(int anio, boolean esperado) {
        assertEquals(esperado, Fechas.esBisiesto(anio));
    }
}
[INFO] Running es.curso.ejercicios.FechasTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.951 s -- in es.curso.ejercicios.FechasTest

Cada fila de @CsvSource es una clase de equivalencia de la regla; JUnit convierte automáticamente "true" en boolean y "2024" en int.

Ejercicio 5: Caja blanca de un clasificador de triángulos

Para el método siguiente, calcula la complejidad ciclomática, obtén los caminos independientes y escribe una prueba por camino.

package es.curso.ejercicios;

public class Triangulo {

    public static String clasificar(int a, int b, int c) {
        if (a <= 0 || b <= 0 || c <= 0) {                       // D1
            throw new IllegalArgumentException("Lados no positivos");
        }
        if (a + b <= c || a + c <= b || b + c <= a) {           // D2
            return "No es triángulo";
        }
        if (a == b && b == c) {                                 // D3
            return "Equilátero";
        }
        if (a == b || b == c || a == c) {                       // D4
            return "Isósceles";
        }
        return "Escaleno";
    }
}
Ver solución

Hay 4 decisiones (D1 a D4), así que V(G) = 4 + 1 = 5 caminos independientes:

Camino Decisiones Entrada Esperado
C1 D1 verdadera (0, 3, 4) Excepción
C2 D1 falsa, D2 verdadera (1, 2, 3) "No es triángulo" (valor límite: 1 + 2 == 3)
C3 D1, D2 falsas, D3 verdadera (5, 5, 5) "Equilátero"
C4 D1, D2, D3 falsas, D4 verdadera (5, 5, 8) "Isósceles"
C5 Todas falsas (3, 4, 5) "Escaleno"
package es.curso.ejercicios;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.*;

class TrianguloTest {

    @Test
    void camino1LadoNoPositivo() {
        assertThrows(IllegalArgumentException.class, () -> Triangulo.clasificar(0, 3, 4));
    }

    @ParameterizedTest(name = "({0}, {1}, {2}) -> {3}")
    @CsvSource({
            "1, 2, 3,  No es triángulo",   // camino 2: 1 + 2 <= 3 (valor límite)
            "5, 5, 5,  Equilátero",        // camino 3
            "5, 5, 8,  Isósceles",         // camino 4
            "3, 4, 5,  Escaleno"           // camino 5
    })
    void caminosIndependientes(int a, int b, int c, String esperado) {
        assertEquals(esperado, Triangulo.clasificar(a, b, c));
    }
}
[INFO] Running es.curso.ejercicios.TrianguloTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.366 s -- in es.curso.ejercicios.TrianguloTest

Para cobertura de condiciones harían falta más casos, por ejemplo (3, 0, 4), (3, 4, 0) en D1 o (5, 8, 5) y (8, 5, 5) en D4.

Ejercicio 6: Depura el máximo

Este programa debería mostrar 9 y -1, pero muestra 9 y 0. Usa el depurador para encontrar el error, corrígelo y añade la validación que consideres necesaria.

public class Maximo {

    static int maximo(int[] v) {
        int max = 0;
        for (int x : v) {
            if (x > max) {
                max = x;
            }
        }
        return max;
    }

    public static void main(String[] args) {
        System.out.println(maximo(new int[]{4, 9, 2}));      // esperado 9
        System.out.println(maximo(new int[]{-3, -7, -1}));   // esperado -1
    }
}
9
0
Ver solución

Con un punto de ruptura en el if y la segunda llamada, al pulsar F8 se ve que x vale -3, -7 y -1, y la condición x > max es siempre falsa porque max empezó en 0, un valor que no está en el array. El error es la inicialización: hay que empezar por el primer elemento. Además, un array vacío no tiene máximo, así que lo rechazamos.

public class MaximoOk {

    static int maximo(int[] v) {
        if (v == null || v.length == 0) {
            throw new IllegalArgumentException("El array no puede estar vacío");
        }
        int max = v[0];                 // empezar por un elemento real, no por 0
        for (int x : v) {
            if (x > max) {
                max = x;
            }
        }
        return max;
    }

    public static void main(String[] args) {
        System.out.println(maximo(new int[]{4, 9, 2}));
        System.out.println(maximo(new int[]{-3, -7, -1}));
    }
}
9
-1

La lección: las pruebas del primer programa solo usaban números positivos. Una clase de equivalencia «todos negativos» habría encontrado el error sin necesidad de depurar.

Ejercicio 7 (propuesto): Contador de vocales con TDD

Desarrolla con TDD, prueba a prueba, Texto.contarVocales(String s): cadena vacía → 0, sin vocales → 0, "Hola" → 2, mayúsculas ("ÁRBOL" → 2), tildes y diéresis ("murciélago" → 5, "pingüino" → 4). Anota cuántos ciclos rojo-verde-refactorizar necesitas y qué pruebas fallaron en cada uno.

Ejercicio 8 (propuesto): Completa la cobertura de Cuenta

A la vista del informe de JaCoCo de la sección 11, añade a CuentaTest las pruebas que faltan (saldo inicial negativo, cantidades no positivas, retirada correcta) hasta llegar al 100 % de ramas. Usa una prueba parametrizada con @ValueSource(doubles = {0, -1, -0.01}) para las cantidades no válidas.

Ejercicio 9 (propuesto): Breakpoint condicional

Escribe un programa que recorra los números del 1 al 100.000 y sume los que son primos. Pon un punto de ruptura condicional que se detenga solo cuando el número sea 99991 y comprueba con Evaluate Expression el valor de la suma acumulada en ese momento. Después usa Evaluate and log para imprimir en consola cada primo mayor que 99.900 sin modificar el código.

Ejercicio 10 (propuesto): Caja negra de un validador de contraseñas

Una contraseña es válida si tiene entre 8 y 20 caracteres, al menos una mayúscula, una minúscula y un dígito, y no contiene espacios. Diseña la tabla de clases de equivalencia (válidas y no válidas, una por cada regla), los valores límite de la longitud (7, 8, 20, 21) y conviértela en una prueba parametrizada con @CsvSource. Implementa después esValida(String) hasta que todas las pruebas pasen.

Ejercicio 11 (propuesto): Pruebas de excepciones propias

Retoma la excepción propia SaldoInsuficienteException del módulo 15 (o créala) y haz que Cuenta.retirar la lance. Escribe una prueba que compruebe con assertThrows el tipo de excepción y que su mensaje incluye el saldo disponible.

Ejercicio 12 (propuesto): Depuración de una ordenación

Implementa el método de la burbuja del módulo 8 introduciendo a propósito un error en el límite del bucle interno (j < n - i en lugar de j < n - i - 1). Ejecútalo, observa la excepción y, con Step Into y el panel Variables, localiza en qué iteración se sale del array. Escribe después pruebas JUnit con arrays vacíos, de un elemento, ya ordenados, en orden inverso y con repetidos.


Resumen

Concepto Clave
Error de compilación Lo detecta javac; corrige el primero y recompila
Error de ejecución Excepción con traza de pila: busca la primera línea de tu código
Error lógico Resultado incorrecto sin avisos: depurador + pruebas
Punto de ruptura Clic en el canal o Ctrl+F8; iniciar con Debug (Shift+F9)
Paso a paso Step Over F8, Step Into F7, Step Out Shift+F8, Run to Cursor Alt+F9, Resume F9
Inspección Panel Variables, valores en línea, Set Value F2
Evaluate Expression Alt+F8: evalúa cualquier expresión en el punto actual
Watch Expresión que se reevalúa en cada parada (Ctrl+Shift+Enter)
Breakpoint condicional Clic derecho sobre el punto rojo → Condition; más opciones con Ctrl+Shift+F8
Caja negra Clases de equivalencia (válidas y no válidas) + valores límite
Caja blanca Grafo de flujo, V(G) = decisiones + 1, un caso por camino independiente
JUnit 6 org.junit.jupiter:junit-jupiter:6.1.3 con scope test; pruebas en src/test/java
Prueba @Test + patrón AAA (preparar, actuar, comprobar)
Aserciones assertEquals (esperado primero), assertTrue, assertThrows, assertAll…
Ciclo de vida @BeforeEach, @AfterEach, @BeforeAll, @AfterAll, @Disabled, @DisplayName
Parametrizadas @ParameterizedTest + @ValueSource, @CsvSource, @NullAndEmptySource…
Ejecutar IntelliJ: ▶ del canal o Ctrl+Shift+F10; Maven: mvn test (-q para salida mínima)
TDD Rojo → verde → refactorizar, en ciclos cortos
Cobertura JaCoCo (target/site/jacoco) o Run with Coverage; 100 % no implica sin errores