Ir al contenido principal

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:

  1. Puntos de interrupción (breakpoints): marcas que detienen la ejecución del programa en líneas específicas.
  2. Ejecución paso a paso: permite ejecutar el código línea por línea.
  3. Inspección de variables: muestra el valor de las variables durante la ejecución.
  4. 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:

  1. Haz clic en el margen izquierdo junto al número de línea donde deseas detener la ejecución.
  2. 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:

  1. Coloca un punto de interrupción en la línea return dividendo + divisor;.
  2. Inicia la depuración (generalmente con F5 o con el botón "Debug").
  3. Cuando la ejecución se detenga, inspecciona los valores de dividendo y divisor.
  4. Utiliza la ejecución paso a paso para continuar y ver el resultado incorrecto.
  5. 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:

  1. Selecciona la expresión que deseas evaluar.
  2. Utiliza la opción "Evaluar expresión" (generalmente con Alt+F8 o en el menú contextual).
  3. Observa el resultado sin modificar el código.

Depuración condicional

Los puntos de interrupción pueden tener condiciones:

  1. Crea un punto de interrupción normal.
  2. Haz clic derecho sobre él y selecciona "Propiedades" o similar.
  3. Establece una condición (por ejemplo, contador > 100).
  4. 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):

  1. Preparación: configuramos el escenario de la prueba
  2. Ejecución: invocamos el método que queremos probar
  3. 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 verdadera
  • assertFalse(boolean): verifica que una condición sea falsa
  • assertNull(Object): verifica que un objeto sea nulo
  • assertNotNull(Object): verifica que un objeto no sea nulo
  • assertSame(expected, actual): verifica que dos referencias apunten al mismo objeto
  • assertNotSame(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

  1. Pruebas independientes: cada prueba debe poder ejecutarse de forma aislada.
  2. Nombres descriptivos: utiliza nombres que describan el comportamiento que se está probando.
  3. Una aserción por prueba: idealmente, cada prueba debería verificar un único aspecto.
  4. Evitar lógica compleja: las pruebas deben ser simples y directas.
  5. Prueba casos límite: incluye valores extremos, nulos, vacíos, etc.
  6. 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:

  1. Las pruebas unitarias detectan que existe un problema.
  2. La depuración ayuda a identificar la causa exacta del problema.
  3. Una vez solucionado, las pruebas confirman que el problema se ha resuelto.

Ejemplo de flujo de trabajo

  1. Escribe una primera versión del código.
  2. Desarrolla pruebas unitarias para verificar su funcionamiento.
  3. Si una prueba falla, utiliza las herramientas de depuración para investigar.
  4. Corrige el problema identificado.
  5. Vuelve a ejecutar las pruebas para confirmar la solución.
  6. 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.