Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado defender el producto y el proceso sobre la misma versión. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
La ampliación ya se utiliza desde el cliente. Hoy prepararás una versión reproducible. Una suite de regresión vuelve a comprobar lo anterior para detectar qué ha dejado de funcionar; el README debe permitir repetir ese resultado desde una instalación limpia.
Pruebas basadas en riesgos: Dónde poner el foco
En la recta final del proyecto el tiempo es limitado. No puedes probarlo absolutamente todo con el mismo nivel de detalle.
El principio rector del Testing Basado en Riesgos (Risk-Based Testing) establece que debes concentrar el esfuerzo donde el impacto de un fallo sea más destructivo para el negocio:
| Nivel de riesgo | Área de la aplicación | Consecuencia de un fallo | Tipo de prueba obligatoria |
|---|---|---|---|
| Crítico (P0) | Seguridad y Autorización (RBAC / ABAC) | Fuga de datos de clientes o usurpación de proyectos ajenos. | Tests con MockMvc simulando peticiones con tokens de distintos roles y usuarios no autorizados (403 Forbidden). |
| Crítico (P0) | Integridad transaccional y presupuestos | Pérdida económica o corrupción del balance contable. | Tests de concurrencia y límites presupuestarios con verificación de rollback en base de datos. |
| Alto (P1) | Integraciones externas y ficheros | Colapso del servidor por caídas ajenas o saturación de disco. | Tests de fallo de red en RestClient con degradación y subida de ficheros maliciosos (Path Traversal). |
| Medio (P2) | Paginación y ordenación de listados | Lentitud de navegación o páginas vacías. | Tests de repositorios y controladores con Pageable. |
La ley del blindaje de errores
En producción los errores son discretos: nunca exponen las tripas del servidor.
Toda excepción no controlada debe ser capturada por el GlobalExceptionHandler devolviendo un JSON Problem Details limpio con código 500 y un correlationId para auditoría interna, suprimiendo cualquier clase, línea de código Java o sentencia SQL.
La sincronización tridimensional de la entrega
Un proyecto no es solo el archivo .jar que compila. En el mundo empresarial una entrega de software es un paquete coherente en tres dimensiones:
- 1. Código Fuente Java (Limpio, sin warnings, refactorizado)
- 2. Contratos OpenAPI 3 (Sincronizados con los DTOs y errores reales)
- 3. Guía de Despliegue README (Reproducible en 3 pasos por cualquiera)
Si el código espera el campo fechaInicio pero el Swagger dice fecha_inicio y el README dice que la base de datos se llama test_db cuando el código busca gestion_proyectos, el proyecto está roto.
Estructura del README.md técnico profesional
Un buen README.md no cuenta qué es Java ni explica qué es un microservicio. Es una guía operacional concisa para que otro ingeniero levante y verifique el proyecto en 3 minutos:
# Gestor de Proyectos e Incidencias · Backend API
Servicio backend REST modular construido con Spring Boot 3.5, Spring Security (JWT),
PostgreSQL y cliente HTTP saliente hacia Open-Meteo.
## 1. Requisitos previos
* Java 21 (Eclipse Temurin o GraalVM)
* Docker y Docker Compose
* Maven 3.9+ (o utilizar `./mvnw` incluido)
## 2. Puesta en marcha en 3 pasos
1. **Iniciar la base de datos PostgreSQL:**
```bash
docker compose up -d
```
2. **Compilar y ejecutar la aplicación:**
```bash
./mvnw spring-boot:run
```
3. **Verificar que el servicio responde:**
Abrir en el navegador: `http://localhost:8080/swagger-ui.html`
## 3. Credenciales de prueba (data.sql)
| Usuario | Contraseña | Rol | Ámbito |
| :--- | :--- | :--- | :--- |
| `admin` | `password123` | `ADMINISTRADOR` | Acceso global a todos los recursos y presupuestos |
| `jefe1` | `password123` | `JEFE_PROYECTO` | Responsable de los proyectos PRJ-2026-001 y 002 |
| `operario1` | `password123` | `DESARROLLADOR` | Operario asignado a tareas de campo |
## 4. Documentación interactiva (Swagger UI)
* URL: `http://localhost:8080/swagger-ui.html`
* Para probar endpoints protegidos: autenticarse en `/api/v1/auth/login`, copiar el token Bearer y pulsar en el botón **Authorize**.
## 5. Colección de pruebas de integración
En la carpeta `/bruno` se incluye la colección completa exportada para verificar los flujos de negocio sin depender del frontend.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre los criterios de la ampliación y ejecuta los tests y la colección completa. Anota fallos con su petición o test reproducible.
- Revisa requisitos, variables, base de datos y comandos del README. Localiza scripts que dependan de rutas de tu ordenador o datos manuales.
- Prepara un entorno de desarrollo separado para comprobar el arranque desde el repositorio, manteniendo intacto el que usas para trabajar.
Paso 2 · La suite de regresión final
Prepara en cada test los usuarios y recursos que necesita, en un entorno de pruebas separado; el nombre utilizado por @WithMockUser debe coincidir con la fila cuando el servicio consulta al usuario. Reutiliza los métodos de prueba de las unidades anteriores y añade únicamente los riesgos de la ampliación. Para un 409 por tareas pendientes crea expresamente ese estado. Para un fallo interno utiliza un colaborador de prueba controlado, nunca una ruta pública de producción que provoque errores deliberados.
| Riesgo técnico identificado | Prueba de mitigación implementada | Clase de test |
|---|---|---|
| Un operario intenta aprobar un presupuesto o reasignar un proyecto. | Petición con JWT de operario a PATCH /proyectos/{id}/presupuesto esperando 403. |
SeguridadAutorizacionIntegrationTest |
| La API de Open-Meteo se cae durante una guardia nocturna. | Simulación de ResourceAccessException en ClimaService esperando 201 y aviso. |
ClimaDegradacionIntegrationTest |
| Se intenta cerrar un proyecto que tiene tareas activas. | Llamada a POST /proyectos/{id}/cerrar esperando 409 Conflict. |
ProyectoCicloVidaIntegrationTest |
Se envía un archivo .sh ejecutable o de 20 MB. |
Petición multipart esperando 400 Bad Request o 413 Payload Too Large. | AdjuntoSeguridadIntegrationTest |
package com.ejemplo.gestor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class RiesgosCriticosIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(username = "operario1", roles = {"DESARROLLADOR"})
@DisplayName("Riesgo 1: Un usuario sin rol directivo no puede aprobar presupuestos (403)")
void operario_noPuedeAprobarPresupuesto() throws Exception {
mockMvc.perform(patch("/api/v1/proyectos/1/presupuesto")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nuevoPresupuesto\": 80000.00}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "jefe1", roles = {"JEFE_PROYECTO"})
@DisplayName("Riesgo 2: Un proyecto con tareas pendientes no puede cerrarse (409 Conflict)")
void cerrarProyecto_conTareasPendientes_devuelveConflicto() throws Exception {
mockMvc.perform(post("/api/v1/proyectos/1/cerrar"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.status").value(409))
.andExpect(jsonPath("$.title").value("Conflicto de negocio"));
}
}
- Por qué aquí sí es
@SpringBootTesty no@WebMvcTest - En la UD7 y la UD9 usabas cortes (slices) para probar una capa aislada y rápido. Estos tests son distintos: comprueban que las piezas encajan entre sí —seguridad, servicio, transacción y base de datos en la misma petición—, y eso solo se ve con el contexto entero levantado. Tardan segundos en vez de milisegundos, y por eso son pocos y elegidos.
- Contra qué base de datos corren
- Contra una de verdad. Crea
src/test/resources/application-test.propertiesapuntando a una basegestion_proyectos_testseparada, añade@ActiveProfiles("test")a la clase y usaddl-auto=create-dropahí: cada ejecución parte de un esquema limpio. Nunca ejecutes la suite contra la base de datos donde tienes tus datos de demostración. - El orden importa, y eso es un problema
- El test del riesgo 2 asume que el proyecto
1existe y tiene tareas pendientes. Si otro test lo cierra antes, este falla sin que nada esté roto. Anota@Sqlo un@BeforeEachque cree sus propios datos: un test que depende de lo que hicieron los anteriores es un test que mentirá tarde o temprano.
Los dos tests anteriores comprueban lo que la aplicación hace. Este comprueba lo que no debe decir:
@Test
@WithMockUser(username = "jefe1", roles = {"JEFE_PROYECTO"})
@DisplayName("Riesgo 3: un error interno no revela clases, SQL ni trazas de pila")
void errorInterno_noFiltraDetallesTecnicos() throws Exception {
String cuerpo = mockMvc.perform(post("/api/v1/proyectos/999999/cerrar"))
.andExpect(status().is4xxClientError())
.andReturn().getResponse().getContentAsString();
// Ninguna de estas cadenas puede aparecer jamás en una respuesta al cliente
for (String prohibido : new String[] {
"org.hibernate", "org.springframework", "com.ejemplo.gestor",
"SQL", "select ", "Exception", ".java:" }) {
assertThat(cuerpo)
.as("La respuesta no debe contener '%s'", prohibido)
.doesNotContain(prohibido);
}
}
Un 500 con una traza de Hibernate le regala a un atacante el nombre de tus tablas, tu versión de Spring y la estructura de tus paquetes. Este test convierte esa regla en algo que la suite vigila sola.
Paso 3 · Auditoría completa con Maven y JaCoCo
Ejecuta el ciclo de vida completo de Maven en tu terminal:
./mvnw clean verify
-
Verificación de verde total: Comprueba que la consola concluye con:
[INFO] BUILD SUCCESS[INFO] Tests run: <los tuyos>, Failures: 0, Errors: 0, Skipped: 0 -
Inspección del informe JaCoCo: Abre
target/site/jacoco/index.html.- Verifica que la cobertura de ramas (Branch Coverage) en los paquetes
serviceysecuritysupera el 75 %. - Constata que no quedan ramas condicionales críticas en color amarillo.
- Verifica que la cobertura de ramas (Branch Coverage) en los paquetes
-
Lectura crítica del informe, no solo del porcentaje:
- Ordena los paquetes por cobertura ascendente y quédate con los tres peores.
- De cada uno, decide una de dos cosas: o escribes el test que falta, o anotas por qué esa clase no lo necesita (un DTO sin lógica, por ejemplo). Las dos respuestas son válidas; lo que no vale es no haber mirado.
- Busca en
servicelosifque JaCoCo pinta en amarillo: significa que la condición se ha ejecutado, pero solo por una de sus dos ramas. En una regla de negocio, la rama que nunca se ha probado suele ser justo la que rechaza.
Paso 4 · Si algo no sale como dice el guion
| Síntoma | Causa casi segura | Qué mirar |
|---|---|---|
| Los tests pasan sueltos pero fallan todos juntos | Se pisan los datos entre sí | Cada test debe crear lo que necesita; añade @Transactional a la clase para que revierta al terminar |
Table 'proyectos' not found en los tests |
El perfil de test no se aplica | Falta @ActiveProfiles("test") o el archivo application-test.properties |
403 en todos los POST y PATCH de los tests |
CSRF activo en el contexto completo | Añade .with(csrf()) a la petición, o comprueba que tu configuración JWT lo desactiva |
| JaCoCo no genera informe | El plugin no está enganchado a la fase | El prepare-agent debe ejecutarse antes de test, y report en verify |
| Cobertura muy alta y aun así aparecen fallos a mano | Estás midiendo líneas, no ramas | Mira la columna Branch, no la de Instructions |
Paso 5 · Comprobar la API siguiendo solo su documentación pública
Los tests automáticos comprueban lo que se te ocurrió comprobar. Esta pasada busca lo que no.
- Ejecuta la colección completa de tu cliente HTTP de principio a fin: autenticación → alta de proyecto → tareas → incidencia con adjunto → descarga → cierre.
- Hazlo contra la base de datos vacía, arrancando de cero. Es la única forma de detectar los pasos que solo funcionan porque tienes datos antiguos a mano.
- Repite la secuencia entera con cada uno de los tres roles. Anota cada respuesta que te sorprenda, aunque sea un código correcto con un mensaje confuso.
- Prueba a propósito las cinco barbaridades que un usuario real acabará haciendo: enviar el cuerpo vacío, mandar un
idque no existe, mandar texto donde esperas un número, repetir dos veces la misma alta y usar el token de otro usuario. - Por cada fallo encontrado, haz dos cosas en este orden: primero escribe el test que lo reproduce en rojo, y después arréglalo. Si lo arreglas antes, nunca sabrás si el test lo habría cazado.
- Cierra con el número que resume la sesión: cuántos fallos ha encontrado la pasada manual que la suite automática no había visto. Ese número es la medida real de la calidad de tus tests, y es lo que se defiende en la sesión 52.
- Cómo saber que lo has terminado
./mvnw clean verifytermina enBUILD SUCCESS; la cobertura de ramas deserviceysecuritypasa del 75 %; ningún cuerpo de respuesta contiene el nombre de un paquete o de una tabla; y cada fallo que encontraste a mano tiene ahora un test que lo vigila.
Documentación y refactorización
Paso 6 · Refactorización limpia y eliminación de deuda
Refactorizar es cambiar la forma sin cambiar el comportamiento. Sin una manera de comprobar que el comportamiento no ha cambiado, no estás refactorizando: estás reescribiendo a ciegas.
- Ejecuta
./mvnw clean verifyy comprueba que todo está en verde antes de empezar. - Haz
git commitde ese estado. Es tu punto de retorno. - A partir de aquí, la regla es: un cambio pequeño → ejecutar los tests → commit. Si algo se pone en rojo, sabes exactamente qué lo rompió porque solo has tocado una cosa.
Busca en tu código literales sueltos con Ctrl+Shift+F: números que no sean 0 o 1, y cadenas entre comillas que no sean mensajes.
// ANTES: ¿qué es 150000? ¿por qué 150000?
if (proyecto.getPresupuestoTotal().compareTo(new BigDecimal("150000.0")) > 0) {
throw new ReglaDeNegocioException("Presupuesto excedido");
}
// DESPUÉS: el número tiene nombre, y vive en un solo sitio
public static final BigDecimal PRESUPUESTO_MAXIMO_SIN_APROBACION = new BigDecimal("150000.00");
if (proyecto.getPresupuestoTotal().compareTo(PRESUPUESTO_MAXIMO_SIN_APROBACION) > 0) {
throw new ReglaDeNegocioException("Presupuesto excedido");
}
Si el valor puede cambiar sin recompilar —un límite de tamaño de fichero, una URL, un tiempo de expiración—, deja de ser una constante para ser una propiedad de configuración. Sácalo a application.properties e inyéctalo con @Value.
Recorre tus controladores y comprueba que ningún método contiene: un if de negocio, una cuenta, una llamada a un repositorio o un try/catch. Un método de controlador tiene tres líneas: recibe, delega, responde.
// ANTES: el controlador está decidiendo
@PatchMapping("/{id}/estado")
public ResponseEntity<TareaResponse> cambiarEstado(@PathVariable Long id, @RequestBody EstadoRequest req) {
Tarea tarea = tareaRepository.findById(id).orElseThrow();
if (tarea.getEstado() == EstadoTarea.FINALIZADA) {
return ResponseEntity.status(409).build();
}
tarea.setEstado(req.nuevoEstado());
tareaRepository.save(tarea);
return ResponseEntity.ok(TareaMapper.aRespuesta(tarea));
}
// DESPUÉS: el controlador solo traduce HTTP; la regla y el 409 viven en el servicio
@PatchMapping("/{id}/estado")
public ResponseEntity<TareaResponse> cambiarEstado(@PathVariable Long id,
@Valid @RequestBody EstadoRequest req) {
return ResponseEntity.ok(tareaService.cambiarEstado(id, req.nuevoEstado()));
}
Si esto te suena, es porque es exactamente el ejercicio de la sesión 15, «El controller monstruoso». Cinco meses después, el código vuelve a engordar por el mismo sitio: esa recurrencia es la lección.
- Elimina los
importno utilizados (tu IDE los marca en gris;Ctrl+Alt+Oen IntelliJ los quita todos). - Borra los métodos privados que nadie llama y los endpoints de prueba que fuiste dejando por el camino:
/clima-rawde la sesión 41, cualquier/test,/boomo/diagnostico. Están sin proteger y sin documentar. - Borra los comentarios que ya mienten. Un comentario que contradice al código es peor que no tener comentario: el código es verdad por definición y el comentario engaña al que lo lee.
- Ejecuta
./mvnw clean verifyuna última vez. Si sigue verde, has refactorizado. Si no, has cambiado el comportamiento sin querer, y ahí tienes el porqué del paso 1.
Paso 7 · La prueba del desarrollador nuevo
Crea un clon en una carpeta distinta y una base de datos local vacía destinada a esta comprobación. Sigue el README sin reutilizar variables, archivos o servicios ocultos del entorno habitual. Ejecuta migraciones o preparación de esquema, datos iniciales de prueba, arranque, autenticación y colección en ese orden. Si falta un paso, añádelo al README y vuelve a empezar por ese punto desde un estado conocido; no borres datos del entorno de trabajo para simular una instalación limpia.
Paso 8 · Si algo no sale como dice el guion
| Síntoma | Causa casi segura | Qué mirar |
|---|---|---|
| Tras refactorizar, un test se pone en rojo | Has cambiado comportamiento, no solo forma | Vuelve al último commit verde y repite el cambio en trozos más pequeños |
docker compose up -d falla con port is already allocated |
Ya tienes un PostgreSQL escuchando en 5432 | Párale, o mapea otro puerto en el docker-compose.yml y ajusta la URL |
| La aplicación arranca en tu máquina pero no en la limpia | Hay configuración que solo existe en tu equipo | Variables de entorno, rutas absolutas de la carpeta de adjuntos o una base de datos creada a mano meses atrás |
Schema-validation: missing table con ddl-auto=validate |
El schema.sql no está sincronizado con las entidades |
Es justo lo que este modo existe para detectar: corrige el script, no bajes a update |
El README funciona para ti y para nadie más |
Lo has probado con la aplicación ya arrancada | La prueba solo vale desde una terminal nueva y una base de datos recién creada |
Paso 9 · Pulir y validar Swagger UI
Entra en http://localhost:8080/swagger-ui.html y haz la última pasada de contrato:
- Revisa cada uno de los endpoints de la aplicación, uno por uno, sin saltarte ninguno.
- Comprueba que todos los esquemas de respuesta tienen ejemplos legibles y que los códigos de error 400, 401, 403, 404 y 409 están formalmente documentados con
@ApiResponse. - Configura el esquema de seguridad para que Swagger sepa pedir el token: añade a
OpenApiConfigunSecuritySchemede tipoHTTPcon esquemabearery formatoJWT, y comprueba que aparece el botón Authorize. Sin él, ningún endpoint protegido se puede probar desde la documentación, y un evaluador que solo tenga tu Swagger no verá funcionar la mitad de la aplicación. - Corrige cualquier incoherencia de nombres entre los DTO: si en un sitio es
fechaInicioy en otrofecha_inicio, quien consuma tu API va a tropezar exactamente ahí. - Descarga
http://localhost:8080/v3/api-docsy guárdalo comoopenapi.jsonjunto alREADME. Es el contrato congelado de la versión que entregas. - La prueba del contrato de tres minutos: dale a un compañero la URL de tu Swagger, sin explicarle nada, y pídele que se autentique y cree un proyecto con una tarea. Cronométralo. Si tarda más de tres minutos o tiene que preguntarte algo, la documentación no está terminada.
- Cómo saber que lo has terminado
- La suite sigue verde después de refactorizar; no queda ningún literal numérico de negocio suelto en el código; ningún controlador contiene un
if; una persona ajena ha levantado tu proyecto siguiendo solo elREADME, y otra ha usado tu API entera desde Swagger sin preguntarte nada.
Paso 10 · Comprobar y registrar el resultado del proyecto
- Sigue el README desde ese entorno y ejecuta el recorrido principal y sus rechazos sin completar pasos de memoria.
- Después de cualquier refactorización repite las pruebas afectadas. Identifica la versión final y deja documentadas las limitaciones que sigan abiertas.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · Detección de fugas de memoria y rendimiento en carga
Simula una ráfaga de 100 peticiones concurrentes utilizando una herramienta de estrés como Apache Bench (ab) o k6:
ab -n 500 -c 20 -H "Authorization: Bearer <token>" http://localhost:8080/api/v1/proyectos
Comprueba que el tiempo medio de respuesta se mantiene por debajo de 50 ms y que el pool de conexiones de HikariCP en PostgreSQL no sufre agotamiento.
./mvnw test.Ver respuestas
1 · Porque asegura que los recursos limitados se inviertan en blindar las áreas donde un fallo tendría consecuencias catastróficas (seguridad, dinero, datos), en lugar de perder tiempo en piezas triviales.
2 · El test funcional verifica una funcionalidad recién creada; el test de regresión comprueba que los cambios nuevos no han roto nada de lo que ya funcionaba previamente en el sistema.
3 · Capturando la excepción genérica Exception.class y devolviendo una respuesta estándar 500 con un mensaje neutro ("Error interno del servidor") y un correlationId, sin volcar la traza de la excepción al JSON.
4 · ./mvnw clean verify (ejecuta el ciclo completo hasta la fase de verificación emitiendo los reportes).
Reto · Contenedorización completa con Docker Compose
Diseña un archivo docker-compose.yml que levante tanto la base de datos PostgreSQL como la propia aplicación Spring Boot empaquetada:
- Diseña un
Dockerfilemultietapa (Multi-stage Build) con Eclipse Temurin 21: una primera etapa con Maven que compile el.jar, y una segunda que solo copie ese.jarsobre una imagen con JRE. - Configura en
docker-compose.ymlla dependenciadepends_oncon comprobación de salud (healthcheck) usandopg_isready, para que Spring Boot no arranque hasta que PostgreSQL acepte conexiones. - Recuerda que dentro de la red de Docker el host de la base de datos ya no es
localhost, sino el nombre del servicio (db). Externaliza la URL con una variable de entorno en lugar de dejarla escrita enapplication.properties. - Comprueba que con un único comando
docker compose up --buildel sistema completo queda operativo en un ordenador que no tenga ni Java ni PostgreSQL instalados.
Ver respuestas
1 · Porque permite a cualquier evaluador o nuevo compañero probar de inmediato la matriz de permisos y el comportamiento de la seguridad sin tener que inspeccionar los scripts SQL o adivinar contraseñas.
2 · Un controlador que carece por completo de lógica de negocio o acceso a datos; su única función es deserializar la petición, validar anotaciones básicas, invocar al servicio correspondiente y retornar la respuesta HTTP tipada.
3 · Porque los tests en verde actúan como red de seguridad inmutable: si durante la limpieza de código introduces un error sutil, los tests fallarán al instante avisándote de la regresión.
4 · Separa la fase pesada de compilación (Maven + JDK completa) de la imagen final de ejecución (solo JRE ligera), reduciendo el tamaño de la imagen Docker de 800 MB a menos de 200 MB y mejorando la seguridad.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
El commit desplegado se corresponde con la versión probada y otra persona puede reproducir el recorrido principal.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.