Testing con JUnit y Tu Primera App en Spring Boot

Llegaste a la última lección. Ya sabés modelar con objetos, elegir estructuras de datos, manejar errores, persistir y consultar una base. Falta lo que separa un ejercicio de un sistema que otras personas usan: demostrar que funciona y exponerlo para que lo consuman.


1. Por qué los tests, en números

Un bug cuesta distinto según cuándo lo encontrás. Escribiendo el código: minutos. En code review: una hora. En producción: una madrugada, un cliente enojado y un parche apurado que probablemente introduzca otro bug.

Los tests no existen para “estar seguros”. Existen para encontrar el bug en el primer escalón, y para que puedas cambiar código sin miedo. Sin tests, refactorizar es apostar.

La pirámide de tests con sus tres niveles, cantidades y velocidades E2E Integración Unitarios End-to-end — poquísimos La app entera, con navegador. Minutos, y se rompen solos. Integración — algunos Varias capas juntas, con base de datos real. Segundos. Unitarios — muchísimos Una clase aislada, sin base ni red. Milisegundos. La forma importa: muchos tests rápidos abajo, poquísimos lentos arriba. Invertir la pirámide da una suite que tarda veinte minutos, falla por motivos ajenos al código, y que el equipo termina ignorando.
Si un test tarda más de un segundo, nadie lo corre antes de cada commit. Y un test que no se corre no protege nada.

2. JUnit 5 y el patrón AAA

La estructura Arrange-Act-Assert de un test 1. ARRANGE — preparar todo lo que el test necesita para existir Calculadora calc = new Calculadora(); 2. ACT — actuar ejecutar EXACTAMENTE una cosa: la que se está probando int resultado = calc.sumar(5, 10); 3. ASSERT — verificar comprobar el resultado esperado, y nada más assertEquals(15, resultado, "5 + 10 debería dar 15");
Si tu test tiene dos bloques "Act", en realidad son dos tests. Separalos: cuando falle, vas a saber cuál de las dos cosas se rompió.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class CalculadoraTest {

    private Calculadora calc;

    @BeforeEach                        // corre antes de CADA test: estado limpio siempre
    void prepararCalculadora() {
        calc = new Calculadora();
    }

    @Test
    @DisplayName("sumar dos positivos devuelve su suma")
    void sumarDosPositivos() {
        int resultado = calc.sumar(5, 10);
        assertEquals(15, resultado);
    }

    @Test
    @DisplayName("dividir por cero lanza ArithmeticException")
    void dividirPorCeroLanzaExcepcion() {
        // Verificamos que la excepción se lanza Y que dice lo correcto
        ArithmeticException e = assertThrows(
            ArithmeticException.class,
            () -> calc.dividir(10, 0)
        );
        assertTrue(e.getMessage().contains("cero"));
    }

    @ParameterizedTest                 // el mismo test, con muchos datos distintos
    @CsvSource({ "1, 1, 2", "0, 0, 0", "-5, 5, 0", "2147483647, 0, 2147483647" })
    void sumarVariosCasos(int a, int b, int esperado) {
        assertEquals(esperado, calc.sumar(a, b));
    }
}

@BeforeEach es más importante de lo que parece: cada test recibe un objeto nuevo. Si compartieran estado, un test podría pasar o fallar según el orden en que se ejecuten, y eso es peor que no tener tests.

Qué hace bueno a un test

  • Rápido. Milisegundos. Nada de dormir, ni de red, ni de base real.
  • Independiente. Corre solo y en cualquier orden. Nunca depende de otro test.
  • Repetible. Mismo resultado siempre. Cuidado con LocalDate.now() y con números aleatorios.
  • Con nombre que explica. sumarDosPositivos sirve; test1 no dice nada cuando falla a las tres de la mañana.
  • Un solo motivo para fallar. Si verifica cinco cosas distintas, son cinco tests.

3. Mocks: por qué las interfaces importaban

Querés testear ProductoServicio, pero depende de ProductoRepositorio, que va a la base de datos. Si el test necesita una base, deja de ser unitario: es lento, frágil y no corre en cualquier máquina.

La misma clase de servicio recibe la implementación real en producción y un mock en los tests ProductoServicio recibe un ProductoRepositorio por constructor «interface» ProductoRepositorio RepositorioJdbc EN PRODUCCIÓN — va a PostgreSQL lento, necesita la base levantada mock(ProductoRepositorio) EN EL TEST — devuelve lo que vos digas instantáneo, sin infraestructura Esto solo funciona porque el servicio depende de la INTERFAZ y la recibe por constructor. Si hiciera new adentro,
Inversión de dependencias: la clase no crea lo que necesita, lo recibe. Es lo que hace testeable un diseño — y es la lección [Clases Abstractas, Interfaces y Organización del Código](/cursos/java/09-clases-abstractas-interfaces-y-modelado) dando su fruto más concreto.
import static org.mockito.Mockito.*;

@Test
void aplicarDescuentoUsaElPrecioDelRepositorio() {
    // Arrange: un doble de prueba que devuelve lo que necesitamos
    ProductoRepositorio repo = mock(ProductoRepositorio.class);
    when(repo.buscarPorId(1L))
        .thenReturn(Optional.of(new Producto(1L, "Yerba", 1000.0, 10)));

    ProductoServicio servicio = new ProductoServicio(repo);   // ← inyección

    // Act
    double precioFinal = servicio.precioConDescuento(1L, 20);

    // Assert: el resultado, y que se consultó el repositorio una sola vez
    assertEquals(800.0, precioFinal, 0.001);
    verify(repo, times(1)).buscarPorId(1L);
}

Si ProductoServicio hiciera new RepositorioJdbc() adentro, este test sería imposible. Por eso las dependencias se reciben, no se crean.


4. Spring Boot: las tres capas

Spring Boot toma esa idea de inyección y la automatiza para toda la aplicación.

Recorrido de una petición HTTP por las tres capas de una aplicación Spring Boot Cliente GET /api/productos/1 @RestController — capa web Traduce HTTP a llamadas Java y de vuelta. Valida la entrada y elige el código de estado. NO tiene lógica de negocio. Se prueba con @WebMvcTest. @Service — capa de negocio Acá viven las reglas: descuentos, validaciones de dominio, transacciones. No sabe nada de HTTP ni de SQL. Se prueba con JUnit puro y mocks. @Repository — capa de datos Solo persistencia: el DAO de Acceso a Bases de Datos con JDBC y SQL Seguro, o Spring Data JPA. No sabe nada de reglas de negocio. Se prueba con @DataJpaTest. Base de datos PostgreSQL Cada capa habla solo con la de abajo, y siempre a través de una interfaz. Por eso se puede probar por separado. JSON de vuelta ↑
La separación no es burocracia: es lo que permite testear la lógica de negocio sin levantar un servidor ni una base de datos.
@RestController
@RequestMapping("/api/productos")
public class ProductoController {

    private final ProductoServicio servicio;

    // Un solo constructor → Spring inyecta solo. No hace falta @Autowired.
    public ProductoController(ProductoServicio servicio) {
        this.servicio = servicio;
    }

    @GetMapping("/{id}")
    public ResponseEntity<Producto> obtener(@PathVariable long id) {
        return servicio.buscarPorId(id)
                       .map(ResponseEntity::ok)                 // 200 con el producto
                       .orElse(ResponseEntity.notFound().build()); // 404 si no está
    }

    @PostMapping
    public ResponseEntity<Producto> crear(@Valid @RequestBody NuevoProducto datos) {
        Producto creado = servicio.crear(datos);
        return ResponseEntity
                   .created(URI.create("/api/productos/" + creado.id()))   // 201
                   .body(creado);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Producto> actualizar(@PathVariable long id, @Valid @RequestBody NuevoProducto datos) {
        return servicio.actualizar(id, datos)
                       .map(ResponseEntity::ok)                    // 200 con el producto reemplazado
                       .orElse(ResponseEntity.notFound().build()); // 404 si no existe
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> eliminar(@PathVariable long id) {
        return servicio.eliminar(id) ? ResponseEntity.noContent().build()   // 204
                                     : ResponseEntity.notFound().build();   // 404
    }
}

@Service
public class ProductoServicio {

    private final ProductoRepositorio repositorio;

    public ProductoServicio(ProductoRepositorio repositorio) {
        this.repositorio = repositorio;      // la interfaz, no la implementación
    }

    public double precioConDescuento(long id, int porcentaje) {
        if (porcentaje < 0 || porcentaje > 100) {
            throw new IllegalArgumentException("El descuento debe estar entre 0 y 100");
        }
        Producto p = repositorio.buscarPorId(id)
            .orElseThrow(() -> new ProductoNoEncontradoException(id));
        return p.precio() * (1 - porcentaje / 100.0);
    }

    public Optional<Producto> actualizar(long id, NuevoProducto datos) {
        // @Valid ya garantizó en el controlador que "datos" es válido.
        return repositorio.buscarPorId(id)
            .map(actual -> repositorio.guardar(
                new Producto(actual.id(), datos.nombre(), datos.precio(), datos.stock())));
    }
}

Ese Optional que se transforma en 200 o en 404 conecta directamente con la lección Manejo de Excepciones y Robustez: “no existe” no es una excepción, es un resultado posible, y acá se traduce a un código HTTP. obtener y actualizar comparten el mismo patrón porque comparten el mismo caso: el id podría no estar.

PUT: reemplazo idempotente, no un parche

actualizar recibe el NuevoProducto completo y le pide al repositorio un reemplazo total: los valores viejos se descartan, no se combinan con los nuevos. Por eso mandar el mismo PUT una, dos o diez veces deja el recurso exactamente en el mismo estado final: PUT es idempotente.

PATCH es la otra cara de la moneda: modifica una parte del recurso (por ejemplo, “sumale 5 unidades al stock”), y repetir esa misma petición dos veces normalmente no da el mismo resultado — cada repetición suma 5 de nuevo. Si el cliente siempre manda el recurso completo, PUT alcanza; si necesita cambios parciales, el verbo correcto es PATCH.

La capa de datos no cambia de rol: el repositorio sigue siendo el único que sabe hablar con la base. actualizar lo usa dos veces — buscarPorId para encontrar el producto actual y guardar para persistir el reemplazo — y el controlador nunca lo toca directamente.

Las respuestas del PUT siguen la misma lógica que ya usás en obtener y eliminar, más una nueva:

  • 200 OK con el producto actualizado, si el id existe.
  • 404 Not Found, si el id no existe. Mismo patrón Optional + orElse que en el GET.
  • 400 Bad Request, si @Valid rechaza el cuerpo — la misma validación que ya protege a crear. Spring corta la petición antes de que el controlador se ejecute, así que el servicio nunca llega a correr.

Con actualizar el CRUD queda completo:

OperaciónVerbo HTTPMétodo del servicioRespuestas
CREATEPOSTcrear201 Created
READGETobtener200 OK / 404 Not Found
UPDATEPUTactualizar200 OK / 404 Not Found / 400 Bad Request
DELETEDELETEeliminar204 No Content / 404 Not Found

Cuatro verbos, tres capas, y ningún código de estado inventado.

Los códigos de estado que sí importan

CódigoCuándo
200 OKLa consulta salió bien y hay contenido.
201 CreatedSe creó un recurso. Devolvé también la URL en el header Location.
204 No ContentSalió bien y no hay nada que devolver (típico del DELETE).
400 Bad RequestLos datos que mandaron son inválidos.
404 Not FoundEl recurso no existe.
409 ConflictChoca con el estado actual (por ejemplo, un email duplicado).
500 Internal Server ErrorSe rompió algo tuyo. Nunca devuelvas esto a propósito.

5. Testear la aplicación Spring

// Test de la capa web: levanta SOLO el controlador, con el servicio simulado
@WebMvcTest(ProductoController.class)
class ProductoControllerTest {

    @Autowired  MockMvc mockMvc;
    @MockBean   ProductoServicio servicio;      // no es el real: es un mock

    @Test
    void devuelve404CuandoElProductoNoExiste() throws Exception {
        when(servicio.buscarPorId(99L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/productos/99"))
               .andExpect(status().isNotFound());
    }

    @Test
    void devuelveElProductoEnJson() throws Exception {
        when(servicio.buscarPorId(1L))
            .thenReturn(Optional.of(new Producto(1L, "Yerba", 3200.0, 45)));

        mockMvc.perform(get("/api/productos/1"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.nombre").value("Yerba"))
               .andExpect(jsonPath("$.precio").value(3200.0));
    }

    @Test
    void actualizaElProductoYDevuelve200() throws Exception {
        when(servicio.actualizar(eq(1L), any(NuevoProducto.class)))
            .thenReturn(Optional.of(new Producto(1L, "Yerba Premium", 3400.0, 40)));

        mockMvc.perform(put("/api/productos/1")
                   .contentType(MediaType.APPLICATION_JSON)
                   .content("""
                       {"nombre":"Yerba Premium","precio":3400.0,"stock":40}
                       """))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.nombre").value("Yerba Premium"));
    }

    @Test
    void devuelve404AlActualizarUnProductoInexistente() throws Exception {
        when(servicio.actualizar(eq(99L), any(NuevoProducto.class)))
            .thenReturn(Optional.empty());

        mockMvc.perform(put("/api/productos/99")
                   .contentType(MediaType.APPLICATION_JSON)
                   .content("""
                       {"nombre":"Yerba Premium","precio":3400.0,"stock":40}
                       """))
               .andExpect(status().isNotFound());
    }

    @Test
    void devuelve400ConUnCuerpoInvalido() throws Exception {
        mockMvc.perform(put("/api/productos/1")
                   .contentType(MediaType.APPLICATION_JSON)
                   .content("""
                       {"nombre":"","precio":-100.0,"stock":40}
                       """))
               .andExpect(status().isBadRequest());

        verifyNoInteractions(servicio);   // @Valid corta antes de llegar al servicio
    }
}

// Test de integración: levanta la aplicación entera. Pocos de estos.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductoIntegracionTest {

    @Autowired TestRestTemplate rest;

    @Test
    void crearYRecuperarUnProducto() {
        ResponseEntity<Producto> creado = rest.postForEntity(
            "/api/productos", new NuevoProducto("Café", 5800.0, 12), Producto.class);

        assertEquals(HttpStatus.CREATED, creado.getStatusCode());

        Producto leido = rest.getForObject(
            "/api/productos/" + creado.getBody().id(), Producto.class);
        assertEquals("Café", leido.nombre());
    }
}

@WebMvcTest arranca en cientos de milisegundos porque levanta solo la capa web. @SpringBootTest levanta todo y tarda segundos. Esa diferencia es, otra vez, la pirámide.


6. Errores frecuentes

ErrorQué pasaCómo se arregla
Tests que dependen del orden de ejecuciónPasan en tu máquina y fallan en CI, sin ninguna explicación.@BeforeEach con estado nuevo; nada compartido entre tests.
Un test que verifica cinco cosasCuando falla, no sabés cuál se rompió.Un test, un motivo para fallar.
Usar la base de datos real en tests unitariosLentos, frágiles y sin poder correrse en paralelo.Mocks para lo unitario; H2 o Testcontainers para integración.
Tests con LocalDate.now() o Math.random()Fallan un día del año o una vez cada cien corridas.Inyectar un Clock o una semilla fija.
Crear las dependencias con new dentro de la claseLa clase no se puede testear aisladamente.Recibirlas por constructor, tipadas con la interfaz.
Lógica de negocio en el @RestControllerNo se puede probar sin levantar el contexto web entero.Toda la lógica en el @Service.
Usar @Autowired sobre campos privadosImposible construir la clase a mano en un test.Inyección por constructor.
Devolver siempre 200 OKEl cliente no puede distinguir éxito de error.Usar los códigos correctos: 201, 204, 404, 400.
Muchos @SpringBootTestLa suite pasa de segundos a veinte minutos.@WebMvcTest, @DataJpaTest, o JUnit puro donde alcance.

7. Ejercicio práctico guiado

Desafío: ProductoServicioTest

Testeá ProductoServicio sin base de datos, con un mock del repositorio:

  1. precioConDescuento calcula bien un caso normal.
  2. Lanza IllegalArgumentException con un porcentaje fuera de rango.
  3. Lanza ProductoNoEncontradoException cuando el id no existe.
  4. crear rechaza precios negativos y no llega a guardar nada.
  5. Un test parametrizado que cubra varios descuentos de una vez.
  6. actualizar reemplaza nombre, precio y stock a partir del NuevoProducto recibido sin tocar el id, y devuelve Optional.empty() si el id no existe.
Ver solución sugerida
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.Optional;

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

@DisplayName("ProductoServicio")
class ProductoServicioTest {

    private ProductoRepositorio repositorio;   // el doble de prueba
    private ProductoServicio servicio;         // lo que estamos probando

    @BeforeEach
    void prepararEscenario() {
        // Mock nuevo en cada test: ninguna interacción se filtra al siguiente
        repositorio = mock(ProductoRepositorio.class);
        servicio = new ProductoServicio(repositorio);
    }

    @Nested
    @DisplayName("precioConDescuento")
    class PrecioConDescuento {

        @Test
        @DisplayName("aplica el porcentaje sobre el precio del repositorio")
        void aplicaElDescuento() {
            // ARRANGE
            when(repositorio.buscarPorId(1L))
                .thenReturn(Optional.of(new Producto(1L, "Yerba", 1000.0, 10)));

            // ACT — una sola cosa
            double resultado = servicio.precioConDescuento(1L, 20);

            // ASSERT
            assertEquals(800.0, resultado, 0.001);
            verify(repositorio).buscarPorId(1L);        // se consultó
            verifyNoMoreInteractions(repositorio);      // y nada más
        }

        @ParameterizedTest(name = "{0}% de $1000 → ${1}")
        @CsvSource({ "0, 1000.0", "10, 900.0", "50, 500.0", "100, 0.0" })
        @DisplayName("cubre todo el rango válido de descuentos")
        void variosDescuentos(int porcentaje, double esperado) {
            when(repositorio.buscarPorId(1L))
                .thenReturn(Optional.of(new Producto(1L, "Yerba", 1000.0, 10)));

            assertEquals(esperado, servicio.precioConDescuento(1L, porcentaje), 0.001);
        }

        @Test
        @DisplayName("rechaza un porcentaje mayor a 100")
        void rechazaPorcentajeInvalido() {
            IllegalArgumentException e = assertThrows(
                IllegalArgumentException.class,
                () -> servicio.precioConDescuento(1L, 150)
            );
            assertTrue(e.getMessage().contains("entre 0 y 100"));

            // Clave: falló en la validación, ANTES de tocar el repositorio
            verifyNoInteractions(repositorio);
        }

        @Test
        @DisplayName("lanza ProductoNoEncontradoException si el id no existe")
        void productoInexistente() {
            when(repositorio.buscarPorId(99L)).thenReturn(Optional.empty());

            assertThrows(ProductoNoEncontradoException.class,
                         () -> servicio.precioConDescuento(99L, 10));
        }
    }

    @Nested
    @DisplayName("crear")
    class Crear {

        @Test
        @DisplayName("rechaza precios negativos y no guarda nada")
        void rechazaPrecioNegativo() {
            NuevoProducto invalido = new NuevoProducto("Café", -100.0, 5);

            assertThrows(IllegalArgumentException.class, () -> servicio.crear(invalido));

            // Lo importante del test: la validación corta ANTES de persistir
            verify(repositorio, never()).guardar(any());
        }

        @Test
        @DisplayName("guarda el producto y devuelve el que trae el repositorio")
        void guardaProductoValido() {
            NuevoProducto datos = new NuevoProducto("Café", 5800.0, 12);
            when(repositorio.guardar(any()))
                .thenReturn(new Producto(7L, "Café", 5800.0, 12));

            Producto creado = servicio.crear(datos);

            assertEquals(7L, creado.id());
            assertEquals("Café", creado.nombre());
            verify(repositorio).guardar(argThat(p -> p.nombre().equals("Café")));
        }
    }

    @Nested
    @DisplayName("actualizar")
    class Actualizar {

        @Test
        @DisplayName("reemplaza el producto conservando el id")
        void reemplazaElProducto() {
            NuevoProducto datos = new NuevoProducto("Yerba Premium", 3400.0, 40);
            when(repositorio.buscarPorId(1L))
                .thenReturn(Optional.of(new Producto(1L, "Yerba", 3200.0, 45)));
            when(repositorio.guardar(any()))
                .thenReturn(new Producto(1L, "Yerba Premium", 3400.0, 40));

            Optional<Producto> resultado = servicio.actualizar(1L, datos);

            assertTrue(resultado.isPresent());
            assertEquals("Yerba Premium", resultado.get().nombre());
            verify(repositorio).guardar(argThat(p -> p.id() == 1L && p.nombre().equals("Yerba Premium")));
        }

        @Test
        @DisplayName("devuelve vacío y no guarda nada si el id no existe")
        void noActualizaUnProductoInexistente() {
            when(repositorio.buscarPorId(99L)).thenReturn(Optional.empty());

            Optional<Producto> resultado =
                servicio.actualizar(99L, new NuevoProducto("Yerba", 3200.0, 45));

            assertTrue(resultado.isEmpty());
            verify(repositorio, never()).guardar(any());
        }
    }
}

Lo más valioso de esta suite no son los assertEquals, son los verify.

verifyNoInteractions(repositorio) en el test del porcentaje inválido demuestra algo que ningún assertEquals puede: que la validación corta antes de consultar la base. Si mañana alguien reordena el método y busca el producto primero, ese test falla y te avisa. Es una regla de diseño convertida en test.

Lo mismo con verify(repositorio, never()).guardar(any()): no alcanza con que se lance la excepción, hay que probar que no se persistió nada. Un servicio que valida después de guardar deja basura en la base incluso cuando lanza el error correcto.

Y fijate que todo esto corre en milisegundos, sin base de datos, sin servidor y sin conexión a internet. Eso es un test unitario.


Para llevarte

  • La pirámide: muchísimos tests unitarios rápidos abajo, poquísimos end-to-end arriba. Al revés, la suite se vuelve inútil.
  • Arrange, Act, Assert. Un solo “Act” por test, y un solo motivo para fallar.
  • Un buen test es rápido, independiente, repetible y tiene un nombre que explica qué se rompió.
  • Los mocks solo son posibles si la clase recibe sus dependencias en lugar de crearlas. Ahí están las interfaces de la lección Clases Abstractas, Interfaces y Organización del Código dando fruto.
  • Spring Boot separa en tres capas: web (@RestController), negocio (@Service) y datos (@Repository).
  • Cada capa se prueba distinto: @WebMvcTest, JUnit puro con mocks, @DataJpaTest. @SpringBootTest, lo mínimo posible.
  • Un Optional vacío en el servicio se traduce en un 404 en el controlador. La misma idea, en dos lenguajes distintos.
  • El CRUD completo son cuatro verbos: POST (201), GET (200/404), PUT (200/404/400) y DELETE (204/404). PUT reemplaza el recurso entero y es idempotente; PATCH cambia una parte y no lo es necesariamente.
  • verify prueba cómo se hizo algo, no solo el resultado. Es lo que convierte una decisión de diseño en una garantía.

Qué sigue

Con testing y Spring Boot cerrás el ciclo de construir software que funciona y demostrar que funciona. Todavía falta una habilidad que se ejercita, no se explica de una vez: qué hacer cuando algo se rompe, y cómo dejar el código mejor de como lo encontraste sin romper nada en el camino. De eso trata la última lección del curso, Depuración, código limpio y refactorización.