Módulo 11: Testing avanzado
Objetivos del módulo
- Entender por qué se automatizan las pruebas y qué es la pirámide de pruebas
- Configurar un proyecto Maven con JUnit 6, AssertJ, Mockito, JaCoCo, H2 y Testcontainers
- Dominar JUnit: ciclo de vida,
@DisplayName,@Nested,@Tag,@Disabled,@RepeatedTest,@Timeout, aserciones y suposiciones - Escribir pruebas parametrizadas con
@ValueSource,@CsvSource,@CsvFileSource,@MethodSourcey@EnumSource - Crear extensiones de JUnit y escribir aserciones legibles con AssertJ (colecciones, excepciones y soft assertions)
- Distinguir los dobles de prueba (dummy, stub, spy, mock y fake) y usarlos con Mockito
- Medir la cobertura con JaCoCo e interpretar su informe
- Hacer pruebas de integración con una base de datos H2 en memoria y con MySQL real en Testcontainers
- Aplicar TDD (rojo-verde-refactorizar) y el estilo BDD (Given-When-Then)
1. ¿Por qué automatizar las pruebas?
Imagina que trabajas en el departamento de informática de EMPRESA y te piden cambiar el cálculo de la nómina para añadir un complemento por antigüedad. Haces el cambio, arrancas la aplicación, calculas a mano la nómina de un par de empleados y parece que todo va bien. Tres semanas después, Contabilidad descubre que las comisiones de los vendedores han desaparecido de las nóminas. ¿Qué ha pasado? Tu cambio rompió algo que nadie volvió a comprobar.
📘 Concepto: Una prueba automatizada es código que ejecuta tu código y comprueba que el resultado es el esperado. Se lanza con una sola orden (
mvn test), tarda segundos y se puede repetir miles de veces sin esfuerzo. Cada vez que cambias algo, las pruebas te dicen si has roto lo que ya funcionaba: es una red de seguridad que te permite cambiar el código sin miedo.
1.1. La pirámide de pruebas
No todas las pruebas son iguales. La forma clásica de clasificarlas es la pirámide de pruebas, propuesta por Mike Cohn:
Figura 1. La pirámide de pruebas.
| Nivel | Qué prueba | Velocidad | Ejemplo en EMPRESA |
|---|---|---|---|
| Unitaria | Una clase (o un método) aislada de sus dependencias | Milisegundos | CalculadoraNominas calcula bien el neto |
| Integración | Varias piezas reales trabajando juntas | Décimas o segundos | EmpleadoDao consulta de verdad la tabla empleados |
| Extremo a extremo (E2E) | La aplicación completa, como la usaría el cliente | Segundos o minutos | Un navegador automatizado hace un pedido en la tienda web |
La idea es que la mayoría de tus pruebas sean unitarias: son rápidas, baratas de escribir y, cuando fallan, señalan exactamente dónde está el error. Las de integración son menos y más lentas, pero detectan problemas que las unitarias no ven (un SQL mal escrito, un tipo de columna incorrecto). Las E2E son las más costosas y frágiles: se reservan para los flujos críticos (una pirámide invertida, con muchas E2E, es un antipatrón muy común).
1.2. Cómo es una buena prueba: AAA y FIRST
Casi todas las pruebas siguen el patrón AAA (Arrange, Act, Assert), en español Preparar, Actuar, Comprobar:
@Test
void sumaComision() {
Empleado carmen = empleado("2200", "500", LocalDate.of(2016, 4, 12)); // Arrange (preparar)
BigDecimal bruto = calculadora.salarioBruto(carmen); // Act (actuar)
assertEquals(new BigDecimal("2700.00"), bruto); // Assert (comprobar)
}
Y deben cumplir los principios FIRST: Fast (rápidas: si tardan, dejarás de ejecutarlas), Independent (ninguna depende de que otra se haya ejecutado antes), Repeatable (mismo resultado en tu portátil y en el servidor de integración continua), Self-validating (pasan o fallan solas, sin que nadie lea la salida) y Timely (se escriben a la vez que el código, o antes, como en TDD).
2. Preparar el proyecto
Todo el código de este módulo forma parte de un único proyecto Maven, testing-empresa, con clases del dominio de EMPRESA: una calculadora de nóminas, un carrito de pedido con descuentos, un servicio de pedidos con repositorio y notificador, un validador de NIF y un DAO de empleados.
testing-empresa/
├── pom.xml
└── src/
├── main/java/es/curso/empresa/ ← cliente/, dao/, nomina/, pedido/
└── test/
├── java/es/curso/empresa/ ← las clases de prueba (*Test y *IT)
└── resources/ ← empleados.csv, h2/*.sql, sql/empresa_mysql_*.sql
📘 Concepto: Maven separa el código de producción (
src/main) del de pruebas (src/test). Las clases de prueba se compilan y ejecutan en la fasetest, pero nunca se empaquetan en el JAR final. Las dependencias con<scope>test</scope>tampoco.
2.1. Versiones que vamos a usar
| Herramienta | Versión | Para qué sirve |
|---|---|---|
JUnit (BOM org.junit:junit-bom) | 6.1.3 | Motor y API de pruebas (Jupiter) |
AssertJ (assertj-core) | 3.27.7 | Aserciones fluidas |
Mockito (mockito-core, mockito-junit-jupiter) | 5.24.0 | Dobles de prueba (mocks) |
JaCoCo (jacoco-maven-plugin) | 0.8.15 | Cobertura de código |
| H2 | 2.5.252 | Base de datos en memoria |
Testcontainers (testcontainers-mysql, testcontainers-junit-jupiter) | 2.0.5 | Bases de datos reales en contenedores Docker |
MySQL Connector/J (com.mysql:mysql-connector-j) | 26.7.0 | Driver JDBC de MySQL |
maven-surefire-plugin / maven-failsafe-plugin (y maven-compiler-plugin 3.16.0) | 3.6.0 | Ejecutan las pruebas unitarias / de integración |
⚠️ Importante: Desde JUnit 6 (septiembre de 2025) los tres componentes de JUnit, Platform (el motor que lanza las pruebas), Jupiter (la API que usas:
@Test,assertEquals…) y Vintage (para ejecutar pruebas antiguas de JUnit 4), comparten el mismo número de versión, y la versión mínima de Java pasa a ser la 17. En JUnit 5 Platform iba por la 1.x y Jupiter por la 5.x, lo que provocaba líos. Lo más cómodo es importar el BOM (Bill Of Materials) y no escribir la versión en cada dependencia. Si más adelante usas Spring Boot 4.1.1 (módulo 23), verás que gestiona JUnit 6.0.3; no pasa nada, la API que aprendes aquí es la misma. AssertJ tiene publicada una 4.0.0-M1, pero es solo un hito de desarrollo: usa la 3.27.x.
2.2. El pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>es.curso</groupId>
<artifactId>testing-empresa</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<argLine></argLine> <!-- JaCoCo le añadirá su agente (8.5) -->
<jacoco.minimo>0.80</jacoco.minimo> <!-- umbral de cobertura (11.4) -->
</properties>
<dependencyManagement>
<dependencies>
<!-- BOM de JUnit: misma versión para Platform, Jupiter y Vintage -->
<dependency><groupId>org.junit</groupId><artifactId>junit-bom</artifactId><version>6.1.3</version><type>pom</type><scope>import</scope></dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><version>26.7.0</version></dependency> <!-- driver JDBC de MySQL (lo usa el DAO) -->
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><scope>test</scope></dependency> <!-- api + params + engine; la versión la pone el BOM -->
<dependency><groupId>org.assertj</groupId><artifactId>assertj-core</artifactId><version>3.27.7</version><scope>test</scope></dependency>
<dependency><groupId>org.mockito</groupId><artifactId>mockito-core</artifactId><version>5.24.0</version><scope>test</scope></dependency>
<dependency><groupId>org.mockito</groupId><artifactId>mockito-junit-jupiter</artifactId><version>5.24.0</version><scope>test</scope></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.5.252</version><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-mysql</artifactId><version>2.0.5</version><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-junit-jupiter</artifactId><version>2.0.5</version><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.16.0</version></plugin>
<!-- Crea la propiedad ${org.mockito:mockito-core:jar} con la ruta del JAR (8.5) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId>
<version>3.11.0</version>
<executions><execution><goals><goal>properties</goal></goals></execution></executions>
</plugin>
<!-- Pruebas unitarias: clases *Test -->
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
</plugin>
<!-- Pruebas de integración: clases *IT, en la fase verify -->
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-failsafe-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
<executions>
<execution><goals><goal>integration-test</goal><goal>verify</goal></goals></execution>
</executions>
</plugin>
<!-- Aquí va también el plugin de JaCoCo (sección 11.2) -->
</plugins>
</build>
</project>
Explicaremos cada bloque a lo largo del módulo. De momento quédate con las dos familias de pruebas:
| Plugin | Clases que ejecuta (por defecto) | Fase de Maven | Orden |
|---|---|---|---|
| Surefire | *Test, Test*, *Tests, *TestCase | test | mvn test |
| Failsafe | *IT, IT*, *ITCase | integration-test y verify | mvn verify |
Así, mvn test ejecuta en segundos solo las pruebas rápidas, y mvn verify añade las de integración lentas (las de Testcontainers, que arrancan un MySQL). Failsafe no detiene la construcción en integration-test si algo falla: espera a verify para que dé tiempo a liberar recursos.
Para ejecutar las pruebas:
mvn test # compila y ejecuta las pruebas unitarias (surefire)
mvn verify # además, las de integración (failsafe) y la comprobación de cobertura
# Sin Maven ni JDK 25 instalados, con el contenedor oficial (así se obtuvieron las salidas del módulo):
docker run --rm -v "$PWD:/w" -v java-m2:/root/.m2 -w /w maven:3.9-eclipse-temurin-25 mvn test
⚠️ Importante: Para no alargar los listados, a partir de aquí omitimos las líneas
packageeimport(salvo cuando el paquete importa). Las importaciones más habituales sonorg.junit.jupiter.api.*(anotaciones),static org.junit.jupiter.api.Assertions.*,static org.assertj.core.api.Assertions.*,static org.mockito.Mockito.*,static org.mockito.ArgumentMatchers.*ystatic org.mockito.BDDMockito.*. El IDE las añade por ti con Alt+Intro.
3. JUnit en profundidad
3.1. La clase que vamos a probar
Empezamos por una clase sin dependencias: la calculadora de nóminas. Usa BigDecimal, como debe hacerse siempre con dinero, y una tabla de retenciones simplificada (no es la tabla real del IRPF).
/** Empleado de la tabla EMPLEADOS (solo los campos que necesitamos). */
public record Empleado(int id, String nombre, String oficio,
BigDecimal salario, BigDecimal comision, LocalDate fechaAlta) {
}
public class CalculadoraNominas {
public static final BigDecimal SEGURIDAD_SOCIAL = new BigDecimal("0.0635"); // 6,35 %
public static final BigDecimal IMPORTE_TRIENIO = new BigDecimal("30.00"); // por cada 3 años
/** Salario base + comisión (la comisión puede ser NULL en la BD). */
public BigDecimal salarioBruto(Empleado e) {
if (e.salario() == null || e.salario().signum() <= 0) {
throw new IllegalArgumentException("El salario debe ser positivo: " + e.salario());
}
BigDecimal comision = e.comision() == null ? BigDecimal.ZERO : e.comision();
return e.salario().add(comision).setScale(2, RoundingMode.HALF_UP);
}
/** Porcentaje de retención según el bruto mensual (tabla simplificada). */
public int porcentajeRetencion(BigDecimal brutoMensual) {
if (brutoMensual.compareTo(new BigDecimal("1500")) < 0) {
return 2;
} else if (brutoMensual.compareTo(new BigDecimal("2500")) < 0) {
return 12;
} else if (brutoMensual.compareTo(new BigDecimal("4000")) < 0) {
return 18;
}
return 24;
}
/** Trienios completos entre la fecha de alta y la fecha de cálculo. */
public int trienios(LocalDate fechaAlta, LocalDate hoy) {
if (fechaAlta.isAfter(hoy)) {
throw new IllegalArgumentException("La fecha de alta es posterior a " + hoy);
}
return Period.between(fechaAlta, hoy).getYears() / 3;
}
/** Paga extra de junio y diciembre: salario base + antigüedad (sin comisión). */
public BigDecimal pagaExtra(Empleado e, LocalDate hoy) {
if (hoy.getMonthValue() != 6 && hoy.getMonthValue() != 12) {
return BigDecimal.ZERO.setScale(2);
}
BigDecimal antiguedad = IMPORTE_TRIENIO.multiply(BigDecimal.valueOf(trienios(e.fechaAlta(), hoy)));
return e.salario().add(antiguedad).setScale(2, RoundingMode.HALF_UP);
}
/** Neto = bruto + antigüedad - retención - Seguridad Social. */
public BigDecimal salarioNeto(Empleado e, LocalDate hoy) {
BigDecimal bruto = salarioBruto(e)
.add(IMPORTE_TRIENIO.multiply(BigDecimal.valueOf(trienios(e.fechaAlta(), hoy))));
BigDecimal retencion = bruto.multiply(BigDecimal.valueOf(porcentajeRetencion(bruto)))
.divide(BigDecimal.valueOf(100));
BigDecimal seguridadSocial = bruto.multiply(SEGURIDAD_SOCIAL);
return bruto.subtract(retencion).subtract(seguridadSocial)
.setScale(2, RoundingMode.HALF_UP);
}
}
💡 Consejo:
trienios,pagaExtraysalarioNetoreciben la fecha de “hoy” como parámetro en lugar de llamar dentro aLocalDate.now(). Connow(), la prueba que hoy pasa podría fallar el año que viene. Hacer que el tiempo sea un parámetro (o inyectar unjava.time.Clock, como viste en el módulo 05) es la forma de que las pruebas sean repetibles.
3.2. Ciclo de vida de una prueba
JUnit no se limita a llamar a tus métodos @Test: sigue un ciclo de vida con puntos en los que puedes preparar y limpiar recursos.
Figura 2. Ciclo de vida de una clase de prueba en JUnit.
| Anotación | Cuándo se ejecuta | Uso típico |
|---|---|---|
@BeforeAll | Una vez, antes de todas las pruebas de la clase (método static) | Arrancar algo caro: un servidor, una base de datos |
@BeforeEach | Antes de cada prueba | Crear el objeto a probar y sus datos |
@AfterEach | Después de cada prueba, aunque haya fallado | Borrar ficheros temporales, deshacer cambios |
@AfterAll | Una vez, al final (método static) | Parar lo que arrancó @BeforeAll |
Esta clase imprime cuándo se ejecuta cada método y el identityHashCode de this (qué instancia de la clase de prueba se usa):
class CicloVidaTest {
@BeforeAll
static void iniciarTodo() { System.out.println("@BeforeAll -> una vez, antes de todas las pruebas"); }
@BeforeEach
void preparar() { System.out.println(" @BeforeEach -> instancia " + System.identityHashCode(this)); }
@Test
void primeraPrueba() { System.out.println(" @Test primeraPrueba"); }
@Test
void segundaPrueba() { System.out.println(" @Test segundaPrueba"); }
@AfterEach
void limpiar() { System.out.println(" @AfterEach"); }
@AfterAll
static void terminarTodo() { System.out.println("@AfterAll -> una vez, al final"); }
}
[INFO] Running es.curso.empresa.CicloVidaTest
@BeforeAll -> una vez, antes de todas las pruebas
@BeforeEach -> instancia 1691629865
@Test segundaPrueba
@AfterEach
@BeforeEach -> instancia 467401150
@Test primeraPrueba
@AfterEach
@AfterAll -> una vez, al final
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.910 s -- in es.curso.empresa.CicloVidaTest
Observa dos cosas: cada prueba se ejecuta en una instancia distinta de CicloVidaTest (por eso @BeforeAll y @AfterAll son static: cuando se ejecutan no existe ninguna instancia), y el orden no es el del fichero: JUnit usa un orden determinista pero deliberadamente “no obvio”, para que no te apoyes en él.
📘 Concepto: Este modo se llama instancia por método (
Lifecycle.PER_METHOD) y es el predeterminado: un campo modificado por una prueba no “contamina” a la siguiente, así que las pruebas son independientes. Con@TestInstance(TestInstance.Lifecycle.PER_CLASS)JUnit usa una sola instancia para toda la clase:@BeforeAllpuede ser un método normal, pero los campos se comparten.
⚠️ Importante: En
CicloVidaPorClaseTest(proyecto del módulo) dos pruebas ordenadas con@TestMethodOrder(MethodOrderer.OrderAnnotation.class)y@Orderincrementan un campocontador: la segunda solo pasa si antes se ejecutó la primera, y si la lanzas sola desde el IDE, falla. UsaPER_CLASScuando tengas un motivo (un recurso caro creado en un@BeforeAllno estático) y nunca para encadenar pruebas. Lo mismo vale para@TestMethodOrder: en pruebas unitarias suele esconder dependencias ocultas.
3.3. Organizar las pruebas: @DisplayName, @Nested y @Disabled
@Nested permite crear clases internas (no estáticas) que agrupan pruebas relacionadas y comparten el @BeforeEach de la clase exterior. @DisplayName da a cada clase o método un nombre legible, con espacios y tildes, que verás en el IDE y en los informes. @Disabled desactiva una prueba.
@DisplayName("Calculadora de nóminas")
@Tag("rapidas")
class CalculadoraNominasTest {
private CalculadoraNominas calculadora;
@BeforeEach
void setUp() {
calculadora = new CalculadoraNominas(); // objeto nuevo en cada prueba
}
private static Empleado empleado(String salario, String comision, LocalDate alta) {
return new Empleado(110, "Carmen", "VENDEDOR", new BigDecimal(salario),
comision == null ? null : new BigDecimal(comision), alta);
}
@Nested
@DisplayName("Salario bruto")
class SalarioBruto {
@Test
@DisplayName("suma salario y comisión")
void sumaComision() {
Empleado carmen = empleado("2200", "500", LocalDate.of(2016, 4, 12));
assertEquals(new BigDecimal("2700.00"), calculadora.salarioBruto(carmen));
}
}
@Nested
@DisplayName("Antigüedad")
class Antiguedad {
@Test
@DisplayName("cuenta solo los trienios completos")
void trieniosCompletos() {
LocalDate hoy = LocalDate.of(2026, 10, 4);
assertAll("trienios",
() -> assertEquals(0, calculadora.trienios(LocalDate.of(2024, 1, 1), hoy)),
() -> assertEquals(1, calculadora.trienios(LocalDate.of(2023, 10, 4), hoy)),
() -> assertEquals(1, calculadora.trienios(LocalDate.of(2020, 10, 5), hoy)),
() -> assertEquals(5, calculadora.trienios(LocalDate.of(2010, 1, 15), hoy)));
}
}
@Test
@Disabled("Pendiente: la tabla de 2027 aún no está publicada")
void tablaRetenciones2027() {
}
}
En el IDE verás un árbol con los @DisplayName (“Calculadora de nóminas › Salario bruto › suma salario y comisión”). Surefire, en la consola, informa por clase, y la prueba desactivada aparece como Skipped:
[INFO] Running Calculadora de nóminas
[INFO] Running Antigüedad
[WARNING] Tests run: 3, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.122 s -- in Antigüedad
[INFO] Running Salario bruto
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.363 s -- in Salario bruto
[INFO] Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.299 s -- in Calculadora de nóminas
La clase del proyecto tiene dos pruebas más que no mostramos: salarioNoPositivo (en “Salario bruto”, comprueba con assertThrows el mensaje “El salario debe ser positivo: 0”) y netoCompleto (en la clase exterior, comprueba con assertAll que el neto de Carmen es 2110,64 €). Surefire suma las pruebas de la clase exterior a la última anidada que termina: por eso “Antigüedad” aparece con 3. Es solo la presentación; el total es correcto.
3.4. Aserciones de JUnit
Las aserciones son métodos estáticos de org.junit.jupiter.api.Assertions. El primer argumento es el valor esperado, el segundo el real y el último (opcional) un mensaje, que puede ser un Supplier<String> para que solo se construya si la prueba falla.
| Aserción | Comprueba que… |
|---|---|
assertEquals(esperado, real) / assertNotEquals | son iguales (o distintos) según equals; con delta para double |
assertTrue, assertFalse, assertNull, assertNotNull, assertSame | condición verdadera / falsa, es null / no lo es, mismo objeto (==) |
assertArrayEquals / assertIterableEquals / assertLinesMatch | arrays, colecciones o líneas de texto iguales y en el mismo orden |
assertInstanceOf(Clase.class, x) | x es de ese tipo (y lo devuelve ya convertido) |
assertThrows(Tipo.class, () -> ...) / assertDoesNotThrow | el código lanza esa excepción (y la devuelve) / no lanza nada |
assertTimeout(duracion, () -> ...) | termina antes de ese tiempo; assertTimeoutPreemptively lo interrumpe al llegar al límite |
assertAll(() -> ..., () -> ...) | ejecuta todas las comprobaciones e informa de todos los fallos juntos |
⚠️ Importante:
BigDecimal.equalscompara valor y escala:new BigDecimal("2700.00")no es igual anew BigDecimal("2700"). Por eso la calculadora redondea siempre consetScale(2, ...)y las pruebas esperan"2700.00". Si solo te importa el valor, usacompareTooisEqualByComparingTode AssertJ (sección 6).
3.5. Repeticiones, tiempos límite y suposiciones
class OtrasAnotacionesTest {
private static final String LETRAS = "TRWAGMYFPDXBNJZSQVHLCKE";
private final ValidadorNif validador = new ValidadorNif();
@RepeatedTest(value = 5, name = "NIF aleatorio {currentRepetition}/{totalRepetitions}")
void nifAleatorioValido(RepetitionInfo info) {
int numero = ThreadLocalRandom.current().nextInt(0, 100_000_000);
String nif = "%08d%c".formatted(numero, LETRAS.charAt(numero % 23));
assertTrue(validador.esValido(nif), () -> "Falla con " + nif);
}
@Test
@Timeout(value = 2, unit = TimeUnit.SECONDS) // falla si tarda más
void validarMilNifsEsRapido() {
IntStream.range(0, 1_000).forEach(i -> validador.esValido("12345678Z"));
}
@Test
void assertTimeoutDevuelveElResultado() {
assertTrue(assertTimeout(Duration.ofMillis(200), () -> validador.esValido("87654321X")));
}
@Test
@Tag("lentas")
void soloEnIntegracionContinua() {
// Si la suposición no se cumple, la prueba se marca como "skipped", no como fallo
assumeTrue("true".equals(System.getenv("CI")), "Solo se ejecuta en el servidor de CI");
assertTrue(validador.esValido("00000000T"));
}
@Test
void parteCondicional() {
assumingThat(System.getProperty("os.name").startsWith("Linux"),
() -> System.out.println("Comprobación extra que solo se hace en Linux"));
assertTrue(validador.esValido("12345678Z")); // esto se comprueba siempre
}
}
| Herramienta | Qué hace |
|---|---|
@RepeatedTest(n) | Ejecuta la prueba n veces; RepetitionInfo dice qué repetición es. Útil con datos aleatorios o errores intermitentes |
@Timeout | Falla si la prueba supera el tiempo (para todas: propiedad junit.jupiter.execution.timeout.default). Ponlo holgado: con 500 ms, esta prueba falló en un servidor cargado con TimeoutException: validarMilNifsEsRapido() timed out after 500 milliseconds |
assumeTrue / assumeFalse | Si la condición no se cumple, la prueba se aborta y cuenta como skipped |
assumingThat(cond, bloque) | Ejecuta el bloque solo si se cumple la condición; el resto de la prueba sigue |
En el contenedor Linux salen 9 pruebas (5 repeticiones + 4); la de CI se salta porque no existe la variable CI:
[INFO] Running es.curso.empresa.OtrasAnotacionesTest
Comprobación extra que solo se hace en Linux
[WARNING] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 2.738 s -- in es.curso.empresa.OtrasAnotacionesTest
📘 Concepto:
@Disableddesactiva la prueba siempre; una suposición decide en tiempo de ejecución si tiene sentido ejecutarla (no hay base de datos, no estamos en Linux…). Para casos típicos hay anotaciones condicionales:@EnabledOnOs(OS.LINUX),@EnabledForJreRange(min = JRE.JAVA_21),@EnabledIfEnvironmentVariable(named = "CI", matches = "true")…
3.6. Etiquetas y filtrado con Surefire
@Tag clasifica pruebas (una clase o método puede tener varias etiquetas) y luego eliges qué ejecutar sin tocar el código. Nuestras pruebas con H2 llevan @Tag("integracion"):
mvn test -Dgroups=integracion
[INFO] Running es.curso.empresa.dao.EmpleadoDaoH2Test
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 309.2 s -- in es.curso.empresa.dao.EmpleadoDaoH2Test
[INFO] Running es.curso.empresa.ejercicios.EmpleadoDaoOficiosTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 16.22 s -- in es.curso.empresa.ejercicios.EmpleadoDaoOficiosTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Solo se ejecutan las 8 pruebas etiquetadas, de las 88 del proyecto.
| Opción | Efecto |
|---|---|
-Dgroups=rapidas / -DexcludedGroups=lentas | Solo las etiquetadas como rapidas / todas menos las lentas |
-Dgroups="rapidas \| integracion" | Expresiones de etiquetas: \| (o), & (y), ! (no) |
-Dtest=CalculadoraNominasTest / -Dtest=*Dao* | Solo una clase (admite comodines) |
-Dtest=ValidadorNifTest#nifConLetraCorrectaEsValido | Solo un método |
4. Pruebas parametrizadas
Para probar la misma lógica con datos distintos no copies y pegues la prueba diez veces: las pruebas parametrizadas ejecutan un mismo método con cada conjunto de argumentos. Se marcan con @ParameterizedTest (artefacto junit-jupiter-params, incluido en junit-jupiter) junto a una fuente de argumentos. Las probaremos con el validador de NIF (sección 10), el enumerado de estados de pedido y el carrito de la tienda:
public record Producto(int id, String nombre, String categoria, // tabla PRODUCTOS
BigDecimal precio, int stock, boolean descatalogado) { }
public record LineaPedido(Producto producto, int cantidad) { // tabla LINEAS_PEDIDO
public BigDecimal importe() { return producto.precio().multiply(BigDecimal.valueOf(cantidad)); }
}
public enum EstadoPedido { // valores de la restricción ck_ped_estado
PENDIENTE, ENVIADO, ENTREGADO, CANCELADO;
public boolean puedeCancelarse() { return this == PENDIENTE; }
public boolean esFinal() { return this == ENTREGADO || this == CANCELADO; }
}
/** Carrito de la tienda con descuento por volumen y cupones. */
public class Carrito {
private static final Map<String, Integer> CUPONES = Map.of("BIENVENIDA10", 10, "VERANO15", 15);
private static final BigDecimal UMBRAL_VOLUMEN = new BigDecimal("1000");
private static final int DESCUENTO_VOLUMEN = 5;
private final List<LineaPedido> lineas = new ArrayList<>();
private int descuentoCupon;
public void agregar(Producto producto, int cantidad) {
if (cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser positiva: " + cantidad);
}
if (producto.descatalogado()) {
throw new IllegalStateException("Producto descatalogado: " + producto.nombre());
}
lineas.add(new LineaPedido(producto, cantidad));
}
public void aplicarCupon(String codigo) {
Integer porcentaje = CUPONES.get(codigo);
if (porcentaje == null) {
throw new IllegalArgumentException("Cupón no válido: " + codigo);
}
descuentoCupon = porcentaje;
}
/** Quita todas las líneas de un producto; si el carrito queda vacío, pierde el cupón. */
public boolean eliminar(int productoId) {
boolean eliminado = lineas.removeIf(l -> l.producto().id() == productoId);
if (lineas.isEmpty()) {
descuentoCupon = 0;
}
return eliminado;
}
public List<LineaPedido> lineas() { return List.copyOf(lineas); }
public int unidades() { return lineas.stream().mapToInt(LineaPedido::cantidad).sum(); }
public BigDecimal subtotal() {
return lineas.stream().map(LineaPedido::importe).reduce(BigDecimal.ZERO, BigDecimal::add);
}
/** Se aplica el mayor de los dos descuentos (no son acumulables). */
public int descuentoAplicado() {
int volumen = subtotal().compareTo(UMBRAL_VOLUMEN) >= 0 ? DESCUENTO_VOLUMEN : 0;
return Math.max(volumen, descuentoCupon);
}
public BigDecimal total() {
BigDecimal factor = BigDecimal.valueOf(100 - descuentoAplicado()).divide(BigDecimal.valueOf(100));
return subtotal().multiply(factor).setScale(2, RoundingMode.HALF_UP);
}
}
Y estas son las pruebas, con cinco fuentes de argumentos distintas:
class ParametrizadasTest {
private final ValidadorNif validador = new ValidadorNif();
// 1) Valores literales de un único tipo
@ParameterizedTest
@ValueSource(strings = {"12345678Z", "00000000T", "87654321X", " 12345678z "})
void nifsValidos(String nif) {
assertTrue(validador.esValido(nif));
}
// 2) null y "" (y con @ValueSource, solo espacios)
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "1234Z", "12345678A", "ABCDEFGHZ"})
void nifsNoValidos(String nif) {
assertFalse(validador.esValido(nif));
}
// 3) Constantes de un enum (todas, o solo las indicadas)
@ParameterizedTest
@EnumSource(value = EstadoPedido.class, names = {"ENVIADO", "ENTREGADO", "CANCELADO"})
void soloSeCancelanLosPendientes(EstadoPedido estado) {
assertFalse(estado.puedeCancelarse());
}
// 4) Un método fábrica que devuelve los argumentos
static Stream<Arguments> carritos() {
Producto portatil = new Producto(2, "Portátil Gaming 16", "Portátiles",
new BigDecimal("1499.00"), 10, false);
Producto raton = new Producto(6, "Ratón inalámbrico", "Ratones y teclados",
new BigDecimal("19.90"), 200, false);
return Stream.of(
arguments(raton, 2, null, "39.80"), // sin descuento
arguments(raton, 2, "BIENVENIDA10", "35.82"), // cupón 10 %
arguments(portatil, 1, null, "1424.05"), // volumen 5 %
arguments(portatil, 1, "VERANO15", "1274.15")); // gana el cupón
}
@ParameterizedTest(name = "[{index}] {1} x {0} con cupón {2} = {3} €")
@MethodSource("carritos")
void totalDelCarrito(Producto producto, int cantidad, String cupon, String esperado) {
Carrito carrito = new Carrito();
carrito.agregar(producto, cantidad);
if (cupon != null) {
carrito.aplicarCupon(cupon);
}
assertEquals(new BigDecimal(esperado), carrito.total());
}
// 5) Datos en un fichero CSV de src/test/resources
@ParameterizedTest(name = "{0}: bruto {3} €")
@CsvFileSource(resources = "/empleados.csv", numLinesToSkip = 1)
void brutoDesdeCsv(String nombre, BigDecimal salario, BigDecimal comision, BigDecimal bruto) {
Empleado e = new Empleado(0, nombre, "VENDEDOR", salario, comision, LocalDate.of(2020, 1, 1));
assertEquals(bruto, new CalculadoraNominas().salarioBruto(e));
}
}
El fichero src/test/resources/empleados.csv empieza por la cabecera nombre,salario,comision,bruto y tiene cinco empleados de Ventas, como Carmen,2200,500,2700.00 o Lucía,1700,,1700.00 (sin comisión: el campo vacío llega como null). Resultado: 22 ejecuciones a partir de 5 métodos (4 + 6 + 3 + 4 + 5):
[INFO] Running es.curso.empresa.ParametrizadasTest
[INFO] Tests run: 22, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.400 s -- in es.curso.empresa.ParametrizadasTest
| Fuente | Ejemplo | Cuándo usarla |
|---|---|---|
@ValueSource | @ValueSource(ints = {1, 2, 3}) | Un parámetro de tipo simple (strings, ints, doubles, classes…) |
@NullSource, @EmptySource, @NullAndEmptySource | junto a @ValueSource | Añadir null y/o vacío |
@EnumSource | @EnumSource(value = EstadoPedido.class, names = {...}, mode = EXCLUDE) | Constantes de un enum |
@CsvSource | @CsvSource({"900, 2", "1500, 12"}) | Varias columnas escritas en la propia prueba |
@CsvFileSource | @CsvFileSource(resources = "/empleados.csv", numLinesToSkip = 1) | Muchas filas: mejor en un fichero |
@MethodSource / @FieldSource | @MethodSource("carritos") | Objetos complejos: método static que devuelve Stream<Arguments> (o un campo) |
@ArgumentsSource | @ArgumentsSource(MiProveedor.class) | Un proveedor reutilizable escrito por ti |
- Conversión automática: en
@CsvSourcetodo es texto, pero JUnit lo convierte al tipo del parámetro (int,BigDecimal,LocalDatecomo2026-10-04,enum…). - En
@CsvSource, un valor vacío sin comillas llega comonull(para una cadena vacía,''), ynamepersonaliza cada caso:{index}es su número y{0},{1}… los argumentos. - Desde JUnit 6 los CSV se analizan con la biblioteca FastCSV y existe el atributo
commentCharacter(por defecto#). Desde la 5.13 hay además clases parametrizadas (@ParameterizedClass), que ejecutan la clase entera una vez por cada conjunto de argumentos.
✏️ Ejercicio: Añade a
nifsNoValidosel valor"12345678ZZ"(una letra de más). ¿Pasa la prueba? ¿Por qué? Pista: mira la expresión regular deValidadorNif.
5. Extensiones de JUnit
📘 Concepto: Una extensión es una clase que se “engancha” a puntos del ciclo de vida de JUnit para añadir comportamiento: medir tiempos, preparar recursos, inyectar parámetros, decidir si se ejecuta una prueba… Es el mecanismo con el que se integran Mockito (
MockitoExtension), Testcontainers (@Testcontainers) o Spring (SpringExtension, en el módulo 23).
Las extensiones implementan interfaces de org.junit.jupiter.api.extension:
| Interfaz | Punto de enganche |
|---|---|
BeforeAllCallback, BeforeEachCallback (y sus After...) | Antes / después de toda la clase o de cada prueba |
BeforeTestExecutionCallback / AfterTestExecutionCallback | Justo antes / después del método de prueba |
ParameterResolver | Inyecta valores en los parámetros de las pruebas |
ExecutionCondition / TestWatcher | Decide si se ejecuta la prueba / recibe su resultado |
5.1. Una extensión que mide el tiempo
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
/** Extensión que mide cuánto tarda cada prueba y avisa de las lentas. */
public class MedirTiempoExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private static final long UMBRAL_MS = 50;
private static final String INICIO = "inicio";
@Override
public void beforeTestExecution(ExtensionContext contexto) {
almacen(contexto).put(INICIO, System.nanoTime());
}
@Override
public void afterTestExecution(ExtensionContext contexto) {
long inicio = almacen(contexto).remove(INICIO, long.class);
long ms = (System.nanoTime() - inicio) / 1_000_000;
String aviso = ms > UMBRAL_MS ? " <-- LENTA" : "";
System.out.printf("[tiempo] %s: %d ms%s%n", contexto.getDisplayName(), ms, aviso);
}
/** Cada prueba tiene su propio almacén de datos dentro del contexto. */
private ExtensionContext.Store almacen(ExtensionContext contexto) {
return contexto.getStore(ExtensionContext.Namespace.create(getClass(), contexto.getRequiredTestMethod()));
}
}
⚠️ Importante: JUnit crea una sola instancia de la extensión y la reutiliza, incluso con pruebas en paralelo. Por eso no guardamos el instante de inicio en un campo, sino en el
StoredelExtensionContext: un almacén clave-valor asociado a cada prueba.
Se registra con @ExtendWith sobre la clase o un método (mostramos solo la prueba lenta; retencionRapida es igual, pero sin sleep y con 2000 €):
@ExtendWith(MedirTiempoExtension.class)
class MedirTiempoTest {
private final CalculadoraNominas calculadora = new CalculadoraNominas();
@Test
void calculoLento() throws InterruptedException {
Thread.sleep(120); // simulamos una operación costosa
assertEquals(24, calculadora.porcentajeRetencion(new BigDecimal("6500")));
}
}
[INFO] Running es.curso.empresa.extension.MedirTiempoTest
[tiempo] retencionRapida(): 1 ms
[tiempo] calculoLento(): 160 ms <-- LENTA
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.784 s -- in es.curso.empresa.extension.MedirTiempoTest
También puedes registrarla con @RegisterExtension sobre un campo (para configurarla por código), crear tu propia anotación @Cronometrado anotada con @ExtendWith(MedirTiempoExtension.class) o registrarla automáticamente con ServiceLoader.
💡 Consejo: JUnit trae extensiones listas para usar:
@TempDir(inyecta una carpeta temporal que se borra al terminar:void exportar(@TempDir Path carpeta)),@AutoClosey, desde JUnit 6.1,@DefaultLocale("es-ES")y@DefaultTimeZone(paqueteorg.junit.jupiter.api.util), que fijan la configuración regional o la zona horaria solo durante una prueba. Sin@DefaultLocale, una prueba que formatea1.499,00 €pasaría en un equipo en español y fallaría en un servidor en inglés.
6. Aserciones fluidas con AssertJ
Las aserciones de JUnit bastan, pero es fácil confundir el orden esperado, real y comprobar colecciones u objetos complejos exige mucho código. AssertJ propone una API fluida: empiezas siempre con assertThat(valorReal) y encadenas comprobaciones (assertThat(lineas).hasSize(3) en lugar de assertEquals(3, lineas.size())). Como assertThat devuelve un objeto específico del tipo (cadena, número, lista, Optional, excepción…), el IDE te autocompleta solo las comprobaciones que tienen sentido (importa org.assertj.core.api.Assertions.assertThat, no el de Hamcrest). JUnit sigue siendo el motor que ejecuta las pruebas; AssertJ es solo la biblioteca de aserciones.
Probamos el carrito con productos reales de la tabla productos de EMPRESA:
class CarritoAssertJTest {
private static final Producto PORTATIL = new Producto(2, "Portátil Gaming 16", "Portátiles",
new BigDecimal("1499.00"), 10, false);
private static final Producto RATON = new Producto(6, "Ratón inalámbrico", "Ratones y teclados",
new BigDecimal("19.90"), 200, false);
private static final Producto TECLADO = new Producto(7, "Teclado mecánico", "Ratones y teclados",
new BigDecimal("79.90"), 60, false);
private static final Producto ALTAVOZ = new Producto(14, "Altavoz Inteligente", "Audio",
new BigDecimal("49.00"), 0, true);
private Carrito carrito;
@BeforeEach
void setUp() {
carrito = new Carrito();
carrito.agregar(PORTATIL, 1);
carrito.agregar(RATON, 3);
carrito.agregar(TECLADO, 2);
}
6.1. Cadenas, números y colecciones
@Test
void cadenasYNumeros() {
assertThat(PORTATIL.nombre()).startsWith("Portátil").contains("Gaming")
.doesNotContainIgnoringCase("básico").hasSize(18);
assertThat(carrito.unidades()).isEqualTo(6).isBetween(1, 10);
// BigDecimal: compara el valor, no la escala (1718.50 == 1718.5)
assertThat(carrito.subtotal()).isEqualByComparingTo("1718.5");
}
@Test
void colecciones() {
List<LineaPedido> lineas = carrito.lineas();
assertThat(lineas).hasSize(3).doesNotContainNull();
assertThat(lineas)
.extracting(linea -> linea.producto().nombre())
.containsExactly("Portátil Gaming 16", "Ratón inalámbrico", "Teclado mecánico");
assertThat(lineas)
.filteredOn(linea -> linea.producto().categoria().equals("Ratones y teclados"))
.extracting(linea -> linea.producto().id(), LineaPedido::cantidad)
.containsExactlyInAnyOrder(tuple(7, 2), tuple(6, 3));
assertThat(lineas)
.allSatisfy(linea -> assertThat(linea.cantidad()).isPositive())
.anySatisfy(linea -> assertThat(linea.importe()).isGreaterThan(new BigDecimal("1000")));
}
isEqualByComparingTo usa compareTo y resuelve el problema de la escala de BigDecimal. Con colecciones es donde AssertJ brilla:
| Método | Qué hace |
|---|---|
extracting(f) / extracting(f1, f2...) | Transforma cada elemento (como map); con varias funciones, cada elemento se convierte en una tupla (tuple(...)) |
filteredOn(predicado) | Se queda con los elementos que cumplen la condición (como filter) |
containsExactly(...) / containsExactlyInAnyOrder(...) | Exactamente esos elementos, en ese orden / en cualquier orden |
contains, doesNotContain, allSatisfy, anySatisfy, allMatch | Al menos esos / ninguno / todos o alguno cumplen las aserciones o el predicado |
first(), last(), element(i) | Siguen comprobando sobre un elemento concreto |
6.2. Excepciones y soft assertions
@Test
void excepciones() {
assertThatThrownBy(() -> carrito.agregar(ALTAVOZ, 1))
.isInstanceOf(IllegalStateException.class)
.hasMessage("Producto descatalogado: Altavoz Inteligente");
assertThatIllegalArgumentException()
.isThrownBy(() -> carrito.aplicarCupon("GRATIS100"))
.withMessageContaining("GRATIS100");
}
@Test
void aseveracionesSuaves() {
carrito.aplicarCupon("VERANO15");
SoftAssertions.assertSoftly(soft -> {
soft.assertThat(carrito.descuentoAplicado()).as("descuento").isEqualTo(15);
soft.assertThat(carrito.total()).as("total").isEqualByComparingTo("1460.73");
soft.assertThat(carrito.lineas()).as("líneas").hasSize(3);
});
}
}
Para excepciones también existen assertThatExceptionOfType(Tipo.class), assertThatNoException().isThrownBy(...) y catchThrowable(() -> ...), que la captura para comprobarla después. Con las aserciones suaves (SoftAssertions) se ejecutan todas las comprobaciones y al final se informa de todos los fallos juntos (como assertAll); as("...") pone la descripción que aparece en el mensaje. Si cambiamos a propósito el descuento esperado a 10 y el total a 1500:
[ERROR] es.curso.empresa.pedido.SoftFalloTest.aseveracionesSuaves -- Time elapsed: 33.87 s <<< FAILURE!
org.assertj.core.error.AssertJMultipleFailuresError:
Multiple Failures (2 failures)
-- failure 1 --
[descuento]
expected: 10
but was: 15
at SoftFalloTest.lambda$aseveracionesSuaves$0(SoftFalloTest.java:18)
-- failure 2 --
[total]
expected: 1500
but was: 1460.73
at SoftFalloTest.lambda$aseveracionesSuaves$0(SoftFalloTest.java:19)
AssertJ informa de los dos fallos a la vez, cada uno con su descripción entre corchetes y la línea exacta. Con aserciones normales solo habrías visto el primero, lo habrías corregido y entonces habría aparecido el segundo.
7. Dobles de prueba
La mayoría del código real colabora con otras piezas: repositorios, servicios de correo, clientes HTTP… Este servicio implementa el caso de uso “realizar un pedido”:
/** Caso de uso "realizar pedido": comprueba stock, guarda y avisa al cliente. */
public class ServicioPedidos {
private final RepositorioProductos productos;
private final RepositorioPedidos pedidos;
private final Notificador notificador;
public ServicioPedidos(RepositorioProductos productos, RepositorioPedidos pedidos,
Notificador notificador) {
this.productos = productos;
this.pedidos = pedidos;
this.notificador = notificador;
}
public Pedido realizarPedido(int clienteId, int productoId, int cantidad) {
Producto producto = productos.buscarPorId(productoId)
.orElseThrow(() -> new NoSuchElementException("No existe el producto " + productoId));
if (producto.stock() < cantidad) {
throw new StockInsuficienteException(producto.nombre(), cantidad, producto.stock());
}
productos.actualizarStock(productoId, producto.stock() - cantidad);
BigDecimal importe = producto.precio().multiply(BigDecimal.valueOf(cantidad));
Pedido guardado = pedidos.guardar(
new Pedido(null, clienteId, productoId, cantidad, importe, EstadoPedido.PENDIENTE));
notificador.enviar(clienteId,
"Pedido " + guardado.id() + " confirmado: " + importe + " €");
return guardado;
}
}
Sus dependencias son interfaces que llegan por el constructor (inversión de dependencias, módulo 10):
public interface RepositorioProductos {
Optional<Producto> buscarPorId(int id);
void actualizarStock(int id, int nuevoStock);
}
public interface RepositorioPedidos {
Pedido guardar(Pedido pedido); // lo devuelve con el id generado
}
public interface Notificador {
void enviar(int clienteId, String mensaje); // correo, SMS...
}
/** Pedido de la tabla PEDIDOS (simplificado: un solo producto; la fecha la pone la BD). */
public record Pedido(Integer id, int clienteId, int productoId, int cantidad,
BigDecimal importe, EstadoPedido estado) {
public Pedido conId(int nuevoId) { // copia con el id asignado por la BD
return new Pedido(nuevoId, clienteId, productoId, cantidad, importe, estado);
}
}
StockInsuficienteException es una RuntimeException con el mensaje “Stock insuficiente de producto: pedidas n, disponibles m”. ¿Cómo probamos realizarPedido sin MySQL ni servidor de correo? Sustituyendo las dependencias por dobles de prueba (test doubles), igual que en el cine un doble sustituye al actor en las escenas peligrosas.
Figura 3. Los cinco tipos de dobles de prueba.
| Tipo | Qué es | Ejemplo con ServicioPedidos |
|---|---|---|
| Dummy | Se pasa pero nunca se usa; solo rellena un parámetro | El notificador cuando el producto no existe |
| Stub | Devuelve respuestas preparadas | buscarPorId(1) devuelve siempre el portátil |
| Spy (espía) | Registra cómo lo han usado para consultarlo después | Un notificador que guarda los mensajes en una lista |
| Mock | La prueba verifica que se le llamó como se esperaba | Comprobar que se llamó a enviar(1001, ...) una vez |
| Fake | Implementación que funciona de verdad, pero simplificada | Repositorio sobre un HashMap en lugar de MySQL |
(Clasificación de Gerard Meszaros, xUnit Test Patterns.) En el día a día se llama “mock” a casi todo, pero conviene distinguir: stubs y fakes ayudan a comprobar el estado final; spies y mocks, el comportamiento (qué llamadas se hicieron).
7.1. Dobles escritos a mano
Un fake del repositorio de productos:
/** FAKE: implementación real pero simplificada (un HashMap en lugar de MySQL). */
public class RepositorioProductosEnMemoria implements RepositorioProductos {
private final Map<Integer, Producto> datos = new HashMap<>();
public RepositorioProductosEnMemoria con(Producto p) {
datos.put(p.id(), p);
return this;
}
@Override
public Optional<Producto> buscarPorId(int id) {
return Optional.ofNullable(datos.get(id));
}
@Override
public void actualizarStock(int id, int nuevoStock) {
Producto p = datos.get(id);
datos.put(id, new Producto(p.id(), p.nombre(), p.categoria(), p.precio(),
nuevoStock, p.descatalogado()));
}
}
Y una prueba que lo combina con un stub (una lambda, porque RepositorioPedidos tiene un solo método) y un spy:
/** SPY manual: un notificador que apunta lo que le piden. */
static class NotificadorEspia implements Notificador {
final List<String> mensajes = new ArrayList<>();
@Override
public void enviar(int clienteId, String mensaje) {
mensajes.add(clienteId + ": " + mensaje);
}
}
@Test
void conFakeStubYSpy() {
RepositorioProductosEnMemoria productos = new RepositorioProductosEnMemoria().con(MONITOR); // fake
RepositorioPedidos pedidos = pedido -> pedido.conId(5001); // stub
NotificadorEspia notificador = new NotificadorEspia(); // spy
Pedido pedido = new ServicioPedidos(productos, pedidos, notificador)
.realizarPedido(1001, 10, 2);
assertThat(pedido.id()).isEqualTo(5001);
assertThat(pedido.importe()).isEqualByComparingTo("658.00");
assertThat(productos.buscarPorId(10)).get().extracting(Producto::stock).isEqualTo(10);
assertThat(notificador.mensajes).containsExactly("1001: Pedido 5001 confirmado: 658.00 €");
}
MONITOR es el “Monitor 27 4K” (329 €, 12 unidades). Un dummy sería, por ejemplo, Notificador dummy = (c, m) -> { throw new AssertionError("no debería usarse"); };, pasado al constructor en una prueba en la que el producto no existe. Con interfaces pequeñas funciona muy bien (y un buen fake se reutiliza en decenas de pruebas), pero si Notificador tuviera diez métodos, el espía tendría que implementarlos todos. Para eso existe Mockito.
8. Mockito
Mockito crea dobles de prueba en tiempo de ejecución: mock(Notificador.class) devuelve un objeto que implementa Notificador, registra todas las llamadas y devuelve lo que tú le digas.
8.1. Crear mocks y programar respuestas (stubbing)
@Test
void mockSinExtension() {
RepositorioProductos productos = mock(RepositorioProductos.class);
// Sin stubbing, un mock devuelve valores "vacíos": Optional.empty(), 0, null, false...
assertThat(productos.buscarPorId(15)).isEmpty();
when(productos.buscarPorId(15)).thenReturn(Optional.of(SILLA));
assertThat(productos.buscarPorId(15)).contains(SILLA);
}
| Programar una respuesta | Efecto |
|---|---|
when(mock.metodo(args)).thenReturn(valor) | Devuelve valor (con thenReturn(a, b, c), uno en cada llamada) |
when(...).thenThrow(excepcion) | Lanza la excepción |
when(...).thenAnswer(inv -> ...) | Calcula la respuesta con los argumentos recibidos (inv.getArgument(0)) |
doThrow(e).when(mock).metodoVoid(...) / doReturn(x).when(espia).metodo(...) | Para métodos void y para espías |
8.2. @Mock, @InjectMocks y MockitoExtension
Lo habitual es activar la extensión de Mockito (artefacto mockito-junit-jupiter) y usar anotaciones: @Mock crea un mock nuevo antes de cada prueba, @Spy un espía, @Captor un ArgumentCaptor e @InjectMocks crea el objeto bajo prueba inyectándole los mocks (por el constructor de más parámetros, por setter o por campo).
@ExtendWith(MockitoExtension.class)
class ServicioPedidosTest {
private static final Producto PORTATIL = new Producto(1, "Portátil UltraBook 14", "Portátiles",
new BigDecimal("899.00"), 25, false);
@Mock
private RepositorioProductos productos;
@Mock
private RepositorioPedidos pedidos;
@Mock
private Notificador notificador;
@InjectMocks // crea el servicio pasando los tres mocks al constructor
private ServicioPedidos servicio;
@Captor
private ArgumentCaptor<Pedido> pedidoCaptor;
@Test
void pedidoCorrectoGuardaYNotifica() {
// Preparar (stubbing)
when(productos.buscarPorId(1)).thenReturn(Optional.of(PORTATIL));
when(pedidos.guardar(any(Pedido.class))).thenAnswer(inv -> inv.<Pedido>getArgument(0).conId(5001));
// Actuar
Pedido resultado = servicio.realizarPedido(1001, 1, 3);
// Comprobar el resultado...
assertThat(resultado.id()).isEqualTo(5001);
// ...y las interacciones
verify(productos).actualizarStock(1, 22);
verify(pedidos, times(1)).guardar(pedidoCaptor.capture());
Pedido guardado = pedidoCaptor.getValue();
assertThat(guardado.id()).isNull(); // el servicio no inventa ids
assertThat(guardado.importe()).isEqualByComparingTo("2697.00");
assertThat(guardado.estado()).isEqualTo(EstadoPedido.PENDIENTE);
verify(notificador).enviar(eq(1001), contains("Pedido 5001 confirmado"));
}
@Test
void sinStockNoGuardaNiNotifica() {
when(productos.buscarPorId(1)).thenReturn(Optional.of(PORTATIL));
assertThatThrownBy(() -> servicio.realizarPedido(1001, 1, 30))
.isInstanceOf(StockInsuficienteException.class)
.hasMessage("Stock insuficiente de Portátil UltraBook 14: pedidas 30, disponibles 25");
verify(productos, never()).actualizarStock(anyInt(), anyInt());
verifyNoInteractions(pedidos, notificador);
}
@Test
void siFallaLaBaseDeDatosNoSeAvisaAlCliente() {
when(productos.buscarPorId(1)).thenReturn(Optional.of(PORTATIL));
when(pedidos.guardar(any())).thenThrow(new IllegalStateException("Conexión perdida"));
assertThatThrownBy(() -> servicio.realizarPedido(1001, 1, 1))
.hasMessage("Conexión perdida");
verify(notificador, never()).enviar(anyInt(), anyString());
}
}
[INFO] Running es.curso.empresa.pedido.ServicioPedidosTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.965 s -- in es.curso.empresa.pedido.ServicioPedidosTest
| Verificación | Comprueba que… |
|---|---|
verify(mock).metodo(args) | se llamó exactamente una vez con esos argumentos (times(1)) |
verify(mock, times(n)), never(), atLeast(n), atMost(n) | número de llamadas |
verifyNoInteractions(m1, m2), verifyNoMoreInteractions(m), inOrder(m1, m2).verify(...) | ninguna llamada / ninguna sin verificar / en ese orden |
Los comparadores (ArgumentMatchers) valen en when y en verify: any(), any(Pedido.class), anyInt(), eq(valor), contains("texto"), argThat(p -> p.cantidad() > 0)… Y con un ArgumentCaptor capturas el argumento real (pedidoCaptor.capture() dentro del verify) para inspeccionarlo después con getValue() (o getAllValues()).
⚠️ Importante: Si usas un comparador en un argumento, todos los de esa llamada deben serlo:
verify(notificador).enviar(1001, contains("Pedido"))lanzaInvalidUseOfMatchersException; lo correcto esenviar(eq(1001), contains("Pedido")).
8.3. Espías
Un espía envuelve un objeto real: por defecto ejecuta los métodos de verdad, pero puedes verificar las llamadas y sobrescribir los que quieras.
@Test
void espiaSobreUnObjetoReal() {
RepositorioProductosEnMemoria real = new RepositorioProductosEnMemoria().con(SILLA);
RepositorioProductosEnMemoria espia = spy(real);
// Por defecto, el espía ejecuta los métodos REALES...
espia.actualizarStock(15, 18);
assertThat(espia.buscarPorId(15)).get().extracting(Producto::stock).isEqualTo(18);
verify(espia).actualizarStock(15, 18);
// ...salvo los que sobrescribimos. Con espías usa doReturn().when()
doReturn(Optional.empty()).when(espia).buscarPorId(15);
assertThat(espia.buscarPorId(15)).isEmpty();
}
⚠️ Importante: Con un espía,
when(espia.buscarPorId(15))ejecuta el método real mientras programas la respuesta (Java evalúa primero el argumento dewhen). Por eso con espías se usadoReturn(...).when(espia).metodo(...). Si necesitas espiar a menudo la propia clase que pruebas, probablemente hace demasiadas cosas.
8.4. Strict stubs y UnnecessaryStubbingException
MockitoExtension trabaja en modo estricto: si programas con when una respuesta que la prueba no llega a usar, la prueba falla. Detecta pruebas mal escritas o código muerto:
@Test
void sinStock() {
when(productos.buscarPorId(8)).thenReturn(Optional.of(PACK_AGOTADO));
when(pedidos.guardar(any())).thenAnswer(inv -> inv.getArgument(0)); // ¡nunca se usa!
assertThatThrownBy(() -> servicio.realizarPedido(1001, 8, 1))
.isInstanceOf(StockInsuficienteException.class);
}
Como no hay stock, el servicio nunca llama a guardar, y Mockito protesta:
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 2.721 s <<< FAILURE! -- in es.curso.empresa.pedido.StubInnecesarioTest
[ERROR] es.curso.empresa.pedido.StubInnecesarioTest.sinStock -- Time elapsed: 2.617 s <<< ERROR!
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
1. -> at es.curso.empresa.pedido.StubInnecesarioTest.sinStock(StubInnecesarioTest.java:30)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
at org.mockito.junit.jupiter.MockitoExtension.afterEach(MockitoExtension.java:198)
Observa que la excepción la lanza MockitoExtension.afterEach: la comprobación se hace al terminar la prueba, cuando Mockito ya sabe qué respuestas se han usado. Y es un Error, no un Failure: el código de la aplicación funciona, lo que está mal es la prueba.
Soluciones, de mejor a peor: borrar el stub que sobra; marcarlo con lenient().when(...) si solo se usa en algunas pruebas (por ejemplo, en un @BeforeEach compartido); o relajar toda la clase con @MockitoSettings(strictness = Strictness.LENIENT).
8.5. Configurar Mockito como agente de Java
Para crear mocks de clases (incluso final), Mockito modifica el bytecode mediante un agente de Java. Hasta hace poco se “autoadjuntaba” ese agente en plena ejecución, pero desde JDK 21 la JVM avisa cuando un agente se carga dinámicamente (JEP 451) y en el futuro lo prohibirá. Sin configurar nada, en JDK 25 ves esto:
[INFO] Running es.curso.empresa.pedido.EspiasMockitoTest
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (/root/.m2/repository/net/bytebuddy/byte-buddy-agent/1.17.7/byte-buddy-agent-1.17.7.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 69.41 s -- in es.curso.empresa.pedido.EspiasMockitoTest
Las pruebas pasan, pero el mensaje es claro: “This will no longer work in future releases of the JDK”.
La solución recomendada es cargar mockito-core como agente al arrancar la JVM de las pruebas. Nuestro pom.xml lo hace en dos pasos: el objetivo properties de maven-dependency-plugin crea la propiedad ${org.mockito:mockito-core:jar} con la ruta del JAR, y Surefire (y Failsafe) la pasan en argLine:
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
⚠️ Importante: El
@{argLine}del principio (con arroba) es imprescindible con JaCoCo:prepare-agentguarda su agente en la propiedadargLine, y la sintaxis@{...}hace que Surefire la evalúe en el último momento, cuando JaCoCo ya la ha rellenado. Si solo pusieras-javaagent:..., machacarías el agente de JaCoCo y el informe saldría vacío. Por eso declaramos también<argLine></argLine>vacío en<properties>: así funciona aunque ejecutes sin JaCoCo (-Djacoco.skip=true).
Con esta configuración el aviso desaparece y el registro de Maven muestra que JaCoCo ha preparado su agente (argLine set to -javaagent:/root/.m2/repository/org/jacoco/org.jacoco.agent/0.8.15/org.jacoco.agent-0.8.15-runtime.jar=destfile=/w/target/jacoco.exec).
💡 Consejo: Con JDK 25 aún verás avisos de
sun.misc.Unsafe::objectFieldOffset(de Byte Buddy, que Mockito usa por dentro) y de Class Data Sharing al espiar clases: no afectan a tus pruebas.
9. BDD: Given-When-Then
BDD (Behaviour-Driven Development) es una evolución de TDD que describe el comportamiento del sistema en un lenguaje que entienda también el cliente, con la plantilla Given (dado un producto con stock…), When (…cuando el cliente lo pide…), Then (…entonces se le avisa). Es el patrón AAA con otros nombres. Herramientas como Cucumber escriben esos escenarios en texto (Gherkin), pero el estilo se aplica directamente en JUnit con BDDMockito, cuyos alias evitan escribir when(...) en la sección Given:
| Mockito clásico | BDDMockito |
|---|---|
when(mock.m()).thenReturn(x) / thenThrow(e) | given(mock.m()).willReturn(x) / willThrow(e) |
verify(mock).m() / verify(mock, never()).m() | then(mock).should().m() / then(mock).should(never()).m() |
verifyNoInteractions(mock) | then(mock).shouldHaveNoInteractions() |
@ExtendWith(MockitoExtension.class)
@DisplayName("Realizar un pedido")
class ServicioPedidosBddTest {
private static final Producto MONITOR = new Producto(9, "Monitor 24 FHD", "Monitores",
new BigDecimal("139.00"), 35, false);
@Mock RepositorioProductos productos;
@Mock RepositorioPedidos pedidos;
@Mock Notificador notificador;
@InjectMocks ServicioPedidos servicio;
@Test
@DisplayName("Dado un producto con stock, cuando el cliente lo pide, entonces se le avisa")
void conStock() {
// Given
given(productos.buscarPorId(9)).willReturn(Optional.of(MONITOR));
given(pedidos.guardar(any())).willAnswer(inv -> inv.<Pedido>getArgument(0).conId(42));
// When
Pedido pedido = servicio.realizarPedido(1003, 9, 2);
// Then
assertThat(pedido.importe()).isEqualByComparingTo("278.00");
then(productos).should().actualizarStock(9, 33);
then(notificador).should().enviar(1003, "Pedido 42 confirmado: 278.00 €");
}
}
[INFO] Running Realizar un pedido
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 8.810 s -- in Realizar un pedido
La segunda prueba (“Dado un producto agotado…”) usa catchThrowable de AssertJ en la sección When (Throwable error = catchThrowable(() -> servicio.realizarPedido(1003, 8, 1));) para que todas las comprobaciones, incluida then(pedidos).shouldHaveNoInteractions(), queden en Then. Los @DisplayName se leen como especificaciones que podría revisar un responsable de negocio.
10. TDD: desarrollo guiado por pruebas
📘 Concepto: En TDD (Test-Driven Development, Kent Beck) escribes la prueba antes que el código, en ciclos muy cortos de tres fases: rojo (una prueba nueva que falla), verde (el código mínimo para que pase) y refactorizar (mejorar el código con las pruebas en verde).
Figura 4. El ciclo rojo-verde-refactorizar de TDD.
¿Por qué al revés? Porque al escribir primero la prueba piensas en cómo se usa la clase antes que en cómo se implementa, obtienes una batería de pruebas completa y no escribes código que nadie necesita. Vamos a verlo con un caso real: la tabla clientes tiene una columna nif y queremos validar los NIF de personas físicas (8 cifras y una letra de control: la del resto de dividir el número entre 23 en la cadena TRWAGMYFPDXBNJZSQVHLCKE).
Paso 1 (rojo): la primera prueba
Escribimos la prueba del caso más sencillo y, para que compile, una clase cuyo método devuelve false:
class ValidadorNifTest {
private final ValidadorNif validador = new ValidadorNif();
@Test
void nifConLetraCorrectaEsValido() {
assertTrue(validador.esValido("12345678Z"));
}
}
public class ValidadorNif {
public boolean esValido(String nif) {
return false; // solo para que compile
}
}
mvn test y… rojo, como esperábamos. Comprobar que la prueba falla es importante: una prueba que nunca ha fallado puede estar mal escrita y no probar nada.
[INFO] Running es.curso.empresa.cliente.ValidadorNifTest
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 3.842 s <<< FAILURE! -- in es.curso.empresa.cliente.ValidadorNifTest
[ERROR] es.curso.empresa.cliente.ValidadorNifTest.nifConLetraCorrectaEsValido -- Time elapsed: 2.542 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
at org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:190)
at es.curso.empresa.cliente.ValidadorNifTest.nifConLetraCorrectaEsValido(ValidadorNifTest.java:13)
[INFO] Results:
[ERROR] Failures:
[ERROR] ValidadorNifTest.nifConLetraCorrectaEsValido:13 expected: <true> but was: <false>
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
Lee el informe: método que falla, línea (ValidadorNifTest.java:13), tipo de error y mensaje expected: <true> but was: <false>. Con BUILD FAILURE, un servidor de integración continua no despliega.
Paso 2 (verde). Lo mínimo para que pase es return true;. Parece trampa, pero es deliberado: no escribimos código que no exija una prueba. Resultado: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 y BUILD SUCCESS.
Paso 3 (rojo): una prueba que obliga a generalizar
@Test
void nifConLetraIncorrectaNoEsValido() {
assertFalse(validador.esValido("12345678A"), "la letra de 12345678 es la Z");
}
[ERROR] es.curso.empresa.cliente.ValidadorNifTest.nifConLetraIncorrectaNoEsValido -- Time elapsed: 1.946 s <<< FAILURE!
org.opentest4j.AssertionFailedError: la letra de 12345678 es la Z ==> expected: <false> but was: <true>
El mensaje que pusimos en la aserción aparece delante de ==>: un buen mensaje ahorra tiempo a quien tenga que arreglar el fallo.
Paso 4 (verde): el algoritmo.
public boolean esValido(String nif) {
int numero = Integer.parseInt(nif.substring(0, 8));
return nif.charAt(8) == "TRWAGMYFPDXBNJZSQVHLCKE".charAt(numero % 23);
}
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0: verde otra vez.
Paso 5 (rojo): datos con formato incorrecto
@Test
void nifConFormatoIncorrectoNoEsValido() {
assertFalse(validador.esValido("1234Z"));
assertFalse(validador.esValido(null));
}
[ERROR] Tests run: 3, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 4.724 s <<< FAILURE! -- in es.curso.empresa.cliente.ValidadorNifTest
[ERROR] es.curso.empresa.cliente.ValidadorNifTest.nifConFormatoIncorrectoNoEsValido -- Time elapsed: 0.323 s <<< ERROR!
java.lang.StringIndexOutOfBoundsException: Range [0, 8) out of bounds for length 5
at java.base/jdk.internal.util.Preconditions$1.apply(Preconditions.java:55)
... (7 líneas más de java.base)
at java.base/java.lang.String.substring(String.java:2899)
at es.curso.empresa.cliente.ValidadorNif.esValido(ValidadorNif.java:6)
at es.curso.empresa.cliente.ValidadorNifTest.nifConFormatoIncorrectoNoEsValido(ValidadorNifTest.java:24)
Para encontrar el origen, busca en la traza la primera línea de tu código: ValidadorNif.esValido(ValidadorNif.java:6), el substring(0, 8) sobre una cadena de 5 caracteres. Las líneas de java.base son la maquinaria interna del JDK.
Esta vez no es un Failure sino un Error: la prueba no llegó a comprobar nada porque el código lanzó una excepción inesperada.
| Resultado | Significa |
|---|---|
| Failure | Una aserción no se cumplió: el código devolvió un valor distinto del esperado |
| Error | Se lanzó una excepción no esperada (el código “explotó”) |
| Skipped | La prueba no se ejecutó (@Disabled o suposición no cumplida) |
Paso 6 (verde). Añadimos al principio if (nif == null || !nif.matches("\\d{8}[A-Z]")) return false; y las tres pruebas pasan (Tests run: 3, Failures: 0).
Paso 7 (rojo): minúsculas y espacios
Los usuarios escriben 12345678z o dejan espacios al copiar y pegar. Nueva regla, nueva prueba:
@Test
void ignoraMinusculasYEspacios() {
assertTrue(validador.esValido(" 12345678z "));
}
[ERROR] Tests run: 4, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 9.156 s <<< FAILURE! -- in es.curso.empresa.cliente.ValidadorNifTest
[ERROR] es.curso.empresa.cliente.ValidadorNifTest.ignoraMinusculasYEspacios -- Time elapsed: 0.625 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
Paso 8 (verde y refactorizar)
Normalizamos la entrada y, ya en verde, refactorizamos: la cadena de letras pasa a una constante y el cálculo a un método con nombre. Las cuatro pruebas garantizan que no rompemos nada:
/** Valida el NIF de una persona física (DNI: 8 cifras + letra de control). */
public class ValidadorNif {
private static final String LETRAS = "TRWAGMYFPDXBNJZSQVHLCKE";
public boolean esValido(String nif) {
if (nif == null) {
return false;
}
String limpio = nif.strip().toUpperCase();
if (!limpio.matches("\\d{8}[A-Z]")) {
return false;
}
int numero = Integer.parseInt(limpio.substring(0, 8));
return limpio.charAt(8) == letraDeControl(numero);
}
private char letraDeControl(int numero) {
return LETRAS.charAt(numero % 23);
}
}
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.013 s -- in es.curso.empresa.cliente.ValidadorNifTest
[INFO] BUILD SUCCESS
💡 Consejo: TDD no es “escribir todas las pruebas primero”: es una prueba cada vez, en ciclos de pocos minutos. Encaja muy bien con reglas de negocio llenas de casos especiales (impuestos, descuentos, validaciones) y peor con la exploración (cuando aún no sabes qué quieres construir) o la interfaz gráfica.
✏️ Ejercicio: Continúa el ciclo con los NIE de extranjeros: empiezan por X, Y o Z (que valen 0, 1 y 2) seguidos de 7 cifras y la letra de control. Escribe primero la prueba para
X1234567L, mírala fallar y luego haz que pase.
11. Cobertura de código con JaCoCo
📘 Concepto: La cobertura mide qué parte del código de producción se ha ejecutado durante las pruebas. JaCoCo (Java Code Coverage) lo consigue con un agente que instrumenta las clases al cargarlas y anota qué instrucciones y ramas se ejecutan; al terminar genera un informe HTML, XML y CSV.
11.1. Tipos de cobertura
| Contador | Qué mide |
|---|---|
Instrucciones (INSTRUCTION) | Instrucciones de bytecode ejecutadas: el más fino, independiente del formato del código |
Ramas (BRANCH) | Si se han recorrido todas las salidas de cada if, switch, &&, \|\| o ?: (if (x > 0) tiene dos) |
Líneas (LINE) | Líneas con al menos una instrucción ejecutada: intuitivo, pero engaña con varias sentencias por línea |
Métodos, clases, complejidad (METHOD, CLASS, COMPLEXITY) | Métodos o clases ejecutados al menos una vez; complejidad ciclomática cubierta |
En el informe cada línea aparece en verde (ejecutada entera), amarillo (ejecutada solo en parte: alguna rama sin recorrer) o rojo (nunca ejecutada), y las líneas con decisiones llevan un rombo que indica cuántas ramas se han cubierto.
11.2. Configuración del plugin
Se añade al <build> del pom.xml del apartado 2.2:
<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></goals></execution>
<execution><id>informe</id><phase>test</phase><goals><goal>report</goal></goals></execution>
<execution>
<id>comprobar-umbral</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>${jacoco.minimo}</minimum></limit>
<limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.70</minimum></limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
| Ejecución | Objetivo | Fase | Qué hace |
|---|---|---|---|
preparar-agente | prepare-agent | initialize | Pone el agente de JaCoCo en la propiedad argLine que usa Surefire |
informe | report | test (por defecto verify) | Lee target/jacoco.exec y genera target/site/jacoco/index.html |
comprobar-umbral | check | verify | Hace fallar la construcción si no se llega a los mínimos |
La regla pide para todo el proyecto (BUNDLE) al menos un 80 % de líneas y un 70 % de ramas. Otros elementos posibles son PACKAGE, CLASS o METHOD; otros valores, MISSEDCOUNT o COVEREDCOUNT; y con <excludes> puedes dejar fuera clases generadas.
11.3. El informe HTML
Tras mvn test, abre target/site/jacoco/index.html en el navegador:

Figura 5. Página principal del informe de JaCoCo del proyecto testing-empresa (captura real).
En total se han ejecutado el 90 % de las instrucciones (faltan 65 de 698) y el 73 % de las ramas (faltan 12 de 46); de las 129 líneas faltan 10 (≈ 92 %), así que se cumplen las dos reglas de check. Los paquetes dao y cliente están al 100 %; nomina y pedido tienen huecos. Pulsando en un paquete y luego en una clase llegas a su código fuente coloreado:

Figura 6. Código fuente coloreado de una clase en el informe de JaCoCo (captura real).
En CalculadoraNominas está todo a la vista: pagaExtra sale en rojo porque ninguna prueba la llama, igual que el throw de trienios (nadie prueba una fecha de alta futura) y el return 2; (ningún bruto baja de 1500 €). Las líneas 21, 30 y 42 están en amarillo, con un rombo amarillo: tienen alguna rama sin recorrer (por ejemplo, nunca llega un salario null). Es justo la lista de pruebas que faltan; el ejercicio 12 te pide completarla.
11.4. Hacer fallar la construcción con check
check se ejecuta en mvn verify. Para ver qué pasa cuando no se llega al umbral, lo subimos al 95 % de líneas desde la línea de órdenes (por eso es la propiedad jacoco.minimo; -DskipITs omite las pruebas de Testcontainers):
mvn verify -DskipITs -Djacoco.minimo=0.95
[INFO] Tests run: 88, Failures: 0, Errors: 0, Skipped: 2
[INFO] --- jacoco:0.8.15:check (comprobar-umbral) @ testing-empresa ---
[INFO] Analyzed bundle 'testing-empresa' with 11 classes
[WARNING] Rule violated for bundle testing-empresa: lines covered ratio is 0.92, but expected minimum is 0.95
[INFO] BUILD FAILURE
[ERROR] Failed to execute goal org.jacoco:jacoco-maven-plugin:0.8.15:check (comprobar-umbral) on project testing-empresa: Coverage checks have not been met. See log for details. -> [Help 1]
Las 88 pruebas pasan, pero la construcción falla: el 92 % de líneas no llega al 95 % exigido. Con el umbral normal (80 %), mvn verify termina en BUILD SUCCESS.
⚠️ Importante: La cobertura dice qué código se ha ejecutado, no qué código se ha comprobado: una prueba sin aserciones que llame a todos los métodos da un 100 % y no prueba nada. Úsala como linterna para encontrar zonas sin probar (sobre todo ramas de error en rojo), no como objetivo. Muchos equipos fijan umbrales entre el 70 y el 85 %; exigir un 100 % suele producir pruebas de relleno.
12. Pruebas de integración con base de datos
Las pruebas con mocks son rápidas, pero no detectan un nombre de columna mal escrito, un ORDER BY que no ordena como crees o una función SQL que no existe. Para eso necesitamos pruebas de integración contra una base de datos de verdad. Probaremos un DAO (Data Access Object) que consulta la tabla empleados con JDBC:
/** Acceso a la tabla EMPLEADOS con JDBC (lo verás a fondo en el módulo 12). */
public class EmpleadoDao {
private final String url;
private final String usuario;
private final String clave;
public EmpleadoDao(String url, String usuario, String clave) {
this.url = url;
this.usuario = usuario;
this.clave = clave;
}
private Connection conectar() throws SQLException {
return DriverManager.getConnection(url, usuario, clave);
}
// nombresPorDepartamento(deptId): "SELECT nombre FROM empleados WHERE dept_id = ? ORDER BY apellidos"
// recorre el ResultSet y devuelve una List<String>
// masaSalarial(deptId): "SELECT COALESCE(SUM(salario), 0) FROM empleados WHERE dept_id = ?"
/** Sube el salario un porcentaje a todo un departamento; devuelve las filas cambiadas. */
public int subirSalario(int deptId, BigDecimal porcentaje) throws SQLException {
String sql = "UPDATE empleados SET salario = ROUND(salario * (100 + ?) / 100, 2) WHERE dept_id = ?";
try (Connection con = conectar();
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setBigDecimal(1, porcentaje);
ps.setInt(2, deptId);
return ps.executeUpdate();
}
}
}
⚠️ Importante: No te preocupes si aún no dominas
DriverManager,PreparedStatementoResultSet: el módulo 12 está dedicado a JDBC. Basta con saber quegetConnection(url, usuario, clave)abre una conexión, que los?se rellenan consetInt/setBigDecimaly que try-with-resources lo cierra todo. Como el DAO recibe la URL en el constructor, la misma clase sirve para el MySQL de producción (jdbc:mysql://localhost:3306/empresa), para H2 o para un contenedor de Testcontainers.
12.1. H2 en memoria
H2 es una base de datos escrita en Java que puede vivir en la memoria de la JVM de las pruebas: no hay que instalar nada, arranca en milisegundos y desaparece al terminar. Tiene modos de compatibilidad, entre ellos MySQL. En src/test/resources/h2/esquema.sql copiamos tal cual los CREATE TABLE departamentos y empleados de empresa_mysql_crear.sql (salvo la restricción ck_emp_comision), y en h2/datos.sql unas pocas filas de empresa_mysql_datos.sql: los departamentos 10, 20 y 70 y los empleados 100, 101, 110, 111 y 112. La prueba recrea la base de datos antes de cada método para que ninguna dependa de otra (mostramos dos de sus tres pruebas; la tercera comprueba que el departamento 70 no tiene empleados):
@Tag("integracion")
class EmpleadoDaoH2Test {
private static final String URL =
"jdbc:h2:mem:empresa;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1";
private final EmpleadoDao dao = new EmpleadoDao(URL, "sa", "");
@BeforeEach
void crearBaseDeDatos() throws SQLException {
try (Connection con = DriverManager.getConnection(URL, "sa", "");
Statement st = con.createStatement()) {
st.execute("DROP ALL OBJECTS");
st.execute("RUNSCRIPT FROM 'classpath:h2/esquema.sql'");
st.execute("RUNSCRIPT FROM 'classpath:h2/datos.sql'");
}
}
@Test
void nombresDeVentasOrdenadosPorApellidos() throws SQLException {
assertThat(dao.nombresPorDepartamento(20))
.containsExactly("Luis", "Elena", "Carmen", "David");
}
@Test
void subidaDeSueldoEnVentas() throws SQLException {
int filas = dao.subirSalario(20, new BigDecimal("10"));
assertThat(filas).isEqualTo(4);
// 4800 + 2200 + 2100 + 2300 = 11400 -> +10 % = 12540
assertThat(dao.masaSalarial(20)).isEqualByComparingTo("12540.00");
assertThat(dao.masaSalarial(10)).isEqualByComparingTo("6500"); // no cambia
}
}
En la URL, jdbc:h2:mem:empresa es una base de datos en memoria llamada empresa; MODE=MySQL imita la sintaxis de MySQL (AUTO_INCREMENT…); DATABASE_TO_LOWER=TRUE pone los nombres en minúsculas, como MySQL en Linux; y DB_CLOSE_DELAY=-1 la mantiene viva aunque se cierren todas las conexiones.
Un fallo real: H2 no es MySQL
La primera versión de subirSalario usaba ROUND(salario * (1 + ? / 100), 2), perfectamente válida en MySQL, y la prueba con H2 falló así:
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 70.71 s <<< FAILURE! -- in es.curso.empresa.dao.EmpleadoDaoH2Test
[ERROR] es.curso.empresa.dao.EmpleadoDaoH2Test.subidaDeSueldoEnVentas -- Time elapsed: 68.14 s <<< FAILURE!
org.opentest4j.AssertionFailedError:
expected: 12540.00
but was: 11400.00
at es.curso.empresa.dao.EmpleadoDaoH2Test.subidaDeSueldoEnVentas(EmpleadoDaoH2Test.java:54)
¡Los salarios no habían cambiado! En MySQL el operador / siempre hace una división decimal (10 / 100 = 0.1), pero H2 asigna al parámetro ? un tipo entero deducido de la expresión ? / 100 y hace una división entera (10 / 100 = 0), así que multiplicaba por 1 + 0. La versión corregida, salario * (100 + ?) / 100, parte de una columna DECIMAL y funciona igual en los dos gestores.
⚠️ Importante: Esa es la gran limitación de H2: el modo MySQL imita muchas cosas, pero no es MySQL. Conversiones de tipos, collations (cómo se ordenan las tildes, mayúsculas), funciones específicas,
JSON… pueden comportarse distinto. Para estar seguros necesitamos el MySQL de verdad.
12.2. Testcontainers: MySQL real en un contenedor
Testcontainers arranca contenedores Docker desde tus pruebas (bases de datos, colas de mensajes, navegadores…) y los elimina al terminar: tus pruebas usan exactamente el mismo MySQL 8.4 que producción, sin instalar nada y sin tocar el MySQL de tu equipo. Solo necesitas Docker funcionando (Docker Desktop en Windows y macOS).
Figura 7. Qué hace Testcontainers durante una prueba de integración.
📘 Concepto: En Testcontainers 2.x los artefactos empiezan por
testcontainers-(org.testcontainers:testcontainers-mysqlen lugar deorg.testcontainers:mysql) y la clase esorg.testcontainers.mysql.MySQLContainer(antesorg.testcontainers.containers.MySQLContainer, genérica). Tenlo en cuenta si copias ejemplos antiguos.
Podemos cargar los mismos scripts del curso: copiamos empresa_mysql_crear.sql y empresa_mysql_datos.sql en src/test/resources/sql/ y los dejamos en /docker-entrypoint-initdb.d/ del contenedor: la imagen oficial de MySQL ejecuta, en orden alfabético, los .sql de esa carpeta la primera vez que arranca.
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.mysql.MySQLContainer;
import org.testcontainers.utility.MountableFile;
@Testcontainers // arranca y para los campos @Container
class EmpleadoDaoMySqlIT {
// static: un único contenedor para todas las pruebas de la clase
@Container
static final MySQLContainer MYSQL = new MySQLContainer("mysql:8.4")
.withDatabaseName("empresa")
.withUsername("curso")
.withPassword("curso")
.withStartupTimeoutSeconds(600) // en equipos lentos o de CI, cargar los scripts tarda
// La imagen oficial ejecuta en orden alfabético los .sql de esta carpeta
.withCopyFileToContainer(MountableFile.forClasspathResource("sql/empresa_mysql_crear.sql"),
"/docker-entrypoint-initdb.d/1-crear.sql")
.withCopyFileToContainer(MountableFile.forClasspathResource("sql/empresa_mysql_datos.sql"),
"/docker-entrypoint-initdb.d/2-datos.sql");
private EmpleadoDao dao;
@BeforeEach
void setUp() {
System.out.println("URL JDBC del contenedor: " + MYSQL.getJdbcUrl());
dao = new EmpleadoDao(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword());
}
@Test
void empleadosDeInformatica() throws SQLException {
assertThat(dao.nombresPorDepartamento(30))
.hasSize(6)
.startsWith("Iván")
.contains("Marta", "Pablo", "Raquel");
}
@Test
void masaSalarialDeVentas() throws SQLException {
// 4800 + 2200 + 2100 + 2300 + 1900 + 1700
assertThat(dao.masaSalarial(20)).isEqualByComparingTo("15000.00");
}
@Test
void subidaDeSueldoEnContabilidad() throws SQLException {
// Cambia datos del contenedor compartido: usamos un departamento que no miran las demás
assertThat(dao.subirSalario(40, new BigDecimal("2"))).isEqualTo(3);
// (4200 + 2600 + 1650) * 1,02 = 8619
assertThat(dao.masaSalarial(40)).isEqualByComparingTo("8619.00");
}
}
| Elemento | Qué hace |
|---|---|
@Container en un campo static | Un contenedor para toda la clase: se arranca antes de la primera prueba y se para al final (en un campo de instancia, uno por prueba) |
new MySQLContainer("mysql:8.4") | Imagen y versión: la misma que producción |
withDatabaseName, withUsername, withPassword, withCopyFileToContainer | Crean la base de datos y el usuario y copian ficheros (aquí, del classpath) al contenedor |
getJdbcUrl() | URL con el puerto real: el 3306 se publica en un puerto libre aleatorio |
Como la clase termina en IT, la ejecuta Failsafe en mvn verify:
[INFO] --- failsafe:3.6.0:integration-test (default) @ testing-empresa ---
[INFO] Running es.curso.empresa.dao.EmpleadoDaoMySqlIT
URL JDBC del contenedor: jdbc:mysql://host.docker.internal:51550/empresa
URL JDBC del contenedor: jdbc:mysql://host.docker.internal:51550/empresa
URL JDBC del contenedor: jdbc:mysql://host.docker.internal:51550/empresa
URL JDBC del contenedor: jdbc:mysql://host.docker.internal:51550/empresa
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 466.5 s -- in es.curso.empresa.dao.EmpleadoDaoMySqlIT
[INFO] BUILD SUCCESS
Las cuatro pruebas (la cuarta, no mostrada, comprueba que el departamento 70 no tiene empleados) comparten el mismo contenedor y su puerto aleatorio, el 51550. Aquí el host es host.docker.internal porque Maven se ejecutaba a su vez en un contenedor (con el socket de Docker montado y TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal); en tu equipo verás localhost. Los 466 s son de un equipo muy cargado (en uno normal tarda mucho menos, salvo la primera vez, que descarga la imagen). En ese equipo, con el tiempo de arranque por defecto (120 s), la prueba falló con ContainerLaunchException: Container startup failed for image mysql:8.4 (Communications link failure): por eso añadimos withStartupTimeoutSeconds(600).
⚠️ Importante: El contenedor es compartido por toda la clase, así que los cambios de una prueba los ven las demás:
subidaDeSueldoEnContabilidadmodifica un departamento (el 40) que no consulta ninguna otra. En proyectos grandes se usan transacciones que se deshacen al final (módulo 13), recargas de datos en@BeforeEacho un contenedor por prueba.
💡 Consejo: La primera ejecución descarga la imagen
mysql:8.4y tarda más. Si en tu equipo no hay Docker, ejecuta solomvn testo usa@Testcontainers(disabledWithoutDocker = true). ¿H2 o Testcontainers? H2 no necesita nada y es rapidísimo, pero solo aproxima MySQL; Testcontainers tarda unos segundos en arrancar y es fiel al 100 %. Muchos equipos usan Testcontainers para todas las pruebas de base de datos; lo verás de nuevo con Spring Boot en el módulo 23.
13. Buenas prácticas y errores comunes
| Error común | Solución |
|---|---|
| Una prueba gigante que comprueba diez cosas | Una prueba por comportamiento, con un nombre que lo describa (sinStockNoGuardaNiNotifica) |
| Pruebas que dependen del orden o comparten estado | Datos nuevos en @BeforeEach; nada de campos static modificables |
Usar LocalDate.now(), Random o la red en pruebas unitarias | Pasa la fecha como parámetro o inyecta un Clock; fija la semilla; usa dobles |
Comparar BigDecimal con assertEquals sin cuidar la escala | isEqualByComparingTo de AssertJ o redondear siempre con setScale |
Mocks de todo, incluidos records, listas o String | Mockea solo dependencias con efectos (repositorios, notificadores); el resto, objetos reales |
Mockear clases que no son tuyas (Connection, clientes HTTP) | Envuélvelas en una interfaz tuya o usa una prueba de integración |
Verificar cada llamada interna con verify | Verifica solo los efectos importantes para el negocio |
| Perseguir el 100 % de cobertura | Usa la cobertura para encontrar huecos, no como objetivo |
💡 Consejo: Trata el código de prueba como el de producción: nombres claros, sin duplicación (métodos de ayuda, fakes reutilizables) y revisado en las pull requests. Las pruebas son la documentación ejecutable de cómo debe comportarse tu aplicación.
14. Ejercicios
Los ejercicios usan las clases del proyecto testing-empresa. Los seis primeros tienen la solución plegada (se han ejecutado con mvn test y pasan); los seis últimos son para que los resuelvas por tu cuenta.
Ejercicio 1: Validaciones del carrito con JUnit puro
Escribe CarritoValidacionesTest usando solo aserciones de JUnit y una clase @Nested “al añadir productos” que compruebe que el carrito rechaza cantidades negativas (con el mensaje exacto) y acumula unidades e importe al añadir dos veces la “Webcam HD” de 45 €.
Ver solución
@DisplayName("Carrito")
class CarritoValidacionesTest {
private static final Producto WEBCAM = new Producto(17, "Webcam HD", "Periféricos",
new BigDecimal("45.00"), 30, false);
private Carrito carrito;
@BeforeEach
void setUp() {
carrito = new Carrito();
}
@Nested
@DisplayName("al añadir productos")
class AlAnadir {
@Test
void cantidadNoPositiva() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> carrito.agregar(WEBCAM, -2));
assertEquals("La cantidad debe ser positiva: -2", ex.getMessage());
}
@Test
void acumula() {
carrito.agregar(WEBCAM, 2);
carrito.agregar(WEBCAM, 1);
assertAll(() -> assertEquals(3, carrito.unidades()),
() -> assertEquals(new BigDecimal("135.00"), carrito.total()));
}
}
}
El @BeforeEach de la clase exterior también se ejecuta antes de las pruebas @Nested, así que cada una recibe un carrito vacío. Esperamos "135.00" y no "135" porque total() redondea a dos decimales y BigDecimal.equals tiene en cuenta la escala.
Ejercicio 2: Descuentos con @CsvSource y @EnumSource
Prueba Carrito.descuentoAplicado() con un @ParameterizedTest y @CsvSource (precio, cantidad, cupón opcional y descuento esperado) que incluya los valores límite del descuento por volumen (999,99 € y 1000 €). (En el proyecto, la misma clase prueba además EstadoPedido.esFinal() con @EnumSource(..., mode = EnumSource.Mode.EXCLUDE).)
Ver solución
@ParameterizedTest(name = "{1} x {0} € con cupón [{2}] -> {3} %")
@CsvSource({
// precio, cantidad, cupón, descuento
"19.90, 5, , 0", // vacío sin comillas = null
"999.99, 1, , 0", // justo por debajo del umbral
"1000.00, 1, , 5", // justo en el umbral
"499.00, 3, BIENVENIDA10, 10", // gana el cupón
"34.50, 1, VERANO15, 15"
})
void descuentoAplicado(BigDecimal precio, int cantidad, String cupon, int esperado) {
Carrito carrito = new Carrito();
carrito.agregar(new Producto(1, "Producto", "Pruebas", precio, 100, false), cantidad);
if (cupon != null) {
carrito.aplicarCupon(cupon);
}
assertEquals(esperado, carrito.descuentoAplicado());
}
La columna del cupón vacía y sin comillas llega como null, lo que permite probar el caso “sin cupón” en la misma tabla. Las filas 2 y 3 son valores límite: los errores se concentran en las fronteras (> en lugar de >=), así que conviene probar justo antes y justo en el umbral.
Ejercicio 3: La plantilla de Ventas con AssertJ
Con los seis empleados del departamento 20 (VENTAS) de EMPRESA, comprueba con AssertJ que los vendedores con comisión mayor de 300 € son exactamente Carmen (500) y David (800), verificando nombre y comisión con tuple.
Ver solución
private static Empleado emp(int id, String nombre, String oficio, String salario, String comision) {
return new Empleado(id, nombre, oficio, new BigDecimal(salario),
comision == null ? null : new BigDecimal(comision), LocalDate.of(2020, 1, 1));
}
// Departamento 20 (VENTAS) de la base de datos EMPRESA
private final List<Empleado> ventas = List.of(
emp(101, "Luis", "DIRECTOR", "4800", null), emp(110, "Carmen", "VENDEDOR", "2200", "500"),
emp(111, "David", "VENDEDOR", "2100", "800"), emp(112, "Elena", "VENDEDOR", "2300", "300"),
emp(113, "Sergio", "VENDEDOR", "1900", "0"), emp(114, "Lucía", "ADMINISTRATIVO", "1700", null));
@Test
void vendedoresConComisionAlta() {
assertThat(ventas)
.filteredOn(e -> e.oficio().equals("VENDEDOR"))
.filteredOn(e -> e.comision().compareTo(new BigDecimal("300")) > 0)
.extracting(Empleado::nombre, Empleado::comision)
.containsExactly(tuple("Carmen", new BigDecimal("500")),
tuple("David", new BigDecimal("800")));
}
El primer filteredOn es necesario porque los que no son vendedores tienen comisión null y el compareTo lanzaría NullPointerException. tuple(...) compara con equals, así que la escala del BigDecimal debe coincidir con la de la lista ("500", sin decimales).
Ejercicio 4: Producto inexistente y mensaje al cliente con Mockito
Con MockitoExtension, @Mock e @InjectMocks, prueba que: (a) si no existe el producto 99 se lanza NoSuchElementException con el mensaje “No existe el producto 99” y no se toca ni el repositorio de pedidos ni el notificador; (b) al pedir 4 sillas ergonómicas (249 €), con el pedido guardado con id 3005, el mensaje que recibe el cliente 1005 (captúralo con ArgumentCaptor<String>) empieza por “Pedido 3005” y termina en “996.00 €”.
Ver solución
@ExtendWith(MockitoExtension.class)
class ServicioPedidosEjercicioTest {
@Mock RepositorioProductos productos;
@Mock RepositorioPedidos pedidos;
@Mock Notificador notificador;
@InjectMocks ServicioPedidos servicio;
@Test
void productoInexistente() {
when(productos.buscarPorId(99)).thenReturn(Optional.empty());
assertThatThrownBy(() -> servicio.realizarPedido(1001, 99, 1))
.isInstanceOf(NoSuchElementException.class)
.hasMessage("No existe el producto 99");
verify(productos, never()).actualizarStock(anyInt(), anyInt());
verifyNoInteractions(pedidos, notificador);
}
@Test
void mensajeAlCliente() {
Producto silla = new Producto(15, "Silla Ergonómica", "Mobiliario",
new BigDecimal("249.00"), 20, false);
when(productos.buscarPorId(15)).thenReturn(Optional.of(silla));
when(pedidos.guardar(any())).thenAnswer(inv -> inv.<Pedido>getArgument(0).conId(3005));
servicio.realizarPedido(1005, 15, 4);
ArgumentCaptor<String> mensaje = ArgumentCaptor.forClass(String.class);
verify(notificador).enviar(eq(1005), mensaje.capture());
assertThat(mensaje.getValue())
.startsWith("Pedido 3005")
.endsWith("996.00 €");
}
}
En la primera prueba el when no es imprescindible (un mock sin programar ya devuelve Optional.empty()), pero deja explícito el escenario. En enviar(eq(1005), mensaje.capture()) hay que envolver el 1005 en eq(...) porque capture() también es un comparador.
Ejercicio 5: Una extensión que inyecta un catálogo
Escribe una extensión CatalogoExtension (ParameterResolver) que inyecte en los parámetros de tipo RepositorioProductosEnMemoria un repositorio nuevo con tres productos de EMPRESA (ids 1, 8 y 14), y una prueba que la use.
Ver solución
/** Inyecta en las pruebas un repositorio con productos reales de EMPRESA. */
public class CatalogoExtension implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext parametro, ExtensionContext contexto) {
return parametro.getParameter().getType() == RepositorioProductosEnMemoria.class;
}
@Override
public Object resolveParameter(ParameterContext parametro, ExtensionContext contexto) {
return new RepositorioProductosEnMemoria()
.con(new Producto(1, "Portátil UltraBook 14", "Portátiles", new BigDecimal("899.00"), 25, false))
.con(new Producto(8, "Pack teclado y ratón", "Ratones y teclados", new BigDecimal("34.50"), 0, false))
.con(new Producto(14, "Altavoz Inteligente", "Audio", new BigDecimal("49.00"), 0, true));
}
}
@ExtendWith(CatalogoExtension.class)
class CatalogoExtensionTest {
@Test
void elCatalogoTraeTresProductos(RepositorioProductosEnMemoria catalogo) {
assertThat(catalogo.buscarPorId(1)).map(Producto::nombre).contains("Portátil UltraBook 14");
assertThat(catalogo.buscarPorId(99)).isEmpty();
}
}
supportsParameter dice a JUnit qué parámetros sabe resolver la extensión y resolveParameter crea el valor. Como devolvemos un objeto nuevo en cada llamada, las pruebas no comparten estado. Es el mecanismo con el que JUnit inyecta TestInfo, RepetitionInfo o el Path de @TempDir.
Ejercicio 6: Un nuevo método del DAO probado con H2
Añade a EmpleadoDao el método int contarPorOficio(String oficio), que cuente los empleados con ese oficio sin distinguir mayúsculas, y pruébalo con H2 (mismos scripts y mismo @BeforeEach que EmpleadoDaoH2Test, pero con la URL jdbc:h2:mem:oficios;...) con estos casos: VENDEDOR → 3, vendedor → 3, DIRECTOR → 1, DIRECTOR GENERAL → 1, BECARIO → 0.
Ver solución
public int contarPorOficio(String oficio) throws SQLException {
String sql = "SELECT COUNT(*) FROM empleados WHERE UPPER(oficio) = UPPER(?)";
try (Connection con = conectar();
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setString(1, oficio);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getInt(1);
}
}
}
@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({"VENDEDOR, 3", "vendedor, 3", "DIRECTOR, 1", "DIRECTOR GENERAL, 1", "BECARIO, 0"})
void cuentaPorOficio(String oficio, int esperado) throws SQLException {
assertThat(dao.contarPorOficio(oficio)).isEqualTo(esperado);
}
COUNT(*) siempre devuelve una fila, así que podemos llamar a rs.next() sin comprobar el resultado. UPPER en los dos lados hace la comparación insensible a mayúsculas en cualquier gestor: en MySQL no haría falta (su collation por defecto ya lo es), pero en H2 sí. Usamos otra base de datos en memoria (oficios) para no interferir con EmpleadoDaoH2Test.
Ejercicio 7: Nóminas desde un CSV (propuesto)
Crea el fichero src/test/resources/nominas.csv con todos los empleados del departamento 30 (INFORMÁTICA) de empresa_mysql_datos.sql: nombre, salario, comisión, fecha de alta y neto esperado a fecha 2026-10-04, calculado a mano. Escribe una prueba con @CsvFileSource para CalculadoraNominas.salarioNeto y usa name para que cada caso muestre el nombre del empleado. ¿Hay algún caso en el que tu cálculo a mano no coincida? ¿Quién tenía razón?
Ejercicio 8: @Nested y BDD para el servicio de pedidos (propuesto)
Reescribe las pruebas de ServicioPedidos en una sola clase con estilo BDD (given/then de BDDMockito) y tres clases @Nested: “con stock suficiente”, “sin stock” y “cuando falla el repositorio de pedidos”. Cada @Nested debe tener su propio @BeforeEach con el Given común. Comprueba que no aparece ninguna UnnecessaryStubbingException; si aparece, decide si debes quitar el stub o marcarlo como lenient().
Ejercicio 9: Una extensión que avisa de los fallos (propuesto)
Implementa una extensión InformeFallosExtension con la interfaz TestWatcher que, cada vez que una prueba falle (testFailed), añada una línea a target/fallos.txt con la fecha y hora, el nombre visible de la prueba y el mensaje de la excepción. Regístrala con @RegisterExtension en una clase que tenga una prueba que falle a propósito. Pista: ExtensionContext.getDisplayName() y Files.writeString(..., StandardOpenOption.CREATE, StandardOpenOption.APPEND).
Ejercicio 10: TDD de la fecha de envío (propuesto)
Siguiendo TDD estricto (una prueba cada vez, mirándola fallar), desarrolla una clase CalculadoraEnvio con el método LocalDate fechaEnvioPrevista(LocalDate fechaPedido) que cumpla estas reglas: los pedidos se envían dos días hábiles después; sábados y domingos no son hábiles; un pedido hecho en sábado o domingo cuenta como hecho el lunes. Anota en un fichero de texto la secuencia de pruebas que has escrito y las salidas rojo/verde de cada paso.
Ejercicio 11: Productos con poco stock en MySQL (propuesto)
Crea un ProductoDao con el método List<String> nombresConStockMenorQue(int umbral) (solo productos no descatalogados, ordenados por stock y nombre) y pruébalo con Testcontainers cargando los scripts de EMPRESA. Con umbral 10, ¿qué productos devuelve? Escribe una segunda prueba que inserte un producto con PreparedStatement y compruebe que aparece. Ejecútalo con mvn verify y explica por qué el nombre de la clase debe terminar en IT.
Ejercicio 12: Subir la cobertura de ramas (propuesto)
Ejecuta mvn test, abre el informe de JaCoCo y localiza las ramas amarillas y rojas de los paquetes pedido y nomina. Añade las pruebas que falten hasta que todas las ramas de Carrito y CalculadoraNominas estén cubiertas. Después añade al pom.xml una segunda regla de check con <element>CLASS</element> que exija un 90 % de ramas en cada clase, excluyendo los records con <excludes>. ¿Qué clases incumplen la regla?
Resumen
| Concepto | Sintaxis / herramienta | Para qué |
|---|---|---|
| Pirámide de pruebas, AAA | Unitarias > integración > E2E; preparar, actuar, comprobar | Equilibrar velocidad, coste y confianza |
| Ciclo de vida | @BeforeAll, @BeforeEach, @AfterEach, @AfterAll, @TestInstance(PER_CLASS) | Preparar y limpiar recursos |
| Organización y filtrado | @DisplayName, @Nested, @Disabled, @Tag + -Dgroups, -DexcludedGroups, -Dtest | Pruebas legibles, agrupadas y filtrables |
| Repetir, limitar, suponer | @RepeatedTest, @Timeout, assertTimeout, assumeTrue, assumingThat | Casos aleatorios, tiempos, condiciones |
| Parametrizadas | @ParameterizedTest + @ValueSource, @NullAndEmptySource, @EnumSource, @CsvSource, @CsvFileSource, @MethodSource | Misma prueba, muchos datos |
| Aserciones JUnit | assertEquals, assertThrows, assertAll, assertTimeout, assertInstanceOf | Comprobaciones básicas |
| Extensiones | @ExtendWith, BeforeTestExecutionCallback, ParameterResolver, ExtensionContext.Store, @TempDir, @DefaultLocale | Comportamiento reutilizable |
| AssertJ | assertThat(x)..., isEqualByComparingTo, extracting, filteredOn, containsExactly, tuple | Aserciones fluidas y colecciones |
| AssertJ: errores y suaves | assertThatThrownBy(...), catchThrowable, SoftAssertions.assertSoftly | Excepciones; ver todos los fallos a la vez |
| Dobles de prueba | Dummy, stub, spy, mock, fake | Aislar la clase bajo prueba |
| Mockito | @Mock, @InjectMocks, MockitoExtension, when(...).thenReturn/thenThrow/thenAnswer, spy, doReturn | Crear y programar dobles |
| Verificación | verify(m, times(n) \| never()), verifyNoInteractions, inOrder, ArgumentCaptor | Comprobar interacciones |
| Strict stubs y agente | UnnecessaryStubbingException, lenient(); argLine @{argLine} -javaagent:${org.mockito:mockito-core:jar} | Pruebas limpias; sin autoadjunción (JDK 21+) |
| BDD y TDD | given(...).willReturn(...), then(m).should(); rojo → verde → refactorizar | Especificar y diseñar guiado por pruebas |
| Cobertura | JaCoCo prepare-agent, report, check; instrucciones, ramas, líneas | Encontrar código sin probar |
| Integración | H2 jdbc:h2:mem:empresa;MODE=MySQL; @Testcontainers, MySQLContainer("mysql:8.4"), getJdbcUrl() | BD rápida en memoria o MySQL real |
| Surefire / Failsafe | *Test en mvn test, *IT en mvn verify | Separar pruebas rápidas y lentas |