Módulo 23: Testing y despliegue
Objetivos del módulo
- Diseñar una estrategia de pruebas para una aplicación Spring Boot: unitarias, slices e integración
- Conocer
spring-boot-starter-testy los starters de pruebas por módulo de Spring Boot 4 - Escribir pruebas unitarias de servicios con JUnit 6, Mockito y AssertJ, sin arrancar Spring
- Probar la capa de datos con
@DataJpaTestsobre H2 y sobre un MySQL real con Testcontainers y@ServiceConnection - Probar la API y las vistas Thymeleaf con
@WebMvcTest,MockMvcTestery@MockitoBean - Probar la seguridad con
spring-security-test:@WithMockUser,user(),jwt()ycsrf() - Arrancar la aplicación completa con
@SpringBootTesten un puerto aleatorio y hacerle peticiones conRestTestClient - Medir la cobertura con JaCoCo e interpretar la salida de
mvn verify - Empaquetar el JAR ejecutable y configurarlo para producción con variables de entorno y el perfil
prod - Crear una imagen Docker con un Dockerfile multietapa y con Buildpacks, y desplegar la aplicación y MySQL con Docker Compose
- Usar Actuator en producción (sondas liveness y readiness), gestionar los logs y automatizar la compilación con GitHub Actions
1. Por qué y qué probar
En el módulo 11 aprendiste JUnit, Mockito, AssertJ y Testcontainers con programas Java sencillos. Ahora toca aplicarlos a empresa-app, que ya tiene servicios, repositorios, una API, vistas y seguridad. Una aplicación así no se prueba «arrancándola y haciendo clic»: cada cambio podría romper algo en otra pantalla y no te enterarías hasta que lo sufriera un usuario.
La idea clave es la pirámide de pruebas: muchas pruebas pequeñas y rápidas en la base, y pocas pruebas grandes y lentas en la cima.
Figura 1. La pirámide de pruebas adaptada a Spring Boot, con las clases de prueba de
empresa-app.
| Nivel | Qué carga | Tiempo | Qué detecta | En empresa-app |
|---|---|---|---|---|
| Unitaria | Una clase; sus dependencias son mocks | Milisegundos | Errores de lógica: cálculos, reglas de negocio | PedidoServiceTest, TokenServiceTest, ImporteLineaTest |
| Slice | Una capa de Spring (web o JPA) | Segundos | Mapeos, consultas, JSON, validación, seguridad de URL | ProductoApiControllerTest, EmpleadoControllerTest, RepositoriosH2Test |
| Integración | La aplicación entera con servidor y base de datos | Decenas de segundos | Que las piezas encajan: configuración, transacciones, HTTP real | EmpresaAppIntegracionTest, EmpresaAppMySqlIT |
| Sistema / E2E | La aplicación desplegada (Docker) y un navegador | Minutos | Flujos completos del usuario | Fuera del alcance de este módulo (Playwright, Selenium) |
📘 Concepto: Una prueba de slice («rebanada») arranca solo una parte del contexto de Spring: con
@WebMvcTest, los controladores, los filtros y la seguridad, pero no los servicios ni la base de datos; con@DataJpaTest, las entidades y los repositorios, pero no la web. Son mucho más rápidas que arrancar todo y, cuando fallan, el motivo está acotado.
Figura 2. Qué parte de la aplicación carga cada tipo de prueba.
Convenciones de nombres
| Sufijo | Plugin de Maven | Fase | Uso |
|---|---|---|---|
*Test, *Tests | Surefire | test | Unitarias y slices (y las de integración rápidas) |
*IT | Failsafe | integration-test / verify | Integración lenta (contenedores, aplicación completa) |
2. Las dependencias de prueba en Spring Boot 4
Spring Boot 4 ha dividido su autoconfiguración en módulos (spring-boot-webmvc, spring-boot-data-jpa, spring-boot-security…), y cada uno tiene su propio módulo de pruebas. Por eso Spring Initializr ya no añade un único spring-boot-starter-test, sino un starter de pruebas por cada starter que elijas. El pom.xml de empresa-app tiene estos:
<!-- Un starter de pruebas por cada starter de la aplicación -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId> <!-- @WebMvcTest, MockMvc -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId> <!-- @DataJpaTest -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId> <!-- spring-security-test -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server-test</artifactId>
<scope>test</scope>
</dependency>
<!-- ... y también actuator-test, thymeleaf-test y validation-test -->
<!-- Integración: cliente HTTP de pruebas y Testcontainers -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-resttestclient</artifactId> <!-- RestTestClient, TestRestTemplate -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId> <!-- @ServiceConnection -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId> <!-- @Testcontainers, @Container -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId> <!-- MySQLContainer -->
<scope>test</scope>
</dependency>
Todos incluyen, a través de spring-boot-starter-test, las librerías de siempre. Estas son las versiones que gestiona Spring Boot 4.1.1:
| Librería | Versión | Para qué |
|---|---|---|
| JUnit (Jupiter) | 6.0.3 | El motor de pruebas: @Test, @ParameterizedTest, @BeforeEach… |
| AssertJ | 3.27.7 | Aserciones fluidas: assertThat(x).isEqualTo(...) |
| Mockito | 5.23.0 | Mocks: @Mock, when(...), verify(...) |
| Spring Test | 7.0.9 | MockMvcTester, @MockitoBean, RestTestClient, @Sql, contexto de pruebas |
| Spring Security Test | 7.1.1 | @WithMockUser, user(), jwt(), csrf() |
| Testcontainers | 2.0.5 | Contenedores Docker desechables para las pruebas |
| JSONassert, JsonPath, Hamcrest | gestionadas | Comparar y recorrer JSON |
💡 Consejo: Si migras un proyecto de Spring Boot 3 y no quieres ajustar las dependencias de golpe, existe
spring-boot-starter-test-classic, que trae todos los módulos de pruebas como antes. Para proyectos nuevos, usa los starters por módulo.
⚠️ Importante: En Spring Boot 4 las anotaciones de prueba han cambiado de paquete al dividirse en módulos. Si copias un ejemplo antiguo, el
importno compilará:
| Anotación | Paquete en Spring Boot 4 |
|---|---|
@SpringBootTest | org.springframework.boot.test.context (sin cambios) |
@WebMvcTest, @AutoConfigureMockMvc | org.springframework.boot.webmvc.test.autoconfigure |
@DataJpaTest | org.springframework.boot.data.jpa.test.autoconfigure |
@AutoConfigureTestDatabase | org.springframework.boot.jdbc.test.autoconfigure |
TestEntityManager | org.springframework.boot.jpa.test.autoconfigure |
@AutoConfigureRestTestClient, TestRestTemplate | org.springframework.boot.resttestclient(.autoconfigure) |
@ServiceConnection | org.springframework.boot.testcontainers.service.connection |
@MockitoBean, @MockitoSpyBean | org.springframework.test.context.bean.override.mockito (de Spring Framework) |
3. Pruebas unitarias del servicio con Mockito
La lógica más valiosa de empresa-app está en PedidoService.crear: comprueba el cliente, el stock y el catálogo, descuenta unidades y calcula importes. Es lógica pura si sustituimos los repositorios por mocks, así que se prueba sin Spring y sin base de datos:
/** Prueba unitaria: sin Spring, sin base de datos. Los repositorios son mocks de Mockito. */
@ExtendWith(MockitoExtension.class)
class PedidoServiceTest {
@Mock PedidoRepository pedidos;
@Mock ClienteRepository clientes;
@Mock EmpleadoRepository empleados;
@Mock ProductoRepository productos;
@InjectMocks PedidoService servicio; // Mockito crea el servicio con los cuatro mocks
private Cliente academia;
private Producto monitor;
@BeforeEach
void preparar() {
academia = new Cliente("Academia Nova", "B12345678");
monitor = new Producto("Monitor 24 FHD", "Monitor de 24 pulgadas", new Categoria("Monitores", null),
new BigDecimal("139.00"), 35);
}
@Test
@DisplayName("Crear un pedido descuenta el stock y calcula el total con descuento")
void crearDescuentaStock() {
when(clientes.findById(1004)).thenReturn(Optional.of(academia));
when(productos.findById(9)).thenReturn(Optional.of(monitor));
when(pedidos.save(any(Pedido.class))).thenAnswer(inv -> inv.getArgument(0)); // devuelve lo que recibe
var req = new NuevoPedidoRequest(1004, null,
List.of(new NuevoPedidoRequest.Linea(9, 3, new BigDecimal("5"))));
Pedido p = servicio.crear(req);
assertThat(monitor.getStock()).isEqualTo(32); // 35 - 3
assertThat(p.getLineas()).hasSize(1);
assertThat(p.getTotal()).isEqualByComparingTo("396.15"); // 3 x 139 x 0,95
verify(pedidos).save(p);
}
@Test
@DisplayName("Sin stock suficiente se lanza ReglaNegocioException y no se guarda nada")
void stockInsuficiente() {
when(clientes.findById(1004)).thenReturn(Optional.of(academia));
when(productos.findById(9)).thenReturn(Optional.of(monitor));
var req = new NuevoPedidoRequest(1004, null, List.of(new NuevoPedidoRequest.Linea(9, 40, null)));
assertThatThrownBy(() -> servicio.crear(req))
.isInstanceOf(ReglaNegocioException.class)
.hasMessage("Stock insuficiente de Monitor 24 FHD: hay 35 y se piden 40");
assertThat(monitor.getStock()).isEqualTo(35);
verify(pedidos, never()).save(any());
}
@Test
@DisplayName("Un producto descatalogado no se puede vender")
void descatalogado() { ... }
@Test
@DisplayName("Un cliente inexistente produce RecursoNoEncontradoException")
void clienteInexistente() {
when(clientes.findById(9999)).thenReturn(Optional.empty());
var req = new NuevoPedidoRequest(9999, null, List.of(new NuevoPedidoRequest.Linea(9, 1, null)));
assertThatThrownBy(() -> servicio.crear(req)).isInstanceOf(RecursoNoEncontradoException.class);
verify(productos, never()).findById(any());
}
}
Resultado real de la clase dentro de mvn verify:
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.356 s -- in es.curso.empresa.servicio.PedidoServiceTest
Cuatro pruebas en 0,36 segundos. Por comparación, cualquier prueba que arranque Spring tarda varios segundos solo en crear el contexto.
| Herramienta | Uso en la prueba |
|---|---|
@ExtendWith(MockitoExtension.class) | Activa Mockito en JUnit; además avisa de stubs que no se usan (pruebas «sucias») |
@Mock | Crea un doble del repositorio: todos sus métodos devuelven valores vacíos salvo que digas otra cosa |
@InjectMocks | Crea el objeto bajo prueba pasándole los mocks por el constructor |
when(...).thenReturn(...) / thenAnswer(...) | Programa la respuesta de un método |
verify(mock).metodo(...) / never() | Comprueba que se llamó (o no) a un método |
assertThatThrownBy(...) | Comprueba la excepción y su mensaje |
💡 Consejo: ¿Por qué aquí no se nota el
@Transactionaldecrear? Porque sin Spring no hay proxy ni transacción: la prueba unitaria comprueba la lógica; que el rollback funcione de verdad lo comprueban las pruebas de integración. Cada nivel de la pirámide tiene su papel.
4. Pruebas de la capa de datos con @DataJpaTest
@DataJpaTest arranca solo JPA: entidades, repositorios, DataSource y un TestEntityManager. Cada prueba se ejecuta dentro de una transacción que se deshace al terminar, así que las pruebas no se ensucian los datos unas a otras.
/**
* Slice de JPA: solo entidades, repositorios y DataSource. Cada prueba se ejecuta
* en una transacción que se deshace al terminar (rollback), así no se ensucian los datos.
*/
@DataJpaTest(properties = // base de datos H2 propia: otra prueba ya tiene abierta jdbc:h2:mem:empresa
"spring.datasource.url=jdbc:h2:mem:repositorios;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH")
@ActiveProfiles("h2") // H2 con los datos de EMPRESA
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // usa la URL del perfil h2
class RepositoriosH2Test {
@Autowired ProductoRepository productos;
@Autowired PedidoRepository pedidos;
@Autowired UsuarioRepository usuarios;
@Test
void productosNuncaVendidos() {
List<String> nombres = productos.nuncaVendidos().stream().map(Producto::getNombre).toList();
assertThat(nombres).contains("Altavoz Inteligente").doesNotContain("Monitor 24 FHD");
}
@Test
void stockBajoSinDescatalogados() {
assertThat(productos.findByStockLessThanAndDescatalogadoFalse(10))
.extracting(Producto::getNombre)
.containsExactlyInAnyOrder("Torre Workstation", "Pack teclado y ratón", "Smartphone Gama Alta",
"Mesa Elevable", "Impresora Láser");
}
@Test
void facturacionOrdenadaDeMayorAMenor() {
List<FacturacionCliente> f = pedidos.facturacionPorCliente();
assertThat(f).isNotEmpty();
assertThat(f).extracting(FacturacionCliente::total).isSortedAccordingTo((a, b) -> b.compareTo(a));
}
@Test
void usuarioConRolesYEmpleado() {
Usuario ana = usuarios.findByUsername("ana").orElseThrow();
assertThat(ana.getRoles()).extracting(r -> r.getNombre()).containsExactlyInAnyOrder("ADMIN", "EMPLEADO");
assertThat(ana.getEmpleado().getNombreCompleto()).isEqualTo("Ana García López");
assertThat(ana.getPassword()).startsWith("$2a$10$"); // guardada con BCrypt
}
}
| Anotación / atributo | Para qué |
|---|---|
@DataJpaTest | Slice de JPA con rollback automático tras cada prueba |
@ActiveProfiles("h2") | Usa application-h2.properties: H2 en modo MySQL y los scripts de EMPRESA y de seguridad |
@AutoConfigureTestDatabase(replace = NONE) | Por defecto, @DataJpaTest sustituye el DataSource por una H2 vacía generada; con NONE respeta el nuestro |
properties = "spring.datasource.url=..." | Propiedades solo para esta clase de prueba |
⚠️ Importante: La primera versión de esta prueba no tenía el atributo
propertiesy falló conTable "departamentos" already exists. Spring guarda en caché los contextos de prueba para reutilizarlos, y la prueba de integración (sección 7) ya tenía abierta la base de datos en memoriajdbc:h2:mem:empresa, con sus tablas. Al crear el contexto de@DataJpaTestcon la misma URL, los scripts de creación se ejecutaron otra vez sobre la misma base de datos. La solución es dar a cada contexto su propia base de datos en memoria (jdbc:h2:mem:repositorios).
Salida real:
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.08 s -- in es.curso.empresa.repositorio.RepositoriosH2Test
Testcontainers: las mismas pruebas contra MySQL de verdad
H2 en «modo MySQL» se parece a MySQL, pero no es MySQL: funciones de fecha, ordenación de textos con tildes, tipos… Para estar seguro de que tus consultas funcionan en producción, pruébalas contra el mismo motor. Testcontainers arranca un contenedor Docker de MySQL 8.4 para la prueba y lo destruye al terminar:
/**
* El mismo tipo de prueba, pero contra un MySQL 8.4 REAL que Testcontainers arranca en Docker.
* Los scripts de EMPRESA se copian a /docker-entrypoint-initdb.d y MySQL los ejecuta al iniciar.
*/
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // no sustituir por H2
class PedidoRepositoryMySqlTest {
@Container
@ServiceConnection // Boot configura spring.datasource.* con la URL, usuario y clave del contenedor
static MySQLContainer mysql = new MySQLContainer("mysql:8.4")
.withDatabaseName("empresa")
.withCopyFileToContainer(MountableFile.forClasspathResource("db/mysql/01-empresa-crear.sql"),
"/docker-entrypoint-initdb.d/01-empresa-crear.sql")
.withCopyFileToContainer(MountableFile.forClasspathResource("db/mysql/02-empresa-datos.sql"),
"/docker-entrypoint-initdb.d/02-empresa-datos.sql")
.withCopyFileToContainer(MountableFile.forClasspathResource("db/mysql/03-seguridad.sql"),
"/docker-entrypoint-initdb.d/03-seguridad.sql");
@Autowired PedidoRepository pedidos;
@Test
void hayTresPedidosPendientes() {
assertThat(pedidos.findByEstado(EstadoPedido.PENDIENTE, Pageable.unpaged()))
.extracting(Pedido::getId)
.containsExactlyInAnyOrder(5022, 5023, 5024);
}
@Test
void facturacionPorClienteEnMySql() {
List<FacturacionCliente> f = pedidos.facturacionPorCliente();
assertThat(f).hasSizeGreaterThan(5);
assertThat(f.getFirst().total()).isPositive();
System.out.println("Cliente que más factura: " + f.getFirst());
}
}
Los scripts de EMPRESA (empresa_mysql_crear.sql y empresa_mysql_datos.sql) y el de seguridad del módulo 22 se copian a src/test/resources/db/mysql/ con un prefijo numérico: la imagen oficial de MySQL ejecuta en orden alfabético los ficheros de /docker-entrypoint-initdb.d.
| Pieza | Qué hace |
|---|---|
@Testcontainers | Extensión de JUnit que arranca y para los campos @Container |
@Container static | Un contenedor para toda la clase (si no es static, uno por prueba: lentísimo) |
MySQLContainer("mysql:8.4") | Contenedor de MySQL. En Testcontainers 2 está en org.testcontainers.mysql (la clase antigua de org.testcontainers.containers está obsoleta) |
@ServiceConnection | Spring Boot lee del contenedor la URL, el usuario y la contraseña y configura el DataSource. Ya no hace falta @DynamicPropertySource |
El log real muestra lo que ocurre (resumido):
INFO org.testcontainers.DockerClientFactory : Testcontainers version: 2.0.5
INFO org.testcontainers.DockerClientFactory : Docker host IP address is host.docker.internal
INFO tc.testcontainers/ryuk:0.14.0 : Container testcontainers/ryuk:0.14.0 started in PT4.592518664S
INFO org.testcontainers.DockerClientFactory : ✔︎ Docker server version should be at least 1.6.0
INFO tc.mysql:8.4 : Creating container for image: mysql:8.4
INFO tc.mysql:8.4 : Waiting for database connection to become available at jdbc:mysql://host.docker.internal:51823/empresa using query 'SELECT 1'
INFO tc.mysql:8.4 : Container mysql:8.4 started in PT52.078486999S
INFO tc.mysql:8.4 : Container is started (JDBC URL: jdbc:mysql://host.docker.internal:51823/empresa)
Database JDBC URL [jdbc:mysql://host.docker.internal:51823/empresa]
Cliente que más factura: FacturacionCliente[cliente=Instituto Tecnológico Sur, pedidos=2, total=20602.55000000]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 81.52 s -- in es.curso.empresa.repositorio.PedidoRepositoryMySqlTest
- Ryuk es un contenedor auxiliar de Testcontainers que borra los contenedores de prueba aunque la JVM termine de forma abrupta.
- El puerto (51823) es aleatorio: puedes ejecutar varias pruebas a la vez sin conflictos con tu MySQL de desarrollo.
- Arrancar MySQL y cargar EMPRESA tarda casi un minuto en el equipo donde se preparó este módulo: por eso se arranca una vez por clase y se reutiliza.
💡 Consejo: Testcontainers solo necesita Docker (Docker Desktop en Windows y macOS). En este curso ejecutamos Maven dentro de un contenedor, así que hubo que darle acceso al Docker del equipo montando el socket e indicar cómo llegar a los contenedores hermanos:
docker run ... -v /var/run/docker.sock:/var/run/docker.sock -e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal maven:3.9-eclipse-temurin-25 mvn verify. Si ejecutas Maven directamente en tu equipo, no necesitas nada de esto.
5. Pruebas de la API con @WebMvcTest y MockMvcTester
@WebMvcTest(ProductoApiController.class) arranca solo ese controlador, los @ControllerAdvice (nuestro ApiExceptionHandler), los convertidores JSON de Jackson 3, la validación y la seguridad. Las peticiones no pasan por la red: MockMvc las entrega directamente al DispatcherServlet.
Desde Spring Framework 6.2 existe MockMvcTester, una versión de MockMvc con aserciones de AssertJ, y Spring Boot la configura automáticamente en @WebMvcTest cuando AssertJ está en el classpath. Es la forma recomendada en proyectos nuevos.
/** Slice web: solo ProductoApiController, el ApiExceptionHandler y la seguridad. El servicio es un mock. */
@WebMvcTest(ProductoApiController.class)
@Import(SeguridadTestConfig.class)
class ProductoApiControllerTest {
@Autowired MockMvcTester mvc; // MockMvc con aserciones de AssertJ
@MockitoBean ProductoService servicio; // sustituye al bean real por un mock de Mockito
@MockitoBean UserDetailsService usuarios; // lo necesita SeguridadConfig (no se usa con JWT)
private Producto monitor() {
return new Producto("Monitor 24 FHD", "Monitor de 24 pulgadas", new Categoria("Monitores", null),
new BigDecimal("139.00"), 35);
}
@Test
void cualquieraPuedeConsultarUnProducto() {
when(servicio.buscar(9)).thenReturn(monitor());
assertThat(mvc.get().uri("/api/productos/9"))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson().extractingPath("$.nombre").isEqualTo("Monitor 24 FHD");
}
@Test
void productoInexistenteDevuelveProblemDetail404() {
when(servicio.buscar(999)).thenThrow(new RecursoNoEncontradoException("producto", 999));
assertThat(mvc.get().uri("/api/productos/999"))
.hasStatus(HttpStatus.NOT_FOUND)
.hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
.bodyJson().extractingPath("$.title").isEqualTo("Recurso no encontrado");
}
@Test
void borrarSinTokenDevuelve401() {
assertThat(mvc.delete().uri("/api/productos/14"))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
@Test
void borrarConRolEmpleadoDevuelve403() {
assertThat(mvc.delete().uri("/api/productos/14")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_EMPLEADO"))))
.hasStatus(HttpStatus.FORBIDDEN);
}
@Test
void borrarConRolAdminDevuelve204() {
assertThat(mvc.delete().uri("/api/productos/14")
.with(jwt().jwt(j -> j.subject("ana"))
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.hasStatus(HttpStatus.NO_CONTENT);
verify(servicio).borrar(14);
}
@Test
void crearConDatosNoValidosDevuelve400() {
assertThat(mvc.post().uri("/api/productos")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"nombre": "", "precio": -5, "stock": 1, "categoriaId": 6}"""))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson().extractingPath("$.errores.nombre").isNotNull();
}
}
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.129 s -- in es.curso.empresa.web.api.ProductoApiControllerTest
MockMvcTester | Ejemplo |
|---|---|
| Petición | mvc.get().uri("/api/productos/{id}", 9), .post(), .put(), .patch(), .delete() |
| Cuerpo y cabeceras | .contentType(MediaType.APPLICATION_JSON).content("{...}"), .header("X", "y") |
| Seguridad | .with(jwt()), .with(user(...)), .with(csrf()) |
| Estado | .hasStatusOk(), .hasStatus(HttpStatus.CREATED), .hasStatus4xxClientError() |
| JSON | .bodyJson().extractingPath("$.nombre").isEqualTo(...), .bodyJson().isLenientlyEqualTo("{...}") |
| Vistas MVC | .hasViewName("empleados/lista"), .model().containsKey("pagina"), .flash().containsEntry(...) |
| Redirecciones | .hasRedirectedUrl("/empleados"), .redirectedUrl().endsWith("/login") |
| HTML | .bodyText().contains("...") |
@MockitoBean sustituye a @MockBean
@MockitoBean reemplaza un bean del contexto de Spring por un mock de Mockito. Es la anotación de Spring Framework (desde 6.2) que sustituye a la clásica @MockBean de Spring Boot.
⚠️ Importante:
@MockBeany@SpyBeanestaban obsoletas desde Spring Boot 3.4 y ya no existen en Spring Boot 4: el JARspring-boot-test4.1.1 no contiene ninguna claseMockBean. Usa@MockitoBeany@MockitoSpyBean(paqueteorg.springframework.test.context.bean.override.mockito).
La seguridad en los slices
@WebMvcTest aplica la autoconfiguración de Spring Security, pero no escanea nuestras clases @Configuration: sin más, la prueba usaría la seguridad por defecto de Spring Boot (todo autenticado con HTTP Basic), no la nuestra. Para probar nuestras reglas, una configuración de prueba las importa:
/**
* Las pruebas de slice (@WebMvcTest) no cargan nuestras clases @Configuration:
* esta configuración de prueba importa la seguridad REAL de la aplicación.
*/
@TestConfiguration
@EnableConfigurationProperties(JwtProperties.class)
@Import({SeguridadConfig.class, JwtConfig.class, ProblemDetailSeguridad.class})
public class SeguridadTestConfig {
}
Como SeguridadConfig necesita un UserDetailsService (para el AuthenticationManager y el remember-me), la prueba lo aporta con @MockitoBean UserDetailsService.
6. Pruebas de las vistas Thymeleaf
Las vistas se prueban con el mismo @WebMvcTest: se comprueba la vista elegida, el modelo, las redirecciones, los mensajes flash y hasta el HTML generado.
/** Slice web de las páginas Thymeleaf de empleados: vista, modelo, HTML generado y seguridad. */
@WebMvcTest(EmpleadoController.class)
@Import(SeguridadTestConfig.class)
class EmpleadoControllerTest {
@Autowired MockMvcTester mvc;
@MockitoBean EmpleadoService empleados;
@MockitoBean DepartamentoService departamentos;
@MockitoBean FotoService fotos;
@MockitoBean UserDetailsService usuarios;
// Principales de prueba con NUESTRA clase (el layout muestra principal.nombreCompleto)
static final UsuarioAutenticado ANA = new UsuarioAutenticado("ana", "x", true,
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"), new SimpleGrantedAuthority("ROLE_EMPLEADO")),
"Ana García López", 100);
static final UsuarioAutenticado CARMEN = new UsuarioAutenticado("carmen", "x", true,
List.of(new SimpleGrantedAuthority("ROLE_EMPLEADO")), "Carmen Ruiz Ortega", 110);
@BeforeEach
void datos() {
Empleado pablo = new Empleado("Pablo", "Moreno Herrera", "pablo.moreno@empresa.es", "ANALISTA",
new BigDecimal("3500.00"), LocalDate.of(2014, 5, 5));
when(empleados.buscar(isNull(), isNull(), isNull(), isNull(), any())).thenReturn(new PageImpl<>(List.of(pablo)));
when(departamentos.listar()).thenReturn(List.of());
}
@Test
void anonimoEsRedirigidoAlLogin() {
assertThat(mvc.get().uri("/empleados"))
.hasStatus(HttpStatus.FOUND)
.redirectedUrl().endsWith("/login");
}
@Test
void empleadoVeElListadoSinBotonDeAlta() {
assertThat(mvc.get().uri("/empleados").with(user(CARMEN)))
.hasStatusOk()
.hasViewName("empleados/lista")
.model().containsKey("pagina");
assertThat(mvc.get().uri("/empleados").with(user(CARMEN)))
.bodyText()
.contains("Moreno Herrera, Pablo", "Carmen Ruiz Ortega")
.doesNotContain("/empleados/nuevo");
}
@Test
void adminVeElBotonDeAlta() {
assertThat(mvc.get().uri("/empleados").with(user(ANA)))
.hasStatusOk()
.bodyText().contains("/empleados/nuevo", "ADMIN");
}
@Test
@WithMockUser(roles = "EMPLEADO") // basta con un usuario ficticio: no se pinta ninguna vista
void empleadoNoPuedeBorrar() {
assertThat(mvc.post().uri("/empleados/120/borrar").with(csrf()))
.hasStatus(HttpStatus.FORBIDDEN);
verify(empleados, never()).borrar(any());
}
@Test
@WithMockUser(roles = "ADMIN")
void borrarSinTokenCsrfDevuelve403() {
assertThat(mvc.post().uri("/empleados/120/borrar")).hasStatus(HttpStatus.FORBIDDEN);
}
@Test
@WithMockUser(roles = "ADMIN")
void adminBorraYEsRedirigidoConMensajeFlash() {
assertThat(mvc.post().uri("/empleados/160/borrar").with(csrf()))
.hasStatus(HttpStatus.FOUND)
.hasRedirectedUrl("/empleados")
.flash().containsEntry("exito", "Empleado 160 borrado");
verify(empleados).borrar(160);
}
@Test
@WithMockUser(roles = "ADMIN")
void siElServicioLoImpideSeMuestraElError() {
doThrow(new ReglaNegocioException("Luis Martínez Ruiz es jefe de otros empleados"))
.when(empleados).borrar(101);
assertThat(mvc.post().uri("/empleados/101/borrar").with(csrf()))
.hasRedirectedUrl("/empleados/101")
.flash().containsEntry("fallo", "Luis Martínez Ruiz es jefe de otros empleados");
}
}
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 18.21 s -- in es.curso.empresa.web.mvc.EmpleadoControllerTest
Estas siete pruebas comprueban en segundos lo que en el módulo 22 hicimos a mano con el navegador y curl: la redirección al login, la visibilidad de los botones según el rol, el 403 de un EMPLEADO, el 403 sin token CSRF y el mensaje flash del patrón PRG.
⚠️ Importante: ¿Por qué en las pruebas que pintan una vista usamos
.with(user(CARMEN))y no@WithMockUser? Porque@WithMockUsercrea un principal de tipoUserde Spring, y nuestro layout muestraprincipal.nombreCompleto, que solo existe enUsuarioAutenticado. Lo comprobamos con una prueba con@WithMockUsery el listado de empleados; falló así (salida real):
TemplateProcessingException: Error retrieving value for property "principal.nombreCompleto" of authentication object of class org.springframework.security.authentication.UsernamePasswordAuthenticationToken (template: "fragmentos/layout" - line 51, col 27)
user(...)acepta cualquierUserDetails, así que le pasamos uno de los nuestros. Para las peticiones que redirigen sin pintar plantillas,@WithMockUseres suficiente y más cómodo.
Resumen de spring-security-test
| Herramienta | Simula |
|---|---|
@WithMockUser(username = "ana", roles = "ADMIN") | Un usuario autenticado en toda la prueba (principal de tipo User) |
@WithUserDetails("ana") | Un usuario cargado con tu UserDetailsService (necesita el bean real) |
@WithAnonymousUser | Un usuario anónimo |
.with(user("ana").roles("ADMIN")) / .with(user(miUserDetails)) | Un usuario en esa petición |
.with(jwt()) | Una petición con un JWT válido; .jwt(j -> j.claim(...)) añade claims y .authorities(...) fija las authorities |
.with(csrf()) | Añade un token CSRF válido a un POST |
.with(httpBasic("ana", "ana2026")) | Cabecera Authorization: Basic |
💡 Consejo:
jwt()no usa nuestroJwtAuthenticationConverter: si quieres probar que el claimrolesse convierte bien, indica las authorities explícitamente (como arriba) o prueba la conversión en una prueba de integración con un token real.
7. La aplicación completa con @SpringBootTest
La cima de la pirámide: la aplicación entera, con Tomcat en un puerto aleatorio, todos los beans, la seguridad real y la base de datos H2 del perfil h2. Las peticiones son HTTP de verdad.
Spring Framework 7 incluye RestTestClient, la versión para pruebas del RestClient del módulo 20, con una API fluida de aserciones parecida a la de WebTestClient. Spring Boot 4 lo configura con @AutoConfigureRestTestClient (módulo spring-boot-resttestclient) para que apunte al puerto aleatorio:
/**
* Prueba de integración: la aplicación COMPLETA arranca con Tomcat en un puerto aleatorio
* (perfil h2) y se le hacen peticiones HTTP reales, igual que haría un cliente.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@ActiveProfiles("h2")
class EmpresaAppIntegracionTest {
@Autowired RestTestClient http; // cliente ya apuntando a http://localhost:<puerto>
@Autowired ProductoRepository productos;
private String token(String usuario, String clave) {
TokenResponse r = http.post().uri("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body("""
{"username": "%s", "password": "%s"}""".formatted(usuario, clave))
.exchange()
.expectStatus().isOk()
.expectBody(TokenResponse.class)
.returnResult().getResponseBody();
assertThat(r).isNotNull();
return r.token();
}
@Test
void sinTokenLaApiDevuelve401() {
http.get().uri("/api/pedidos/5001").exchange()
.expectStatus().isUnauthorized()
.expectHeader().valueMatches("WWW-Authenticate", "Bearer.*");
}
@Test
void credencialesIncorrectasDevuelven401ConProblemDetail() {
http.post().uri("/api/auth/login").contentType(MediaType.APPLICATION_JSON)
.body("{\"username\": \"carmen\", \"password\": \"mala\"}")
.exchange()
.expectStatus().isEqualTo(HttpStatus.UNAUTHORIZED)
.expectBody().jsonPath("$.detail").isEqualTo("Usuario o contraseña incorrectos");
}
@Test
void unEmpleadoCreaUnPedidoYSeDescuentaElStock() {
int stockAntes = productos.findById(9).orElseThrow().getStock();
String token = token("carmen", "carmen2026");
http.post().uri("/api/pedidos")
.headers(h -> h.setBearerAuth(token))
.contentType(MediaType.APPLICATION_JSON)
.body("""
{"clienteId": 1004, "empleadoId": 110,
"lineas": [{"productoId": 9, "cantidad": 2, "descuento": 5}]}""")
.exchange()
.expectStatus().isCreated()
.expectHeader().exists("Location")
.expectBody()
.jsonPath("$.estado").isEqualTo("PENDIENTE")
.jsonPath("$.total").isEqualTo(264.10);
assertThat(productos.findById(9).orElseThrow().getStock()).isEqualTo(stockAntes - 2);
}
@Test
void unEmpleadoNoPuedeCambiarElEstadoDeUnPedido() {
String token = token("carmen", "carmen2026");
http.patch().uri("/api/pedidos/5022/estado")
.headers(h -> h.setBearerAuth(token))
.contentType(MediaType.APPLICATION_JSON)
.body("{\"estado\": \"ENVIADO\"}")
.exchange()
.expectStatus().isForbidden();
}
@Test
void laPaginaDeLoginEsPublicaYLlevaTokenCsrf() {
http.get().uri("/login").exchange()
.expectStatus().isOk()
.expectBody(String.class)
.value(html -> assertThat(html).contains("name=\"_csrf\"", "Iniciar sesión"));
}
@Test
void healthEstaDisponibleSinAutenticar() {
http.get().uri("/actuator/health").exchange()
.expectStatus().isOk()
.expectBody().jsonPath("$.status").isEqualTo("UP");
}
}
INFO ... e.c.empresa.EmpresaAppIntegracionTest : Started EmpresaAppIntegracionTest in 96.578 seconds (process running for 117.88)
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 122.1 s -- in es.curso.empresa.EmpresaAppIntegracionTest
webEnvironment | Efecto |
|---|---|
MOCK (por defecto) | Contexto web sin servidor real; se usa con @AutoConfigureMockMvc |
RANDOM_PORT | Tomcat en un puerto libre aleatorio (@LocalServerPort lo inyecta) |
DEFINED_PORT | En el puerto configurado (8080): choca si hay otra cosa escuchando |
NONE | Sin web: para aplicaciones de consola o pruebas de servicios |
⚠️ Importante: Con
RANDOM_PORTla prueba y el servidor se ejecutan en hilos distintos, así que no hay rollback automático aunque pongas@Transactionalen la prueba: el pedido creado se queda en la base de datos H2. Por eso la prueba compara el stock con el que había antes, en lugar de con un número fijo, y por eso es mejor que cada contexto tenga su propia base de datos.
💡 Consejo:
TestRestTemplatesigue existiendo en el mismo módulospring-boot-resttestclient(con@AutoConfigureTestRestTemplate), peroRestTemplateestá en mantenimiento desde hace años; para código nuevo,RestTestClient.
8. La salida de mvn verify y la cobertura con JaCoCo
El ciclo completo
mvn verify compila, ejecuta las pruebas unitarias (Surefire), empaqueta el JAR, ejecuta las de integración (Failsafe) y comprueba la cobertura. Este es un resumen de la salida real del proyecto con todas las pruebas de este módulo y del 22 (con el JDK 25 en Docker):
[INFO] Building empresa-app 0.0.1-SNAPSHOT
[INFO] --- jacoco:0.8.15:prepare-agent (preparar-agente) @ empresa-app ---
[INFO] --- spring-boot:4.1.1:build-info (default) @ empresa-app ---
[INFO] --- compiler:3.15.0:compile (default-compile) @ empresa-app ---
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ empresa-app ---
[INFO] --- surefire:3.5.6:test (default-test) @ empresa-app ---
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 122.1 s -- in es.curso.empresa.EmpresaAppIntegracionTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.936 s -- in es.curso.empresa.modelo.ImporteLineaTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.66 s -- in es.curso.empresa.repositorio.ClienteRepositoryTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 81.52 s -- in es.curso.empresa.repositorio.PedidoRepositoryMySqlTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.08 s -- in es.curso.empresa.repositorio.RepositoriosH2Test
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.027 s -- in es.curso.empresa.seguridad.TokenServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.578 s -- in es.curso.empresa.servicio.EmpleadoServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.356 s -- in es.curso.empresa.servicio.PedidoServiceTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.47 s -- in es.curso.empresa.web.api.PedidoApiControllerTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.129 s -- in es.curso.empresa.web.api.ProductoApiControllerTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.114 s -- in es.curso.empresa.web.Ejercicios22Test
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 18.21 s -- in es.curso.empresa.web.mvc.EmpleadoControllerTest
[INFO] Results:
[INFO] Tests run: 50, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jar:3.5.1:jar (default-jar) @ empresa-app ---
[INFO] Building jar: /w/target/empresa-app-0.0.1-SNAPSHOT.jar
[INFO] --- spring-boot:4.1.1:repackage (repackage) @ empresa-app ---
[INFO] Replacing main artifact /w/target/empresa-app-0.0.1-SNAPSHOT.jar with repackaged archive, adding nested dependencies in BOOT-INF/.
[INFO] --- failsafe:3.5.6:integration-test (default) @ empresa-app ---
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 160.3 s -- in es.curso.empresa.EmpresaAppMySqlIT
[INFO] Results:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jacoco:0.8.15:report (informe) @ empresa-app ---
[INFO] Analyzed bundle 'empresa-app' with 75 classes
[INFO] --- jacoco:0.8.15:check (cobertura-minima) @ empresa-app ---
[INFO] All coverage checks have been met.
[INFO] --- failsafe:3.5.6:verify (default) @ empresa-app ---
[INFO] BUILD SUCCESS
[INFO] Total time: 09:08 min
Fíjate en el orden de las fases: las pruebas IT se ejecutan después de empaquetar, y si una falla, failsafe:verify marca la build como fallida al final (así puede limpiar antes los contenedores). Los tiempos son los de un equipo compartido y cargado; en un portátil normal todo tarda bastante menos.
JaCoCo
JaCoCo (Java Code Coverage) mide qué líneas e instrucciones de tu código se ejecutan durante las pruebas. Se configura como plugin (la versión 0.8.14 fue la primera compatible con Java 25; usamos la 0.8.15, la última en Maven Central):
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.15</version>
<executions>
<execution>
<id>preparar-agente</id>
<goals>
<goal>prepare-agent</goal> <!-- añade el agente de JaCoCo a la JVM de las pruebas -->
</goals>
</execution>
<execution>
<id>informe</id>
<phase>verify</phase>
<goals>
<goal>report</goal> <!-- genera target/site/jacoco/index.html -->
</goals>
</execution>
</executions>
<configuration>
<!-- El código de los ejercicios de módulos anteriores no cuenta para la cobertura -->
<excludes>
<exclude>es/curso/empresa/ejercicios/**</exclude>
</excludes>
</configuration>
</plugin>
Abre target/site/jacoco/index.html en el navegador:
Figura 3. Informe de cobertura de JaCoCo por paquetes. Captura real.
Pulsando en un paquete y en una clase se ve el código coloreado: verde = ejecutado, rojo = nunca ejecutado, amarillo = rama ejecutada solo en parte (el rombo de la izquierda indica cuántas ramas de un if se recorrieron):
Figura 4. PedidoService en el informe: crear está probado; cambiarEstado, no. Captura real.
La figura 4 enseña algo útil: cambiarEstado está en rojo aunque la prueba de integración hace un PATCH /api/pedidos/5022/estado. ¿Por qué? Porque es Carmen quien lo intenta, la regla de seguridad la rechaza con 403 y el método nunca llega a ejecutarse. La cobertura no dice si tus pruebas son buenas, pero sí señala qué no has probado.
| Métrica | Significado |
|---|---|
| Instructions | Instrucciones de bytecode ejecutadas: la medida más fina |
| Branches | Ramas de if/switch/operador ?: recorridas |
| Lines / Methods / Classes | Ejecutadas al menos una vez |
| Cxty | Complejidad ciclomática: número de caminos independientes |
💡 Consejo: No persigas el 100 %: probar getters o configuración no aporta nada. Una cobertura del 70-80 % en servicios y controladores es un objetivo razonable. Con la regla
check(ejercicio 5) la build falla si la cobertura baja de un mínimo, y así nadie la empeora sin darse cuenta.
9. Empaquetar y configurar para producción
El JAR ejecutable
mvn package (o verify) genera target/empresa-app-0.0.1-SNAPSHOT.jar: un JAR «gordo» con la aplicación y todas sus dependencias dentro de BOOT-INF/lib, que se ejecuta con:
java -jar target/empresa-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
Es lo que hiciste en el módulo 18. Lo importante ahora es cómo se configura en un servidor, donde no vas a editar application.properties.
Configuración por variables de entorno
Spring Boot lee las variables de entorno y las traduce a propiedades con el relaxed binding: se pasa a mayúsculas, los puntos se convierten en _ y se quitan los guiones.
| Propiedad | Variable de entorno |
|---|---|
spring.profiles.active | SPRING_PROFILES_ACTIVE |
spring.datasource.url | SPRING_DATASOURCE_URL |
server.port | SERVER_PORT |
empresa.jwt.secreto | EMPRESA_JWT_SECRETO |
management.endpoint.health.show-details | MANAGEMENT_ENDPOINT_HEALTH_SHOWDETAILS |
Además, nuestro perfil prod (módulo 18) usa marcadores ${...} sin valor por defecto, de modo que la aplicación no arranca si falta alguno. Así ha quedado application-prod.properties tras los módulos 22 y 23:
# ==========================================================
# Perfil prod: todo por variables de entorno, sin valores por defecto
# ==========================================================
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USER}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.hikari.maximum-pool-size=20
# Clave de los JWT: obligatoria y fuera del codigo
empresa.jwt.secreto=${JWT_SECRET}
spring.jpa.show-sql=false
logging.level.root=info
logging.level.es.curso.empresa=info
logging.file.name=logs/empresa-app.log
# En produccion se exponen menos endpoints y sin detalles
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=never
# Sondas de Kubernetes/Docker: /actuator/health/liveness y /actuator/health/readiness
management.endpoint.health.probes.enabled=true
# Sin documentacion de la API en produccion
springdoc.api-docs.enabled=false
springdoc.swagger-ui.enabled=false
⚠️ Importante: Los secretos (contraseñas, la clave de los JWT) nunca van en el código ni en los ficheros de configuración que se suben a Git. En el servidor se inyectan como variables de entorno o mediante el gestor de secretos de la plataforma (Docker secrets, Kubernetes Secrets, Azure Key Vault…). Recuerda que
application.propertiesse lee en ISO-8859-1: en los comentarios de arriba hemos evitado las tildes por eso.
10. Imagen Docker con un Dockerfile multietapa
En el módulo 18 arrancaste MySQL con Docker. Ahora metemos la propia aplicación en una imagen: así se ejecuta igual en tu portátil, en el servidor de pruebas y en la nube, con el JRE exacto que necesita.
Figura 5. Las tres etapas del Dockerfile y las capas de la imagen final.
Dockerfile en la raíz del proyecto:
# syntax=docker/dockerfile:1
# =====================================================================
# empresa-app - imagen Docker en tres etapas
# 1) construccion: compila y empaqueta con Maven y el JDK 25
# 2) capas: separa el JAR en capas (dependencias / aplicación)
# 3) final: solo el JRE 25 y las capas, con un usuario sin privilegios
# =====================================================================
# ---------- 1. Construcción ----------
FROM maven:3.9-eclipse-temurin-25 AS construccion
WORKDIR /fuente
# Primero solo el pom.xml: si no cambia, Docker reutiliza la capa con las dependencias
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -q dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -q package -DskipTests
# ---------- 2. Extracción de capas ----------
FROM eclipse-temurin:25-jre AS capas
WORKDIR /capas
COPY --from=construccion /fuente/target/empresa-app-*.jar application.jar
RUN java -Djarmode=tools -jar application.jar extract --layers --destination extraido
# ---------- 3. Imagen final ----------
FROM eclipse-temurin:25-jre
LABEL org.opencontainers.image.title="empresa-app" \
org.opencontainers.image.description="Gestión de EMPRESA - Curso de Java Avanzado"
RUN useradd --system --uid 1001 --create-home empresa
WORKDIR /application
# Cada COPY es una capa: las dependencias (que casi nunca cambian) van primero
COPY --from=capas /capas/extraido/dependencies/ ./
COPY --from=capas /capas/extraido/spring-boot-loader/ ./
COPY --from=capas /capas/extraido/snapshot-dependencies/ ./
COPY --from=capas /capas/extraido/application/ ./
RUN mkdir logs fotos && chown empresa logs fotos
USER empresa
ENV SPRING_PROFILES_ACTIVE=prod
EXPOSE 8080
# MaxRAMPercentage: la JVM ajusta su memoria al límite del contenedor
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "application.jar"]
| Instrucción | Por qué |
|---|---|
Varias etapas FROM ... AS nombre | Solo la última forma la imagen: Maven, el JDK completo y el código fuente se quedan fuera |
COPY pom.xml antes que src | Docker guarda en caché cada paso; si solo cambias código, no vuelve a descargar las dependencias |
--mount=type=cache,target=/root/.m2 | Caché de Maven de BuildKit, compartida entre construcciones |
-Djarmode=tools ... extract --layers | Separa el JAR en carpetas por frecuencia de cambio. Es la forma documentada por Spring Boot (sustituye al antiguo layertools) |
java -jar application.jar | El JAR extraído solo contiene tu código y referencias a lib/: arranca más rápido y admite la caché AOT de Java 25 |
useradd + USER empresa | No ejecutar como root dentro del contenedor |
-XX:MaxRAMPercentage=75 | Usar el 75 % de la memoria que se asigne al contenedor |
Y un .dockerignore para no enviar a Docker lo que no hace falta (ni secretos):
# Lo que NO se envía a Docker al construir la imagen
target/
logs/
fotos/
.idea/
.vscode/
*.iml
.git/
.env
docker build
docker build -t empresa-app:1.0 .
Salida real (resumida). La descarga de dependencias fue lo más lento la primera vez; en las siguientes construcciones esa capa sale de la caché:
#14 [construccion 4/6] RUN --mount=type=cache,target=/root/.m2 mvn -q dependency:go-offline
#14 DONE 226.6s
#18 [construccion 5/6] COPY src ./src
#19 [construccion 6/6] RUN --mount=type=cache,target=/root/.m2 mvn -q package -DskipTests
#19 DONE 25.2s
#20 [capas 3/4] COPY --from=construccion /fuente/target/empresa-app-*.jar application.jar
#21 [capas 4/4] RUN java -Djarmode=tools -jar application.jar extract --layers --destination extraido
#21 DONE 3.5s
#22 [stage-2 4/8] COPY --from=capas /capas/extraido/dependencies/ ./
#23 [stage-2 5/8] COPY --from=capas /capas/extraido/spring-boot-loader/ ./
#24 [stage-2 6/8] COPY --from=capas /capas/extraido/snapshot-dependencies/ ./
#25 [stage-2 7/8] COPY --from=capas /capas/extraido/application/ ./
#26 [stage-2 8/8] RUN mkdir logs fotos && chown empresa logs fotos
#27 naming to docker.io/library/empresa-app:1.0 done
Las capas de la imagen (docker history empresa-app:1.0, las de arriba):
0B ENTRYPOINT ["java" "-XX:MaxRAMPercentage=75"…
0B USER empresa
0B RUN /bin/sh -c mkdir logs fotos && chown emp…
178kB COPY /capas/extraido/application/ ./
0B COPY /capas/extraido/snapshot-dependencies/ …
0B COPY /capas/extraido/spring-boot-loader/ ./
71.2MB COPY /capas/extraido/dependencies/ ./
... (debajo, las capas de eclipse-temurin:25-jre)
Cuando cambies una línea de Java, solo cambiará la capa de 178 kB: subir la nueva versión al registro de imágenes es casi instantáneo.
docker run
Ejecutamos la imagen contra el MySQL del módulo 18 (en la red curso-java), pasando la configuración por variables de entorno:
docker run -d --name empresa-app --network curso-java -p 8080:8080 \
-e DB_URL=jdbc:mysql://mysql-curso:3306/empresa -e DB_USER=curso -e DB_PASSWORD=curso \
-e JWT_SECRET=q3M9X2v7cK1pZr8wT5yB0nL4hF6jD2sA9eG7uV3iO1E= \
empresa-app:1.0
docker logs -f empresa-app
empresa-app 0.0.1-SNAPSHOT - Spring Boot 4.1.1
INFO 1 --- [empresa-app] [main] es.curso.empresa.EmpresaAppApplication : Starting EmpresaAppApplication v0.0.1-SNAPSHOT using Java 25.0.4.1 with PID 1 (/application/application.jar ...)
INFO 1 --- [empresa-app] [main] es.curso.empresa.EmpresaAppApplication : The following 1 profile is active: "prod"
INFO 1 --- [empresa-app] [main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
INFO 1 --- [empresa-app] [main] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.4.5.Final
Database version: 8.4.11
INFO 1 --- [empresa-app] [main] es.curso.empresa.EmpresaAppApplication : Started EmpresaAppApplication in 30.658 seconds (process running for 33.856)
Y comprobamos desde fuera (salida real):
$ curl -s http://localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}
$ curl -s http://localhost:8080/actuator/health/liveness
{"status":"UP"}
$ curl -s http://localhost:8080/actuator/health/readiness
{"status":"UP"}
$ curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/swagger-ui.html
404
$ docker exec empresa-app sh -c "whoami; java -version 2>&1 | head -1"
empresa
openjdk version "25.0.4.1" 2026-08-18 LTS
En producción, health ya no muestra detalles, Swagger UI está desactivada (404) y el proceso se ejecuta como el usuario empresa, no como root.
💡 Consejo: Para arrancar aún más rápido, Java 25 permite generar una caché AOT (
-XX:AOTCacheOutput=app.aoten un «entrenamiento» y-XX:AOTCache=app.aotal ejecutar), y la documentación de Spring Boot incluye un Dockerfile de ejemplo que la usa. El entrenamiento arranca el contexto de Spring, así que necesita acceso a la base de datos durante la construcción de la imagen; por eso no lo hemos incluido aquí.
11. Imagen sin Dockerfile: Cloud Native Buildpacks
El plugin de Maven de Spring Boot puede crear la imagen sin escribir un Dockerfile, usando Cloud Native Buildpacks (el proyecto Paketo): unas imágenes «constructoras» que detectan que tu proyecto es Java, eligen el JRE, separan las capas y configuran la memoria de la JVM.
mvn spring-boot:build-image -DskipTests -Dspring-boot.build-image.imageName=empresa-app:bp
Solo necesita Docker. Salida real (muy resumida; la primera vez descarga el constructor, de varios cientos de MB, y tardó nueve minutos):
[INFO] --- spring-boot:4.1.1:build-image (default-cli) @ empresa-app ---
[INFO] Building image 'docker.io/library/empresa-app:bp'
[INFO] > Pulled builder image 'paketobuildpacks/builder-noble-java-tiny@sha256:b95da27f...'
[INFO] > Pulled run image 'paketobuildpacks/ubuntu-noble-run-tiny@sha256:b1a26d91...'
[INFO] [creator] ===> BUILDING
[INFO] [creator] Paketo Buildpack for CA Certificates 3.13.0
[INFO] [creator] Paketo Buildpack for BellSoft Liberica 11.9.0
[INFO] [creator] Using Java version 25 extracted from MANIFEST.MF
[INFO] [creator] Paketo Buildpack for Syft 2.41.0
[INFO] [creator] Paketo Buildpack for Executable JAR 6.16.0
[INFO] [creator] Paketo Buildpack for Spring Boot 5.37.0
[INFO] Successfully built image 'docker.io/library/empresa-app:bp'
[INFO] BUILD SUCCESS
El constructor por defecto de Spring Boot 4.1 es builder-noble-java-tiny (Ubuntu 24.04 mínimo) con el JRE BellSoft Liberica 25, la versión que lee del MANIFEST.MF (la de java.version del pom.xml). Al arrancar, la imagen calcula la memoria de la JVM:
Calculating JVM memory based on 36852460K available memory
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -Xmx36208754K -XX:MaxMetaspaceSize=131705K -XX:ReservedCodeCacheSize=240M -Xss1M (Total Memory: 36852460K, Thread Count: 250, Loaded Class Count: 20839, Headroom: 0%)
Adding 121 container CA certificates to JVM truststore
El primer docker run con esta imagen falló, y el motivo es instructivo:
ERROR in ch.qos.logback.core.rolling.RollingFileAppender[FILE] - Failed to create parent directories for [/workspace/logs/empresa-app.log]
...
Caused by: java.nio.file.AccessDeniedException: /workspace/fotos
La imagen de Buildpacks ejecuta la aplicación con el usuario sin privilegios cnb y la carpeta de la aplicación (/workspace) es de solo lectura para él. Nuestro perfil prod escribe el log en logs/ y FotoService (módulo 21) crea la carpeta fotos/, ambas relativas a esa carpeta. En nuestro Dockerfile lo resolvimos creándolas con chown; aquí se resuelve por configuración, apuntando a una carpeta escribible (o mejor, a un volumen):
docker run -d --name empresa-app-bp --network curso-java -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e DB_URL=jdbc:mysql://mysql-curso:3306/empresa -e DB_USER=curso -e DB_PASSWORD=curso \
-e JWT_SECRET=q3M9X2v7cK1pZr8wT5yB0nL4hF6jD2sA9eG7uV3iO1E= \
-e LOGGING_FILE_NAME=/tmp/empresa-app.log -e EMPRESA_DIRECTORIOFOTOS=/tmp/fotos \
empresa-app:bp
INFO 1 --- [empresa-app] [main] es.curso.empresa.EmpresaAppApplication : Started EmpresaAppApplication in 33.829 seconds (process running for 36.489)
$ curl -s http://localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}
⚠️ Importante: Si trabajas con Git Bash en Windows, añade
export MSYS_NO_PATHCONV=1antes de estas órdenes. Si no, Git Bash «traduce»/tmp/fotosa una ruta de Windows (C:/Program Files/Git/tmp/fotos) antes de pasársela a Docker, y la aplicación vuelve a fallar conAccessDeniedException: /workspace/C:. Nos pasó al preparar este módulo.
| Dockerfile propio | Buildpacks | |
|---|---|---|
| Control | Total: imagen base, usuario, carpetas, opciones de la JVM | Convenciones de Paketo; se ajusta con variables BP_* y BPL_* |
| Mantenimiento | Tú actualizas la imagen base | Reconstruir aplica los parches del constructor |
| Imagen | eclipse-temurin:25-jre (Ubuntu, con shell) — 416 MB | ubuntu-noble-run-tiny (sin shell: docker exec ... sh falla) — 392 MB |
| Extras | Lo que añadas | Cálculo de memoria, certificados, SBOM (lista de componentes, con Syft) |
| Requisitos | Docker | Docker y una primera descarga grande |
12. Docker Compose: la aplicación y MySQL juntos
Con docker run hay que arrancar cada contenedor a mano, en orden y con muchas opciones. Docker Compose describe toda la aplicación (servicios, redes, volúmenes, variables) en un fichero YAML y la levanta con una orden.
Figura 6. Los servicios de
docker-compose.yml.
docker-compose.yml:
# =====================================================================
# empresa-app + MySQL 8.4 con Docker Compose
# Uso: docker compose up -d --build (y docker compose down para parar)
# Los secretos se leen del fichero .env (que NO se sube a Git)
# =====================================================================
services:
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: empresa
MYSQL_USER: curso
MYSQL_PASSWORD: ${DB_PASSWORD:?Define DB_PASSWORD en .env}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?Define DB_ROOT_PASSWORD en .env}
TZ: Europe/Madrid
volumes:
- datos-mysql:/var/lib/mysql # los datos sobreviven a docker compose down
- ./docker/mysql:/docker-entrypoint-initdb.d:ro # scripts de EMPRESA (solo la primera vez)
healthcheck:
# Por TCP (127.0.0.1): durante la carga inicial MySQL arranca un servidor temporal
# que solo escucha por socket y "localhost" lo daría por sano antes de tiempo
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 5s
timeout: 5s
retries: 30
restart: unless-stopped
app:
build: .
image: empresa-app:1.0
depends_on:
db:
condition: service_healthy # espera a que MySQL acepte conexiones
environment:
SPRING_PROFILES_ACTIVE: prod
DB_URL: jdbc:mysql://db:3306/empresa # "db" es el nombre del servicio
DB_USER: curso
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET:?Define JWT_SECRET en .env}
TZ: Europe/Madrid
ports:
- "${APP_PORT:-8080}:8080"
restart: unless-stopped
volumes:
datos-mysql:
Y el fichero .env, junto al anterior, que Compose lee automáticamente y que añadimos a .gitignore:
# Variables para docker compose (este fichero NO se sube a Git: está en .gitignore)
DB_PASSWORD=curso
DB_ROOT_PASSWORD=root2026
JWT_SECRET=q3M9X2v7cK1pZr8wT5yB0nL4hF6jD2sA9eG7uV3iO1E=
| Elemento | Explicación |
|---|---|
services | Cada servicio es un contenedor (o varios, si lo escalas) |
| Nombre del servicio como host | Compose crea una red en la que app llega a MySQL por el nombre db |
build: . + image: | Construye la imagen con nuestro Dockerfile y le pone ese nombre |
${VAR:?mensaje} | Si falta la variable, Compose se detiene con ese mensaje |
$$MYSQL_ROOT_PASSWORD | $$ escapa el $ para que la variable la expanda el contenedor, no Compose |
depends_on ... condition: service_healthy | No arranca app hasta que el healthcheck de db diga que está sana |
volumes con nombre | Los datos de MySQL persisten entre reinicios |
./docker/mysql:/docker-entrypoint-initdb.d:ro | Los tres scripts (EMPRESA y seguridad) se ejecutan solo cuando el volumen está vacío |
MySQL sin ports | La base de datos no es accesible desde fuera: solo la aplicación la ve |
docker compose up
docker compose up -d --build
Salida real, abreviada (tras construir la imagen):
Image empresa-app:1.0 Built
Network empresa-app_default Created
Volume empresa-app_datos-mysql Created
Container empresa-app-db-1 Created
Container empresa-app-app-1 Created
Container empresa-app-db-1 Started
Container empresa-app-db-1 Waiting
Container empresa-app-db-1 Healthy
Container empresa-app-app-1 Starting
Container empresa-app-app-1 Started
docker compose ps
NAME IMAGE COMMAND SERVICE STATUS PORTS
empresa-app-app-1 empresa-app:1.0 "java -XX:MaxRAMPerc…" app Up About a minute 0.0.0.0:8080->8080/tcp
empresa-app-db-1 mysql:8.4 "docker-entrypoint.s…" db Up About a minute (healthy) 3306/tcp, 33060/tcp
Y los logs de los dos servicios (docker compose logs), donde se ve la carga de EMPRESA en el primer arranque:
db-1 | [Note] [Entrypoint]: Starting temporary server
db-1 | [Note] [Entrypoint]: Temporary server started.
db-1 | [Note] [Entrypoint]: Creating database empresa
db-1 | [Note] [Entrypoint]: Creating user curso
db-1 | [Note] [Entrypoint]: /usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/01-empresa-crear.sql
db-1 | [Note] [Entrypoint]: /usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/02-empresa-datos.sql
db-1 | [Note] [Entrypoint]: /usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/03-seguridad.sql
db-1 | [Note] [Entrypoint]: Stopping temporary server
db-1 | [Note] [Entrypoint]: MySQL init process done. Ready for start up.
db-1 | [Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.11' socket: '/var/run/mysqld/mysqld.sock' port: 3306
app-1 | INFO 1 --- [empresa-app] [main] es.curso.empresa.EmpresaAppApplication : The following 1 profile is active: "prod"
app-1 | Database version: 8.4.11
app-1 | INFO 1 --- [empresa-app] [main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
app-1 | INFO 1 --- [empresa-app] [main] es.curso.empresa.EmpresaAppApplication : Started EmpresaAppApplication in 45.406 seconds (process running for 48.54)
⚠️ Importante: La primera versión del healthcheck usaba
mysqladmin ping -h localhost, y en los logs vimos queappempezó a arrancar a las 17:24:32, cuando MySQL aún estaba ejecutando los scripts con su servidor temporal (que terminó a las 17:24:36). Ese servidor temporal solo escucha por socket local, y-h localhostusa precisamente el socket: el healthcheck daba la base de datos por sana antes de tiempo. La aplicación se salvó porque tardó en llegar a conectarse, pero en un equipo más rápido habría fallado. Con-h 127.0.0.1el ping va por TCP, que el servidor temporal no atiende (port: 0). En el despliegue del proyecto final (módulo 24) comprobamos en los logs que, con este cambio, la aplicación ya arranca después de que MySQL termine la carga.
La aplicación funcionando dentro de los contenedores, con Ana conectada:
Figura 7. empresa-app ejecutándose con Docker Compose y MySQL 8.4. Captura real.
Figura 8. /actuator/health en producción: sin detalles y con los grupos de sondas. Captura real.
Las órdenes de cada día
| Orden | Qué hace |
|---|---|
docker compose up -d --build | Construye (si hace falta) y arranca todo en segundo plano |
docker compose ps | Estado de los servicios |
docker compose logs -f app | Sigue los logs de un servicio |
docker compose exec db mysql -ucurso -p empresa | Abre una consola en un contenedor en marcha |
docker compose restart app | Reinicia un servicio |
docker compose down | Para y borra los contenedores y la red (conserva el volumen) |
docker compose down -v | Lo mismo y borra los volúmenes: la próxima vez se recarga EMPRESA |
💡 Consejo: Spring Boot tiene además el módulo
spring-boot-docker-compose: añadido como dependencia de desarrollo, al ejecutar la aplicación desde el IDE arranca solo los servicios de uncompose.yaml(por ejemplo, MySQL) y configura la conexión con ellos, de forma parecida a@ServiceConnection.
13. Actuator y logs en producción
Sondas liveness y readiness
Las plataformas de contenedores (Kubernetes, Azure Container Apps, AWS ECS…) preguntan periódicamente a la aplicación cómo está:
| Sonda | URL | Pregunta | Si falla… |
|---|---|---|---|
| Liveness | /actuator/health/liveness | ¿El proceso está vivo o se ha quedado bloqueado? | La plataforma reinicia el contenedor |
| Readiness | /actuator/health/readiness | ¿Puede atender peticiones ya? | La plataforma deja de enviarle tráfico (sin reiniciar) |
Spring Boot las activa solo en Kubernetes; con management.endpoint.health.probes.enabled=true (en nuestro perfil prod) están disponibles siempre, como has visto en las salidas de curl. Durante el arranque, readiness vale OUT_OF_SERVICE hasta que la aplicación está lista, y durante el apagado ordenado (graceful shutdown, activo por defecto en Spring Boot) vuelve a OUT_OF_SERVICE mientras termina las peticiones en curso.
💡 Consejo: Para incluir la base de datos en la readiness (no aceptar tráfico si MySQL ha caído) se usa
management.endpoint.health.group.readiness.include=readinessState,db. No la incluyas en la liveness: si MySQL cae, reiniciar la aplicación no lo arregla y solo provoca reinicios en cadena.
Logs
En un contenedor, la convención (twelve-factor app) es escribir los logs en la salida estándar y dejar que la plataforma los recoja: docker logs, docker compose logs, o el sistema de logs de la nube. Nuestro perfil prod también los escribe en logs/empresa-app.log (módulo 18); por eso el Dockerfile crea la carpeta logs con permisos para el usuario empresa, que si no, no podría crearla. En una plataforma real, lo normal es quitar logging.file.name y quedarse solo con la consola, o activar el formato estructurado en JSON que Spring Boot incluye:
# Logs en JSON (formato Elastic Common Schema), fáciles de indexar y buscar
logging.structured.format.console=ecs
14. Integración continua con GitHub Actions
La integración continua (CI) compila y ejecuta las pruebas automáticamente en cada push, así nadie puede romper la rama principal sin enterarse. Con GitHub Actions basta con un fichero en .github/workflows/:
# Integración continua de empresa-app: compila y prueba en cada push y pull request
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest # trae Docker: Testcontainers funciona sin configurar nada
steps:
- name: Descargar el código
uses: actions/checkout@v5
- name: Instalar el JDK 25 (Temurin) con caché de Maven
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Compilar, probar y medir la cobertura
run: ./mvnw -B verify
- name: Publicar el informe de JaCoCo
if: always()
uses: actions/upload-artifact@v4
with:
name: cobertura
path: target/site/jacoco/
- name: Construir la imagen Docker
run: docker build -t empresa-app:${{ github.sha }} .
| Parte | Explicación |
|---|---|
on | Cuándo se ejecuta: cada push y cada pull request a main |
runs-on: ubuntu-latest | Una máquina virtual de GitHub, nueva en cada ejecución, con Docker instalado |
actions/setup-java con cache: maven | Instala el JDK y guarda ~/.m2 entre ejecuciones |
./mvnw -B verify | El Maven Wrapper (módulo 18) en modo no interactivo: el mismo ciclo que en tu equipo |
upload-artifact | Guarda el informe de cobertura para descargarlo desde la página de la ejecución |
⚠️ Importante: Este workflow se muestra como modelo y no se ha ejecutado en GitHub durante la preparación del módulo (sí todo lo que hace:
mvn verifyydocker build). Antes de usarlo, comprueba en GitHub Marketplace las versiones vigentes de las acciones (checkout,setup-java,upload-artifact). El siguiente paso, la entrega continua (CD), publicaría la imagen en un registro (GitHub Container Registry, Docker Hub) y la desplegaría.
15. Opciones de despliegue en la nube
Una vez tienes una imagen Docker, puedes ejecutarla casi en cualquier parte:
| Opción | Ejemplos | Comentario |
|---|---|---|
| Contenedores gestionados («serverless») | Google Cloud Run, Azure Container Apps, AWS App Runner | Subes la imagen y la plataforma la escala; pagas por uso |
| Plataformas para desarrolladores (PaaS) | Render, Railway, Fly.io, Heroku | Muy sencillas; algunas construyen la imagen desde tu repositorio con Buildpacks |
| Orquestadores | Kubernetes (EKS, AKS, GKE), AWS ECS | Para muchos servicios; aquí brillan las sondas liveness/readiness |
| Máquina virtual propia | Un VPS con Docker Compose | Barato y didáctico; tú te ocupas de actualizaciones, copias y HTTPS |
| Base de datos gestionada | Amazon RDS, Azure Database for MySQL, Cloud SQL | Mejor que un MySQL en contenedor para producción: copias y alta disponibilidad incluidas |
En todas ellas los pasos son los mismos que has hecho en este módulo: construir la imagen, configurar las variables de entorno (con los secretos en el gestor de la plataforma), apuntar a la base de datos y comprobar el health.
16. Ejercicios
Ejercicio 1: Reglas de borrado de empleados
Escribe pruebas unitarias con Mockito para EmpleadoService: no se puede borrar a un jefe, ni a quien tiene pedidos; sí a quien no tiene dependencias; y el alta con un email repetido falla sin guardar nada.
Ver solución
/** Ejercicio 1 (módulo 23): reglas de negocio de EmpleadoService con Mockito. */
@ExtendWith(MockitoExtension.class)
class EmpleadoServiceTest {
@Mock EmpleadoRepository empleados;
@Mock DepartamentoRepository departamentos;
@Mock ClienteRepository clientes;
@Mock PedidoRepository pedidos;
@InjectMocks EmpleadoService servicio;
private final Empleado luis = new Empleado("Luis", "Martínez Ruiz", "luis.martinez@empresa.es",
"DIRECTOR", new BigDecimal("4500"), LocalDate.of(2012, 3, 1));
@Test
void noSeBorraAUnJefe() {
when(empleados.findConRelacionesById(101)).thenReturn(Optional.of(luis));
when(empleados.countByJefeId(101)).thenReturn(5L);
assertThatThrownBy(() -> servicio.borrar(101))
.isInstanceOf(ReglaNegocioException.class)
.hasMessage("Luis Martínez Ruiz es jefe de otros empleados");
verify(empleados, never()).delete(any(Empleado.class)); // any() a secas es ambiguo: hay dos delete
}
@Test
void noSeBorraAQuienTienePedidos() {
when(empleados.findConRelacionesById(101)).thenReturn(Optional.of(luis));
when(pedidos.countByEmpleadoId(101)).thenReturn(2L);
assertThatThrownBy(() -> servicio.borrar(101)).hasMessageContaining("clientes o pedidos");
}
@Test
void seBorraSiNoTieneDependencias() {
when(empleados.findConRelacionesById(101)).thenReturn(Optional.of(luis));
servicio.borrar(101);
verify(empleados).delete(luis);
}
@Test
void altaConEmailRepetido() {
EmpleadoForm form = new EmpleadoForm();
form.setEmail("ana.garcia@empresa.es");
when(empleados.existsByEmail("ana.garcia@empresa.es")).thenReturn(true);
assertThatThrownBy(() -> servicio.guardar(form))
.isInstanceOf(ReglaNegocioException.class)
.hasMessage("Ya existe un empleado con el email ana.garcia@empresa.es");
verify(empleados, never()).save(any());
}
}
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.578 s -- in es.curso.empresa.servicio.EmpleadoServiceTest
La primera versión escribía verify(empleados, never()).delete(any()) y no compilaba: reference to delete is ambiguous — both method delete(DeleteSpecification<T>) in JpaSpecificationExecutor and method delete(T) in CrudRepository match. EmpleadoRepository hereda de JpaSpecificationExecutor (módulo 19), que en Spring Data 2026 tiene un delete(DeleteSpecification), y any() encaja con los dos. any(Empleado.class) deshace la ambigüedad. En noSeBorraAQuienTienePedidos no hace falta programar countByJefeId: un mock devuelve 0 por defecto. Y en altaConEmailRepetido, el @PreAuthorize de guardar no interviene porque no hay Spring: la seguridad por método se prueba en los slices o en integración.
Ejercicio 2: Prueba parametrizada de los importes
Comprueba con una sola prueba parametrizada (@CsvSource) el cálculo del importe de una línea de pedido en seis casos, incluido un redondeo y un descuento del 100 %.
Ver solución
/** Ejercicio 2 (módulo 23): prueba parametrizada del cálculo de importes. */
class ImporteLineaTest {
@ParameterizedTest(name = "{0} x {1} € con {2} % = {3} €")
@CsvSource({
"1, 1499.00, 0, 1499.00",
"2, 19.90, 0, 39.80",
"2, 139.00, 5, 264.10",
"3, 139.00, 5, 396.15",
"7, 0.99, 10, 6.24", // 6,237 se redondea a 6,24 (HALF_UP)
"1, 50.00, 100, 0.00"
})
void importeDeUnaLinea(int cantidad, BigDecimal precio, BigDecimal descuento, BigDecimal esperado) {
Producto p = new Producto("Prueba", null, new Categoria("Pruebas", null), precio, 100);
Pedido pedido = new Pedido(new Cliente("Cliente", "B00000000"), null);
LineaPedido linea = pedido.anadirLinea(p, cantidad, descuento);
assertThat(linea.getImporte()).isEqualByComparingTo(esperado);
assertThat(pedido.getTotal()).isEqualByComparingTo(esperado);
}
}
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.936 s -- in es.curso.empresa.modelo.ImporteLineaTest
JUnit convierte cada columna del CSV al tipo del parámetro (int, BigDecimal) y ejecuta la prueba seis veces; el atributo name hace que el IDE muestre cada caso como «3 x 139.00 € con 5 % = 396.15 €». Se usa isEqualByComparingTo porque BigDecimal.equals distingue la escala: new BigDecimal("0.00").equals(BigDecimal.ZERO) es false. La prueba está en el paquete es.curso.empresa.modelo porque el constructor de LineaPedido es de paquete; usamos Pedido.anadirLinea, que es como lo hace la aplicación.
Ejercicio 3: Datos de prueba con @Sql y TestEntityManager
Escribe un @DataJpaTest de ClienteRepository que: (a) prepare datos con @Sql y busque clientes por provincia; (b) compruebe que esos cambios se han deshecho al terminar; (c) inserte un cliente con TestEntityManager y lo busque por NIF.
Ver solución
/** Ejercicio 3 (módulo 23): @Sql para preparar datos y TestEntityManager para insertar. */
@DataJpaTest(properties =
"spring.datasource.url=jdbc:h2:mem:clientes;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH")
@ActiveProfiles("h2")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ClienteRepositoryTest {
@Autowired ClienteRepository clientes;
@Autowired TestEntityManager em;
@Test
@Sql(statements = "UPDATE clientes SET provincia = 'Teruel' WHERE cliente_id IN (1004, 1007)")
void clientesDeUnaProvinciaOrdenados() {
assertThat(clientes.findByProvinciaOrderByNombre("Teruel"))
.extracting(Cliente::getNombre)
.containsExactly("Academia Nova", "Hotel Mar Azul S.L.");
}
@Test
void elUpdateDeLaOtraPruebaSeHaDeshecho() {
assertThat(clientes.countByProvincia("Teruel")).isZero(); // rollback al terminar cada prueba
}
@Test
void buscarPorNifUnClienteNuevo() {
Cliente c = new Cliente("Librería Cervantes", "B99887766");
c.setCiudad("Alcalá de Henares");
em.persistAndFlush(c); // INSERT real en H2
assertThat(clientes.findByNif("B99887766")).get()
.extracting(Cliente::getCiudad).isEqualTo("Alcalá de Henares");
assertThat(clientes.existsByNif("B00000000")).isFalse();
}
}
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.66 s -- in es.curso.empresa.repositorio.ClienteRepositoryTest
@Sql ejecuta sentencias o ficheros SQL antes de la prueba dentro de su misma transacción, así que también se deshacen. La segunda prueba lo demuestra (JUnit no garantiza el orden, pero pase lo que pase antes, ninguna prueba deja datos). persistAndFlush obliga a Hibernate a ejecutar el INSERT en ese momento; sin flush, la consulta posterior funcionaría igualmente porque Spring Data hace flush antes de consultar, pero no comprobaríamos las restricciones de la tabla (NIF único, columnas obligatorias) en el punto exacto. Cada clase usa su propia base de datos en memoria (jdbc:h2:mem:clientes), como explicamos en la sección 4.
Ejercicio 4: Slice de la API de pedidos
Prueba PedidoApiController con @WebMvcTest: el detalle de un pedido devuelve el importe de sus líneas, y un pedido sin líneas devuelve 400 con el mensaje de validación, sin llegar a llamar al servicio.
Ver solución
/** Ejercicio 4 (módulo 23): slice de la API de pedidos. */
@WebMvcTest(PedidoApiController.class)
@Import(SeguridadTestConfig.class)
class PedidoApiControllerTest {
@Autowired MockMvcTester mvc;
@MockitoBean PedidoService servicio;
@MockitoBean UserDetailsService usuarios;
@Test
void detalleDeUnPedidoConSuTotal() {
Pedido p = new Pedido(new Cliente("Talleres Martín S.L.", "B11111111"), null);
Producto monitor = new Producto("Monitor 24 FHD", null, new Categoria("Monitores", null),
new BigDecimal("139.00"), 35);
p.anadirLinea(monitor, 2, new BigDecimal("5"));
when(servicio.buscar(5001)).thenReturn(p);
assertThat(mvc.get().uri("/api/pedidos/5001")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_EMPLEADO"))))
.hasStatusOk()
.bodyJson()
.hasPathSatisfying("$.lineas[0].importe", v -> v.assertThat().asNumber().isEqualTo(264.10))
.extractingPath("$.cliente").isEqualTo("Talleres Martín S.L.");
}
@Test
void pedidoSinLineasDevuelve400() {
assertThat(mvc.post().uri("/api/pedidos")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN")))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"clienteId\": 1004, \"lineas\": []}"))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson().extractingPath("$.errores.lineas").isEqualTo("El pedido debe tener al menos una línea");
verify(servicio, never()).crear(any());
}
}
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.47 s -- in es.curso.empresa.web.api.PedidoApiControllerTest
El JSON se genera con el mismo Jackson 3 de la aplicación, así que la prueba comprueba también el DTO (PedidoDto.desde). El 400 lo produce @Valid (@NotEmpty de NuevoPedidoRequest) y lo da formato nuestro ApiExceptionHandler, que está incluido en el slice por ser un @RestControllerAdvice. verify(servicio, never()).crear(any()) demuestra que la validación corta la petición antes de llegar al servicio.
Ejercicio 5: Cobertura mínima obligatoria
Configura JaCoCo para que mvn verify falle si la cobertura de instrucciones baja de un mínimo, definido como propiedad del pom.xml. Compruébalo con un mínimo del 60 % y deja el valor real del proyecto.
Ver solución
En <properties>:
<!-- Cobertura mínima exigida por jacoco:check (ejercicio 5 del módulo 23) -->
<cobertura.minima>0.35</cobertura.minima>
Y una tercera ejecución del plugin de JaCoCo:
<!-- Ejercicio 5 (módulo 23): la build falla si la cobertura baja del mínimo -->
<execution>
<id>cobertura-minima</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>${cobertura.minima}</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
Tras un mvn verify, el objetivo se puede volver a lanzar solo, cambiando la propiedad desde la línea de órdenes (salida real):
mvn jacoco:check@cobertura-minima -Dcobertura.minima=0.60
[WARNING] Rule violated for bundle empresa-app: instructions covered ratio is 0.47, but expected minimum is 0.60
[INFO] BUILD FAILURE
[ERROR] Failed to execute goal org.jacoco:jacoco-maven-plugin:0.8.15:check (cobertura-minima) on project empresa-app: Coverage checks have not been met. See log for details. -> [Help 1]
Con el valor del pom.xml (0.35), la salida de la sección 8 muestra All coverage checks have been met.. El sufijo @cobertura-minima elige la ejecución por su id (y por tanto su configuración). Lo sensato es fijar el mínimo un poco por debajo del valor actual (47 %) e ir subiéndolo: así la cobertura solo puede mejorar. Las reglas también pueden aplicarse por paquete o clase (<element>CLASS</element>) y a otros contadores (BRANCH, LINE).
Ejercicio 6: Integración con el perfil de producción y MySQL
Escribe una prueba de integración EmpresaAppMySqlIT que arranque la aplicación completa con el perfil prod contra un MySQL de Testcontainers declarado como bean, inicie sesión como Ana y consulte los pedidos pendientes. Configura Failsafe para que se ejecute en mvn verify.
Ver solución
El contenedor como bean de una configuración de prueba reutilizable (es el estilo que recomienda Spring Boot, y el mismo que usaría mvn spring-boot:test-run para arrancar la aplicación en desarrollo con un MySQL desechable):
/**
* Ejercicio 6 (módulo 23): el contenedor de MySQL como bean. Spring lo arranca al crear el contexto,
* lo para al cerrarlo y, gracias a @ServiceConnection, configura el DataSource para usarlo.
*/
@TestConfiguration(proxyBeanMethods = false)
public class MySqlTestcontainersConfig {
@Bean
@ServiceConnection
MySQLContainer mysql() {
return new MySQLContainer("mysql:8.4")
.withDatabaseName("empresa")
.withCopyFileToContainer(MountableFile.forClasspathResource("db/mysql/01-empresa-crear.sql"),
"/docker-entrypoint-initdb.d/01-empresa-crear.sql")
.withCopyFileToContainer(MountableFile.forClasspathResource("db/mysql/02-empresa-datos.sql"),
"/docker-entrypoint-initdb.d/02-empresa-datos.sql")
.withCopyFileToContainer(MountableFile.forClasspathResource("db/mysql/03-seguridad.sql"),
"/docker-entrypoint-initdb.d/03-seguridad.sql");
}
}
La prueba:
/** Ejercicio 6 (módulo 23): la aplicación completa contra un MySQL real de Testcontainers. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"spring.profiles.active=prod", // perfil de producción, ¡como en el servidor!
"DB_URL=jdbc:mysql://la-sustituye-testcontainers", "DB_USER=x", "DB_PASSWORD=x",
"JWT_SECRET=q3M9X2v7cK1pZr8wT5yB0nL4hF6jD2sA9eG7uV3iO1E="})
@Import(MySqlTestcontainersConfig.class)
@AutoConfigureRestTestClient
class EmpresaAppMySqlIT {
@Autowired RestTestClient http;
@Test
void anaConsultaLosPedidosPendientesEnMySql() {
String respuesta = http.post().uri("/api/auth/login").contentType(MediaType.APPLICATION_JSON)
.body("{\"username\": \"ana\", \"password\": \"ana2026\"}")
.exchange().expectStatus().isOk()
.expectBody(String.class).returnResult().getResponseBody();
String token = respuesta.replaceAll(".*\"token\":\"([^\"]+)\".*", "$1");
http.get().uri("/api/pedidos?estado=PENDIENTE")
.headers(h -> h.setBearerAuth(token))
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.page.totalElements").isEqualTo(3)
.jsonPath("$.content[0].id").isEqualTo(5024);
}
}
Y el plugin Failsafe en el pom.xml (su versión la gestiona Spring Boot):
<!-- Pruebas de integración *IT con Failsafe (ejercicio 6 del módulo 23) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
Salida real:
[INFO] --- failsafe:3.5.6:integration-test (default) @ empresa-app ---
[INFO] Running es.curso.empresa.EmpresaAppMySqlIT
INFO 356 --- [empresa-app] [main] es.curso.empresa.EmpresaAppMySqlIT : The following 1 profile is active: "prod"
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 160.3 s -- in es.curso.empresa.EmpresaAppMySqlIT
Es la prueba más parecida a producción: el perfil prod con su configuración de Actuator, logs y Swagger desactivado, y MySQL 8.4 de verdad. Las propiedades DB_URL, DB_USER, DB_PASSWORD y JWT_SECRET simulan las variables de entorno que exige el perfil; la URL es falsa a propósito porque @ServiceConnection tiene prioridad sobre spring.datasource.*. Como el nombre acaba en IT, Surefire la ignora y la ejecuta Failsafe tras empaquetar.
Ejercicio 7 (propuesto): Probar el formulario de empleados
Con @WebMvcTest(EmpleadoController.class), envía el formulario de alta (POST /empleados/guardar con .param(...)) con datos incorrectos y comprueba que se vuelve a mostrar empleados/formulario con errores en los campos (.model().attributeHasFieldErrors(...) o el HTML con is-invalid), y con datos correctos que redirige al detalle con el mensaje flash.
Ejercicio 8 (propuesto): Una prueba de extremo a extremo
Con la aplicación levantada con Docker Compose, escribe una prueba con Playwright para Java (com.microsoft.playwright) que abra el navegador, inicie sesión como Ana, cree un departamento y compruebe que aparece en el listado. Ejecútala como *IT y solo cuando se active un perfil de Maven (-Pe2e).
Ejercicio 9 (propuesto): Healthcheck de la aplicación en Compose
La imagen eclipse-temurin:25-jre no trae curl. Añade al servicio app un healthcheck que consulte /actuator/health/readiness (por ejemplo, instalando curl en la imagen o con un pequeño programa Java de comprobación) y comprueba con docker compose ps que aparece (healthy).
Ejercicio 10 (propuesto): Publicar la imagen desde GitHub Actions
Amplía el workflow para que, en cada push a main con las pruebas en verde, inicie sesión en GitHub Container Registry (ghcr.io) con docker/login-action y publique la imagen con las etiquetas latest y el SHA del commit.
Ejercicio 11 (propuesto): Caché AOT de Java 25
Siguiendo el Dockerfile de ejemplo de la documentación de Spring Boot, añade un paso de «entrenamiento» con -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh y arranca con -XX:AOTCache=app.aot. Mide el tiempo de arranque antes y después. Pista: el entrenamiento necesita una base de datos; prueba con el perfil h2.
Ejercicio 12 (propuesto): HTTPS con un proxy inverso
Añade a docker-compose.yml un servicio Caddy o Nginx que publique la aplicación en el puerto 443 con un certificado (autofirmado en local) y redirija el 80 al 443. Quita el puerto publicado de app y configura server.forward-headers-strategy=framework para que la aplicación genere bien las URL (Location, redirecciones al login).
Resumen
| Concepto | Sintaxis / ejemplo |
|---|---|
| Pirámide | Muchas unitarias, bastantes slices, pocas de integración |
| Dependencias | spring-boot-starter-<módulo>-test, spring-boot-resttestclient, spring-boot-testcontainers, testcontainers-mysql |
| Unitarias | @ExtendWith(MockitoExtension.class), @Mock, @InjectMocks, when, verify, assertThatThrownBy |
| JPA | @DataJpaTest (+ @AutoConfigureTestDatabase(replace = NONE)), TestEntityManager, @Sql, rollback automático |
| Testcontainers | @Testcontainers, @Container static MySQLContainer, @ServiceConnection |
| Web | @WebMvcTest(Controlador.class), MockMvcTester, @MockitoBean (sustituye a @MockBean, eliminado en Boot 4) |
| Seguridad | @WithMockUser, .with(user(...)), .with(jwt()), .with(csrf()); @Import de la configuración real |
| Integración | @SpringBootTest(webEnvironment = RANDOM_PORT) + @AutoConfigureRestTestClient + RestTestClient |
| Ciclo | mvn verify: Surefire (*Test) → JAR → Failsafe (*IT) → JaCoCo report y check |
| Configuración | Perfil prod + variables de entorno (SPRING_PROFILES_ACTIVE, DB_URL, JWT_SECRET) |
| Docker | Dockerfile multietapa, java -Djarmode=tools -jar app.jar extract --layers, usuario sin privilegios |
| Buildpacks | mvn spring-boot:build-image (sin Dockerfile) |
| Compose | docker compose up -d --build, depends_on: condition: service_healthy, .env, down -v |
| Producción | /actuator/health/liveness y /readiness, logs por la salida estándar, Swagger desactivado |
| CI | .github/workflows/ci.yml con setup-java y ./mvnw -B verify |