Pruebas y calidad de software
Pruebas unitarias en Java con JUnit 5 y Mockito 5
Principiante8 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Un recorrido práctico por el conjunto de herramientas de pruebas unitarias en Java tal como está hoy: el modelo de programación de JUnit Jupiter, las aserciones fluidas de AssertJ, los cinco tipos de doble de prueba y cómo los implementa Mockito. Termina con las decisiones más difíciles: probar el tiempo, probar excepciones y decidir qué no se debe mockear nunca.
Lo que aprenderás
- Estructurar una clase de test de Jupiter con las anotaciones de ciclo de vida adecuadas y un nombre que exprese el comportamiento
- Escribir aserciones con assertAll, assertThrows y la API fluida de AssertJ en lugar de un montón de llamadas a assertEquals
- Sustituir tests repetitivos por @ParameterizedTest y organizar los casos con @Nested
- Distinguir entre dummy, stub, spy, mock y fake, y elegir el más barato que funcione
- Crear stubs, verificar llamadas y capturar argumentos con Mockito 5 sin sobreespecificar la implementación
Un test unitario es una comprobación pequeña, rápida y determinista de un comportamiento concreto, aislada de todo lo que sea lo bastante lento como para notarse. Es el único tipo de test que un desarrollador ejecuta de buena gana decenas de veces por hora, y eso es lo que lo convierte en la base de todo lo demás: refactorizar es seguro en la medida en que puedas descubrir rápido que has roto algo.
Las herramientas de Java para esto llevan años estables (JUnit Jupiter para la estructura, AssertJ para las aserciones, Mockito para los dobles de prueba), pero los detalles han cambiado. JUnit 5.x y la línea JUnit 6 comparten el modelo de programación de Jupiter sobre Java 17 o superior; Mockito 5 usa por defecto el inline mock maker, así que mockear clases final y métodos estáticos ya no requiere una dependencia adicional. Los ejemplos de esta guía dan por hecho esas versiones y un build que lo coloca todo bajo src/test/java.
Qué convierte un test en unitario
Hay dos propiedades que importan más que cualquier definición de «unidad». Un test unitario debe ser rápido: una suite de mil tests debería terminar en segundos, así que nada puede tocar la red, una base de datos real o el reloj de forma que lo obligue a esperar. Y debe estar aislado: ejecutarlo solo, en otro orden o en paralelo con sus vecinos tiene que dar el mismo resultado. Cuando esto falla, los tres culpables habituales son el estado estático compartido, los archivos escritos en una ruta fija y una base de datos que el test anterior dejó sucia.
Todo lo demás es negociable. Un test que construye tres value objects que colaboran entre sí sigue siendo un test unitario en cualquier sentido útil. Un test que mockea las seis dependencias de un servicio hasta el punto de verificar solo sus propios stubs no es un buen test, diga lo que diga la clasificación.
El modelo de programación de Jupiter
class InvoiceTotalTest {
private Invoice invoice;
@BeforeEach
void newInvoice() {
invoice = new Invoice("INV-1", Currency.getInstance("EUR"));
}
@Test
@DisplayName("an empty invoice totals zero in its own currency")
void emptyInvoiceTotalsZero() {
assertThat(invoice.total()).isEqualTo(Money.zero("EUR"));
}
@Test
void lineTotalsAreSummed() {
invoice.add(new Line("Consulting", 3, Money.of("450.00", "EUR")));
invoice.add(new Line("Licence", 1, Money.of("120.00", "EUR")));
assertThat(invoice.total()).isEqualTo(Money.of("1470.00", "EUR"));
}
}
En Jupiter, las clases y métodos de test no necesitan el modificador public, y no hay runner: el motor descubre directamente los métodos @Test. @BeforeEach y @AfterEach se ejecutan alrededor de cada test; @BeforeAll y @AfterAll se ejecutan una vez por clase y deben ser static, salvo que la clase esté anotada con @TestInstance(Lifecycle.PER_CLASS). Por defecto se crea una instancia nueva de la clase de test para cada método de test, y eso es lo que impide que un test filtre estado al siguiente: resiste la tentación de cambiarlo por comodidad.
@DisplayName aporta una etiqueta legible para los informes; @Disabled("reason") omite un test y, a diferencia de comentarlo, deja un rastro visible. @Tag("slow") agrupa tests para que el build pueda incluirlos o excluirlos, y así un mismo árbol de código fuente puede servir tanto para una ejecución rápida antes del commit como para una ejecución más completa en el pipeline.
Aserciones que se leen bien
Las aserciones de Jupiter son suficientes y siempre están disponibles. Merece la pena conocer tres de ellas incluso en una base de código que use AssertJ:
assertAll("customer",
() -> assertEquals("Ada", customer.firstName()),
() -> assertEquals("Lovelace", customer.lastName()),
() -> assertTrue(customer.isActive()));
IllegalStateException thrown = assertThrows(IllegalStateException.class,
() -> account.withdraw(Money.of("500.00", "EUR")));
assertEquals("INSUFFICIENT_FUNDS", thrown.getErrorCode());
assertTimeoutPreemptively(Duration.ofMillis(200), () -> report.render());
assertAll informa de todos los fallos que contiene en lugar de detenerse en el primero, lo que convierte tres ejecuciones sucesivas en una sola. assertThrows devuelve la excepción capturada para que puedas hacer aserciones sobre su contenido: prefiere siempre un código de error tipado o un campo antes que comparar el texto del mensaje, porque los mensajes se reformulan y se traducen.
AssertJ sustituye el juego de adivinar a qué assertX llamar por un único assertThat y el autocompletado:
assertThat(orders)
.hasSize(3)
.extracting(Order::status)
.containsExactly(Status.NEW, Status.PAID, Status.SHIPPED);
assertThat(findCustomer("unknown")).isEmpty(); // Optional
assertThat(response.body())
.asString()
.contains("\"currency\":\"EUR\"");
assertThatThrownBy(() -> parser.parse("2026-13-45"))
.isInstanceOf(DateTimeParseException.class)
.hasMessageContaining("13");
assertThat(actualOrder)
.usingRecursiveComparison()
.ignoringFields("id", "createdAt")
.isEqualTo(expectedOrder);
usingRecursiveComparison resuelve el problema habitual de comparar objetos cuyo equals no es fiable, y es mucho mejor que escribir una docena de aserciones campo a campo que acabarán desactualizadas. Cuando un tipo del dominio aparece una y otra vez en las aserciones, una clase de aserción propia que extienda AbstractAssert da a la suite un vocabulario propio: assertThat(invoice).isPaid().hasTotalOf("1470.00").
Eliminar la repetición: tests parametrizados y anidados
Cuando el mismo comportamiento debe comprobarse con varias entradas, un test parametrizado lo dice explícitamente en lugar de esconder los casos en un bucle:
@ParameterizedTest(name = "IBAN {0} is {1}")
@CsvSource({
"FR7630006000011234567890189, true",
"DE89370400440532013000, true",
"FR7630006000011234567890188, false", // wrong check digits
"XX00, false"
})
void validatesIbanCheckDigits(String iban, boolean expected) {
assertThat(Iban.isValid(iban)).isEqualTo(expected);
}
@ParameterizedTest
@EnumSource(value = Status.class, names = {"SHIPPED", "DELIVERED", "REFUNDED"})
void terminalStatusesCannotBeCancelled(Status status) {
assertThatThrownBy(() -> new Order(status).cancel())
.isInstanceOf(IllegalStateException.class);
}
@ParameterizedTest
@MethodSource("discountScenarios")
void appliesTieredDiscount(Money subtotal, Tier tier, Money expected) {
assertThat(pricing.discount(subtotal, tier)).isEqualTo(expected);
}
static Stream<Arguments> discountScenarios() {
return Stream.of(
arguments(Money.of("99.99", "EUR"), Tier.STANDARD, Money.zero("EUR")),
arguments(Money.of("100.00", "EUR"), Tier.STANDARD, Money.of("5.00", "EUR")),
arguments(Money.of("100.00", "EUR"), Tier.PREMIUM, Money.of("10.00", "EUR")));
}
Cada conjunto de argumentos se informa como un test independiente, así que un fallo indica qué entrada lo provocó. El atributo name controla cómo se muestra. Otras fuentes son @ValueSource para un único parámetro primitivo, @NullAndEmptySource para las dos entradas que todo el mundo olvida y @CsvFileSource cuando la tabla crece más allá de lo que cabe razonablemente en una anotación. Los valores límite de las técnicas de diseño de pruebas encajan de forma natural en este formato: una fila por límite, con un comentario que diga de qué límite se trata.
Las clases internas @Nested agrupan los tests que comparten preparación y dan al informe una jerarquía legible:
class ShoppingCartTest {
@Nested
class WhenEmpty {
private final Cart cart = new Cart();
@Test void totalIsZero() { assertThat(cart.total()).isZero(); }
@Test void checkoutIsRejected() { assertThatThrownBy(cart::checkout).isInstanceOf(EmptyCartException.class); }
}
@Nested
class WithOneItem {
private final Cart cart = new Cart().add(Item.of("SKU-1", Money.of("20.00", "EUR")));
@Test void totalIsTheItemPrice() { assertThat(cart.total()).isEqualTo(Money.of("20.00", "EUR")); }
@Test void removingLeavesEmpty() { assertThat(cart.remove("SKU-1").isEmpty()).isTrue(); }
}
}
Cinco tipos de doble de prueba
Coloquialmente se dice «mock» para todos, pero las distinciones describen compromisos reales.
| Doble | Qué hace | Uso típico |
|---|---|---|
| Dummy | Se pasa para satisfacer una firma y nunca se usa | Rellenar un parámetro de constructor que no se utiliza |
| Stub | Devuelve respuestas predefinidas | Proporcionar al código bajo prueba los datos que necesita un escenario |
| Spy | Objeto real que registra cómo se le llamó | Código legacy que no puedes sustituir fácilmente |
| Mock | Se programa con expectativas y se verifica después | Comprobar que se produjo un efecto secundario |
| Fake | Implementación funcional pero simplificada | Repositorio en memoria, un hash map en lugar de un servidor de caché |
El que menos se aprovecha es el fake. Una implementación en memoria de una interfaz de repositorio, escrita una sola vez, hace que decenas de tests se lean como código normal sin ningún stub, y detecta errores de secuencia que un stub que devuelve un valor fijo no puede ver. Si la interfaz de un colaborador es pequeña y acabas escribiendo siempre el mismo stub para ella, escribe el fake.
Mockito en la práctica
@ExtendWith(MockitoExtension.class)
class SubscriptionServiceTest {
@Mock PaymentGateway gateway;
@Mock SubscriberRepository subscribers;
@Mock Mailer mailer;
@InjectMocks SubscriptionService service;
@Test
void chargesAndActivatesOnSuccess() {
Subscriber ada = new Subscriber(7L, "[email protected]", Plan.ANNUAL);
when(subscribers.findById(7L)).thenReturn(Optional.of(ada));
when(gateway.charge(any(ChargeRequest.class)))
.thenReturn(ChargeResult.approved("txn-88"));
service.renew(7L);
assertThat(ada.status()).isEqualTo(Status.ACTIVE);
verify(subscribers).save(ada);
verify(mailer).sendRenewalReceipt("[email protected]", "txn-88");
}
@Test
void doesNotActivateWhenTheCardIsDeclined() {
when(subscribers.findById(7L)).thenReturn(Optional.of(new Subscriber(7L, "[email protected]", Plan.ANNUAL)));
when(gateway.charge(any())).thenReturn(ChargeResult.declined("insufficient_funds"));
assertThatThrownBy(() -> service.renew(7L)).isInstanceOf(PaymentDeclinedException.class);
verify(subscribers, never()).save(any());
verifyNoInteractions(mailer);
}
}
MockitoExtension crea los mocks, los inyecta y, lo que es importante, impone los strict stubs: un stub que nunca se usa hace fallar el test. Esa regla detecta la preparación copiada y pegada y los tests que dejaron de corresponderse con el código sin que nadie se diera cuenta. Cuando un stub es realmente condicional, envuélvelo con lenient() en lugar de relajar toda la clase.
Los argument matchers (any(), eq(), argThat()) deben usarse para todos los argumentos de una llamada o para ninguno: mezclar un valor literal con un matcher lanza una excepción. Cuando necesites inspeccionar lo que se pasó, captúralo:
@Captor ArgumentCaptor<ChargeRequest> charge;
@Test
void chargesTheAnnualPriceInTheSubscriberCurrency() {
// ... arrange and act ...
verify(gateway).charge(charge.capture());
assertThat(charge.getValue().amount()).isEqualTo(Money.of("199.00", "EUR"));
assertThat(charge.getValue().idempotencyKey()).isNotBlank();
}
Otras cosas que conviene saber: thenAnswer calcula un resultado a partir de los argumentos (when(repo.save(any())).thenAnswer(i -> i.getArgument(0)) es la fórmula estándar de «save devuelve lo que recibió»); doThrow(...).when(mock).method() permite hacer stub de métodos void; verify(mock, times(2)), atLeastOnce() e InOrder restringen cuántas veces y en qué orden se producen las llamadas; y mockStatic / mockConstruction existen para el código legacy, pero hay que tomarlos como una señal de que el diseño necesita un punto de desacoplamiento, no como una herramienta rutinaria.
Dependencias incómodas
El tiempo. Inyecta un java.time.Clock. La producción proporciona Clock.systemUTC(), el test proporciona Clock.fixed(Instant.parse("2026-03-01T09:00:00Z"), ZoneOffset.UTC), y las aserciones sobre caducidad, retención o días laborables pasan a ser exactas en lugar de aproximadas.
La aleatoriedad y los identificadores. Recibe como dependencia un Supplier<UUID> o un RandomGenerator con semilla. Un test que comprueba que una clave de idempotencia «no está vacía» es mucho más débil que uno que comprueba que es igual a un valor conocido.
Los archivos. El parámetro o campo @TempDir de Jupiter da a cada test un directorio nuevo que se borra después, lo que es más sencillo y seguro que una ruta fija bajo /tmp.
La asincronía. Nunca Thread.sleep. O bien haz que el componente acepte un executor para que el test pueda ejecutar las tareas de forma síncrona, o bien sondea con Awaitility: await().atMost(TWO_SECONDS).untilAsserted(() -> assertThat(store.count()).isEqualTo(3)).
Qué no mockear
No mockees tipos que no son tuyos: un driver JDBC, una librería cliente HTTP, el cliente de un message broker. Sus contratos son complejos, tu stub codifica lo que crees saber de ellos y el test pasa tanto si esas creencias son ciertas como si no. Pon delante de la librería una interfaz fina propia y mockea esa, o prueba contra el componente real en un test de integración.
No mockees value objects, DTO, colecciones ni enums. Construir uno real es más barato que hacerle un stub e infinitamente más legible.
No mockees la clase bajo prueba, tampoco con un spy parcial que haga stub de la mitad de sus propios métodos. Si un método es difícil de probar porque otro método de la misma clase hace algo costoso, ese otro método está pidiendo ser un colaborador independiente.
Por último, no verifiques todas las llamadas. Cada verify es una aserción sobre cómo funciona el código, y una suite llena de ellos falla con refactorizaciones que no cambiaron nada observable. Verifica los efectos secundarios que son la razón de ser del método (el cobro realizado, el mensaje enviado) y deja que los valores de retorno se encarguen del resto. Los nombres también ayudan: renewalReceiptIsSentAfterASuccessfulCharge expresa un comportamiento, mientras que testRenew2 no expresa nada en absoluto. La guía de estándares de código cubre las convenciones que mantienen el código de test tan legible como el de producción.
Ideas clave
- Un test unitario se gana el nombre por ser rápido y aislado; todo lo demás de la clasificación es una discusión terminológica.
- Jupiter crea una instancia de test nueva por método, no necesita runner ni modificadores public y te da
assertAll,assertThrowsy@TempDirde serie. - Las aserciones fluidas de AssertJ producen mejores mensajes de fallo y manejan colecciones, optionals y comparación de objetos completos sin código auxiliar.
@ParameterizedTestcon@CsvSourceo@MethodSourceconvierte una tabla de casos límite en tests que se informan por separado.- Conoce los cinco dobles de prueba y recurre a un fake cuando el mismo stub se repite una y otra vez; los strict stubs te avisarán cuando un mock se haya alejado de la realidad.
- Inyecta relojes, fuentes de aleatoriedad y executors en lugar de mockear métodos estáticos, y nunca mockees tipos que no son tuyos ni la clase bajo prueba.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Debo usar las aserciones propias de JUnit o AssertJ?
- Cualquiera de las dos sirve, pero AssertJ escala mejor. Su API fluida te deja descubrir la aserción adecuada con el autocompletado, genera mensajes de fallo que describen la diferencia en lugar de volcar dos objetos y maneja colecciones, optionals, excepciones y comparación recursiva sin métodos auxiliares. Las aserciones de JUnit bastan para una igualdad sencilla y siempre están disponibles; la mayoría de los equipos estandariza en AssertJ y usa junto a ella assertThrows o assertAll de Jupiter.
- ¿Cuándo usar @Mock y cuándo construir el objeto real?
- Mockea un colaborador cuando es lento, no determinista, tiene efectos secundarios que no quieres o cuando el objetivo del test es precisamente la interacción con él. Usa el objeto real cuando es un valor, una estructura de datos sencilla o una función pura: mockearlos debilita el test y lo acopla a un detalle de implementación. Si mockear algo exige más de tres llamadas con stub, suele ser señal de que el diseño tiene demasiados colaboradores.
- ¿Por qué Mockito se queja de stubs innecesarios?
- MockitoExtension usa por defecto strict stubs, que hacen fallar un test que define un stub para una llamada que el código bajo prueba nunca realiza. Es una comprobación realmente útil: un stub sin usar suele indicar que el test ya no se corresponde con el código de producción o que se copió de otro test. Corrige el test en lugar de pasar al modo lenient; si un stub es legítimamente condicional, marca solo ese con Mockito.lenient().
- ¿Cómo hago un test unitario de código que llama a LocalDateTime.now()?
- Inyecta un java.time.Clock y lee la hora a través de él. El código de producción recibe Clock.systemDefaultZone() desde la configuración, los tests reciben Clock.fixed(instant, zone) y se vuelven deterministas. El mismo principio se aplica a los números aleatorios, la generación de UUID y el sistema de archivos: recibe la fuente de no determinismo como parámetro del constructor en lugar de llamar a una factoría estática, y el test no necesitará trucos de bytecode.