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,@DisplayNamey 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.
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:
- Primera línea: el tipo de excepción (
NullPointerException) y el mensaje. Desde Java 14 los mensajes deNullPointerExceptionexplican qué eranull(helpful NullPointerExceptions). - 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ó eninicial, línea 14, que fue llamado porimprimirIniciales, línea 9, que fue llamado pormain, línea 4». - 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 amain→ 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
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
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 (
F2sobre una variable, o clic derecho → Set Value): cambia el valor de una variable en caliente. Muy útil para probar «¿qué pasaría sinotavaliera 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…
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)ocuenta.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.
Figura 5. Esquema de las propiedades de un punto de ruptura: clic derecho sobre el círculo rojo.
- Pon un punto de ruptura normal.
- Haz clic derecho sobre el círculo rojo: aparece un panel con Enabled, Suspend y Condition.
- Escribe una expresión Java booleana, por ejemplo
nota == 5oi == 9000onombre == 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
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
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.
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
- 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.
- 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 + 1V(G) = aristas - nodos + 2V(G) = número de regiones cerradas del grafo + 1
- 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.
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:
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-jupiterincluye 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 desrc/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
@Testen una clase, pulsaAlt+Entersobre 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
publicen JUnit 5/6 (basta la visibilidad de paquete). - Un método de prueba es
voidy 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:
assertThrowsrecibe una lambda (() -> Calificador.calificar(-1)), no la llamada directa. Si escribierasassertThrows(IllegalArgumentException.class, Calificador.calificar(-1)), la excepción saltaría antes de entrar enassertThrowsy 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));
}
}
@DisplayNamepone un nombre legible (con espacios, tildes…) a la clase o al método, que es el que aparece en los informes.assertAllagrupa varias aserciones: si fallan dos, verás las dos en el informe, en lugar de detenerse en la primera.assertThrowsdevuelve 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:
ingresarSumaAlSaldodeja 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
@Testaparece en el canal un ▶ verde: clic → Run ‘CalificadorTest’. TambiénCtrl+Shift+F10con 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.
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).
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:
Figura 13. Resumen de cobertura por clase: Cuenta solo tiene el 58 % de las ramas cubiertas.
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:
String s = null; System.out.println(s.length());int media = (8 + 7) / 2; // se esperaba 7.5for (int i = 0; i <= v.length; i++) suma += v[i];boolean iguales = nombre1 == nombre2; // dos String leídos con Scannerdouble d = 3.5; int n = d;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 |