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-test y 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 @DataJpaTest sobre H2 y sobre un MySQL real con Testcontainers y @ServiceConnection
  • Probar la API y las vistas Thymeleaf con @WebMvcTest, MockMvcTester y @MockitoBean
  • Probar la seguridad con spring-security-test: @WithMockUser, user(), jwt() y csrf()
  • Arrancar la aplicación completa con @SpringBootTest en un puerto aleatorio y hacerle peticiones con RestTestClient
  • 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.

Pirámide de pruebas: en la base, muchas pruebas unitarias con JUnit, Mockito y AssertJ; encima, los slices de Spring con WebMvcTest y DataJpaTest; después la integración con SpringBootTest y Testcontainers; y en la cima pocas pruebas de sistema en el navegador 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.

Tabla de qué carga cada prueba: la unitaria solo la clase con mocks; WebMvcTest la capa web y la seguridad con los servicios sustituidos por MockitoBean; DataJpaTest las entidades, los repositorios y la base de datos con rollback; SpringBootTest la aplicación completa con Tomcat en un puerto aleatorio 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 import no 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 @Transactional de crear? 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 properties y falló con Table "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 memoria jdbc:h2:mem:empresa, con sus tablas. Al crear el contexto de @DataJpaTest con 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: @MockBean y @SpyBean estaban obsoletas desde Spring Boot 3.4 y ya no existen en Spring Boot 4: el JAR spring-boot-test 4.1.1 no contiene ninguna clase MockBean. Usa @MockitoBean y @MockitoSpyBean (paquete org.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 @WithMockUser crea un principal de tipo User de Spring, y nuestro layout muestra principal.nombreCompleto, que solo existe en UsuarioAutenticado. Lo comprobamos con una prueba con @WithMockUser y 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 cualquier UserDetails, así que le pasamos uno de los nuestros. Para las peticiones que redirigen sin pintar plantillas, @WithMockUser es 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 nuestro JwtAuthenticationConverter: si quieres probar que el claim roles se 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_PORT la prueba y el servidor se ejecutan en hilos distintos, así que no hay rollback automático aunque pongas @Transactional en 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: TestRestTemplate sigue existiendo en el mismo módulo spring-boot-resttestclient (con @AutoConfigureTestRestTemplate), pero RestTemplate está 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:

Informe de JaCoCo de empresa-app: cobertura total del 47 % de instrucciones y del 17 % de ramas, con el paquete seguridad al 97 %, config al 97 %, modelo al 55 %, web.api al 45 % y servicio al 23 % 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):

Código de PedidoService.java en JaCoCo: el método crear aparece en verde, con rombos verdes y amarillos en los if, y el método cambiarEstado completamente en rojo 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.properties se 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.

Dockerfile en tres etapas: construcción con maven y el JDK 25, extracción de capas con jarmode tools y una imagen final con el JRE 25, un usuario sin privilegios y las capas dependencies de 71 MB y application de 178 kB 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.aot en un «entrenamiento» y -XX:AOTCache=app.aot al 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=1 antes de estas órdenes. Si no, Git Bash «traduce» /tmp/fotos a una ruta de Windows (C:/Program Files/Git/tmp/fotos) antes de pasársela a Docker, y la aplicación vuelve a fallar con AccessDeniedException: /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.

docker compose: en la red privada del proyecto, el servicio app (imagen empresa-app:1.0, perfil prod, DB_URL apuntando a db) conecta por JDBC con el servicio db (mysql:8.4 con healthcheck), que guarda los datos en el volumen datos-mysql y carga los scripts de EMPRESA de ./docker/mysql; el equipo accede por el puerto 8080 y los secretos salen del fichero .env 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 que app empezó 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 localhost usa 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.1 el 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:

Listado de departamentos de empresa-app servido desde el contenedor de Docker Compose, con Ana García López conectada como ADMIN, los siete departamentos y la fila de totales Figura 7. empresa-app ejecutándose con Docker Compose y MySQL 8.4. Captura real.

Respuesta JSON de /actuator/health en el navegador: groups liveness y readiness y status UP 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 un compose.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 verify y docker 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