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.
2. JUnit 5 y el patrón AAA
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.
sumarDosPositivossirve;test1no 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.
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.
@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 OKcon el producto actualizado, si el id existe.404 Not Found, si el id no existe. Mismo patrónOptional+orElseque en elGET.400 Bad Request, si@Validrechaza el cuerpo — la misma validación que ya protege acrear. 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ón | Verbo HTTP | Método del servicio | Respuestas |
|---|---|---|---|
| CREATE | POST | crear | 201 Created |
| READ | GET | obtener | 200 OK / 404 Not Found |
| UPDATE | PUT | actualizar | 200 OK / 404 Not Found / 400 Bad Request |
| DELETE | DELETE | eliminar | 204 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ódigo | Cuándo |
|---|---|
200 OK | La consulta salió bien y hay contenido. |
201 Created | Se creó un recurso. Devolvé también la URL en el header Location. |
204 No Content | Salió bien y no hay nada que devolver (típico del DELETE). |
400 Bad Request | Los datos que mandaron son inválidos. |
404 Not Found | El recurso no existe. |
409 Conflict | Choca con el estado actual (por ejemplo, un email duplicado). |
500 Internal Server Error | Se 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
| Error | Qué pasa | Cómo se arregla |
|---|---|---|
| Tests que dependen del orden de ejecución | Pasan 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 cosas | Cuando falla, no sabés cuál se rompió. | Un test, un motivo para fallar. |
| Usar la base de datos real en tests unitarios | Lentos, 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 clase | La clase no se puede testear aisladamente. | Recibirlas por constructor, tipadas con la interfaz. |
Lógica de negocio en el @RestController | No se puede probar sin levantar el contexto web entero. | Toda la lógica en el @Service. |
Usar @Autowired sobre campos privados | Imposible construir la clase a mano en un test. | Inyección por constructor. |
Devolver siempre 200 OK | El cliente no puede distinguir éxito de error. | Usar los códigos correctos: 201, 204, 404, 400. |
Muchos @SpringBootTest | La 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:
precioConDescuentocalcula bien un caso normal.- Lanza
IllegalArgumentExceptioncon un porcentaje fuera de rango. - Lanza
ProductoNoEncontradoExceptioncuando el id no existe. crearrechaza precios negativos y no llega a guardar nada.- Un test parametrizado que cubra varios descuentos de una vez.
actualizarreemplaza nombre, precio y stock a partir delNuevoProductorecibido sin tocar el id, y devuelveOptional.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
Optionalvacío en el servicio se traduce en un404en el controlador. La misma idea, en dos lenguajes distintos. - El CRUD completo son cuatro verbos:
POST(201),GET(200/404),PUT(200/404/400) yDELETE(204/404).PUTreemplaza el recurso entero y es idempotente;PATCHcambia una parte y no lo es necesariamente. verifyprueba 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.