Depuración y pruebas unitarias en Java
Introducción
La depuración y las pruebas unitarias son habilidades fundamentales para cualquier desarrollador de Java. Mientras que la depuración nos permite identificar y corregir errores en nuestro código, las pruebas unitarias aseguran que cada componente de nuestro programa funcione según lo esperado. Ambas prácticas son esenciales para el desarrollo de software de calidad y forman parte del ciclo de vida del desarrollo de aplicaciones en Java.
En este artículo aprenderás a utilizar las herramientas de depuración integradas en los entornos de desarrollo más populares y a implementar pruebas unitarias con JUnit, el framework de pruebas más utilizado en el ecosistema Java. Estas habilidades te permitirán escribir código más robusto y confiable, reduciendo significativamente el tiempo dedicado a la resolución de problemas.
Depuración en Java
¿Qué es la depuración?
La depuración (debugging) es el proceso de identificar y eliminar errores en un programa. Estos errores pueden ser:
- Errores de sintaxis: detectados durante la compilación
- Errores de ejecución: ocurren durante la ejecución del programa
- Errores lógicos: el programa se ejecuta pero no produce el resultado esperado
Herramientas de depuración
Depuración con System.out.println()
La forma más básica de depuración consiste en insertar sentencias System.out.println()
para mostrar el valor de variables o el flujo de ejecución:
public int sumar(int a, int b) {
System.out.println("Valor de a: " + a);
System.out.println("Valor de b: " + b);
int resultado = a + b;
System.out.println("Resultado: " + resultado);
return resultado;
}
Esta técnica, aunque simple, puede resultar útil para problemas sencillos, pero no es eficiente para casos complejos.
Depuración con un IDE
Los entornos de desarrollo integrados (IDE) como IntelliJ IDEA, Eclipse o NetBeans ofrecen potentes herramientas de depuración:
- Puntos de interrupción (breakpoints): marcas que detienen la ejecución del programa en líneas específicas.
- Ejecución paso a paso: permite ejecutar el código línea por línea.
- Inspección de variables: muestra el valor de las variables durante la ejecución.
- Pila de llamadas: visualiza la secuencia de llamadas a métodos.
Cómo establecer un punto de interrupción
Para establecer un punto de interrupción en la mayoría de los IDE:
- Haz clic en el margen izquierdo junto al número de línea donde deseas detener la ejecución.
- Aparecerá un indicador (generalmente un círculo rojo) que marca el punto de interrupción.
Ejemplo de sesión de depuración
Consideremos este código con un error lógico:
public class CalculadoraError {
public static int dividir(int dividendo, int divisor) {
// Error lógico: devuelve la suma en lugar de la división
return dividendo + divisor;
}
public static void main(String[] args) {
int resultado = dividir(10, 2);
System.out.println("10 / 2 = " + resultado); // Debería ser 5, pero imprime 12
}
}
Para depurar este código:
- Coloca un punto de interrupción en la línea
return dividendo + divisor;
. - Inicia la depuración (generalmente con F5 o con el botón "Debug").
- Cuando la ejecución se detenga, inspecciona los valores de
dividendo
ydivisor
. - Utiliza la ejecución paso a paso para continuar y ver el resultado incorrecto.
- Corrige el error cambiando la operación a división (
dividendo / divisor
).
Técnicas avanzadas de depuración
Evaluación de expresiones
La mayoría de los IDE permiten evaluar expresiones durante la depuración:
- Selecciona la expresión que deseas evaluar.
- Utiliza la opción "Evaluar expresión" (generalmente con Alt+F8 o en el menú contextual).
- Observa el resultado sin modificar el código.
Depuración condicional
Los puntos de interrupción pueden tener condiciones:
- Crea un punto de interrupción normal.
- Haz clic derecho sobre él y selecciona "Propiedades" o similar.
- Establece una condición (por ejemplo,
contador > 100
). - La ejecución solo se detendrá cuando la condición sea verdadera.
Logging con java.util.logging
Para una depuración más estructurada, puedes utilizar el API de logging de Java:
import java.util.logging.Level;
import java.util.logging.Logger;
public class EjemploLogging {
private static final Logger LOGGER = Logger.getLogger(EjemploLogging.class.getName());
public void metodoComplicado() {
LOGGER.info("Iniciando método complicado");
try {
// Operación arriesgada
int[] array = new int[3];
LOGGER.log(Level.FINE, "Accediendo al elemento 2 del array");
array[2] = 42; // Correcto
LOGGER.log(Level.WARNING, "Intentando acceder a un índice fuera de rango");
array[3] = 99; // Error: índice fuera de rango
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Error en metodoComplicado", e);
}
}
public static void main(String[] args) {
new EjemploLogging().metodoComplicado();
}
}
Pruebas unitarias con JUnit
¿Qué son las pruebas unitarias?
Las pruebas unitarias son tests automatizados que verifican el funcionamiento de unidades individuales de código (típicamente métodos). Su objetivo es asegurar que cada parte del programa funcione correctamente de forma aislada.
Ventajas de las pruebas unitarias
- Detectan errores tempranamente en el ciclo de desarrollo
- Facilitan los cambios y refactorizaciones
- Sirven como documentación ejecutable
- Mejoran el diseño del código
- Aumentan la confianza en el código
Configuración de JUnit
JUnit es el framework de pruebas más popular para Java. Para utilizarlo, necesitas añadir la dependencia correspondiente:
En Maven (pom.xml):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
En Gradle (build.gradle):
testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'
Estructura básica de una prueba unitaria
Las pruebas JUnit siguen una estructura común:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculadoraTest {
@Test
public void testSumar() {
// 1. Preparación (Arrange)
Calculadora calc = new Calculadora();
// 2. Ejecución (Act)
int resultado = calc.sumar(3, 5);
// 3. Verificación (Assert)
assertEquals(8, resultado, "La suma de 3 y 5 debe ser 8");
}
}
Esta estructura sigue el patrón "AAA" (Arrange-Act-Assert):
- Preparación: configuramos el escenario de la prueba
- Ejecución: invocamos el método que queremos probar
- Verificación: comprobamos que el resultado sea el esperado
Clase a probar
public class Calculadora {
public int sumar(int a, int b) {
return a + b;
}
public int restar(int a, int b) {
return a - b;
}
public int multiplicar(int a, int b) {
return a * b;
}
public int dividir(int a, int b) {
if (b == 0) {
throw new ArithmeticException("No se puede dividir por cero");
}
return a / b;
}
}
Métodos de aserción
JUnit proporciona varios métodos para verificar resultados:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculadoraTest {
private final Calculadora calc = new Calculadora();
@Test
public void testSumar() {
assertEquals(8, calc.sumar(3, 5));
}
@Test
public void testRestar() {
assertEquals(2, calc.restar(5, 3));
}
@Test
public void testMultiplicar() {
assertEquals(15, calc.multiplicar(3, 5));
}
@Test
public void testDividir() {
assertEquals(2, calc.dividir(10, 5));
}
@Test
public void testDividirPorCero() {
assertThrows(ArithmeticException.class, () -> calc.dividir(10, 0));
}
}
Otros métodos de aserción útiles:
assertTrue(boolean)
: verifica que una condición sea verdaderaassertFalse(boolean)
: verifica que una condición sea falsaassertNull(Object)
: verifica que un objeto sea nuloassertNotNull(Object)
: verifica que un objeto no sea nuloassertSame(expected, actual)
: verifica que dos referencias apunten al mismo objetoassertNotSame(expected, actual)
: verifica que dos referencias no apunten al mismo objeto
Ciclo de vida de las pruebas
JUnit permite ejecutar código antes y después de cada prueba:
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculadoraAvanzadaTest {
private CalculadoraAvanzada calc;
@BeforeEach
public void setUp() {
// Se ejecuta antes de cada prueba
System.out.println("Iniciando prueba");
calc = new CalculadoraAvanzada();
}
@AfterEach
public void tearDown() {
// Se ejecuta después de cada prueba
System.out.println("Finalizando prueba");
calc = null;
}
@Test
public void testSumar() {
assertEquals(8, calc.sumar(3, 5));
}
@Test
public void testMemoria() {
calc.sumar(3, 5);
assertEquals(8, calc.obtenerResultadoAnterior());
}
}
También existen anotaciones para el ciclo de vida de la clase:
@BeforeAll
: se ejecuta una vez antes de todas las pruebas@AfterAll
: se ejecuta una vez después de todas las pruebas
Pruebas parametrizadas
JUnit 5 permite parametrizar pruebas para ejecutar el mismo test con diferentes valores:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
public class CalculadoraParametrizedTest {
private final Calculadora calc = new Calculadora();
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"5, 3, 8",
"10, -5, 5",
"0, 0, 0"
})
public void testSumarConDiferentesValores(int a, int b, int esperado) {
assertEquals(esperado, calc.sumar(a, b),
"La suma de " + a + " y " + b + " debe ser " + esperado);
}
}
Pruebas de tiempo de ejecución
JUnit permite verificar que un método se ejecute dentro de un tiempo límite:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.TimeUnit;
public class AlgoritmoTest {
@Test
@Timeout(value = 100, unit = TimeUnit.MILLISECONDS)
public void testAlgoritmoRapido() {
// Este test fallará si tarda más de 100 milisegundos
Algoritmo algo = new Algoritmo();
algo.procesarDatos(1000);
}
}
Buenas prácticas en pruebas unitarias
- Pruebas independientes: cada prueba debe poder ejecutarse de forma aislada.
- Nombres descriptivos: utiliza nombres que describan el comportamiento que se está probando.
- Una aserción por prueba: idealmente, cada prueba debería verificar un único aspecto.
- Evitar lógica compleja: las pruebas deben ser simples y directas.
- Prueba casos límite: incluye valores extremos, nulos, vacíos, etc.
- Cobertura de código: intenta que tus pruebas cubran la mayor parte del código posible.
Cobertura de código
La cobertura de código mide qué porcentaje del código fuente está siendo ejecutado por las pruebas. Herramientas como JaCoCo pueden integrarse con Maven o Gradle para generar informes de cobertura:
En Maven (pom.xml):
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.8</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
Integración de pruebas y depuración
La depuración y las pruebas unitarias son complementarias:
- Las pruebas unitarias detectan que existe un problema.
- La depuración ayuda a identificar la causa exacta del problema.
- Una vez solucionado, las pruebas confirman que el problema se ha resuelto.
Ejemplo de flujo de trabajo
- Escribe una primera versión del código.
- Desarrolla pruebas unitarias para verificar su funcionamiento.
- Si una prueba falla, utiliza las herramientas de depuración para investigar.
- Corrige el problema identificado.
- Vuelve a ejecutar las pruebas para confirmar la solución.
- Añade nuevas pruebas para casos que no estaban cubiertos.
Resumen
La depuración y las pruebas unitarias son habilidades esenciales para cualquier desarrollador Java. Mientras que la depuración nos permite identificar y resolver problemas específicos en nuestro código, las pruebas unitarias nos aseguran que cada componente funciona correctamente y nos protegen contra regresiones futuras.
A lo largo de este artículo, hemos explorado las técnicas de depuración desde el simple uso de System.out.println()
hasta las potentes herramientas integradas en los IDE modernos. También hemos aprendido a implementar pruebas unitarias con JUnit, incluyendo aserciones, ciclos de vida, pruebas parametrizadas y buenas prácticas. Dominar estas técnicas te permitirá desarrollar software más robusto y confiable, ahorrando tiempo y frustraciones en el proceso de desarrollo.