Un proyecto Java con diez herramientas de testing y ninguna idea clara de para qué sirve cada una acaba teniendo una suite lenta, frágil y que nadie quiere ejecutar. La solución no pasa por elegir «la mejor» librería, sino por colocar cada herramienta en el nivel que le corresponde: pruebas unitarias rápidas en la base, pruebas de integración con dependencias reales en el medio y unos pocos tests de extremo a extremo y de carga en la punta.
Este mapa recorre las herramientas que se usan hoy en proyectos Java, con las versiones que figuran en Maven Central a principios de noviembre de 2026 y un ejemplo mínimo de cada una. Si buscas una introducción a la cultura de tests con JUnit y Mockito, la tienes en Testing en Java en 2026; aquí el foco es qué usar en cada capa.
Resumen por niveles
|
Nivel | Herramienta | Versión estable | Cuándo usarla |
Unitario | JUnit 6, Mockito, AssertJ | 6.1.3 / 5.24.0 / 3.27.x | Lógica de negocio sin red ni base de datos |
Integración | Spring Boot Test + Testcontainers | Spring Boot 4.1 / Testcontainers 2.0.5 | Repositorios, migraciones, colas, caché |
API | REST Assured | 6.3.0 | Contrato HTTP de un servicio |
Extremo a extremo | Playwright o Selenium | 1.64.0 / 4.52.0 | Flujos críticos en un navegador real |
BDD | Cucumber-JVM | 8.0.4 | Criterios de aceptación legibles por negocio |
Carga | Gatling o JMeter | 3.16.0 / 5.6.3 | Rendimiento y capacidad |
Nivel unitario: JUnit 6, Mockito y AssertJ
JUnit 6.0.0 salió el 30 de septiembre de 2025 y la rama actual es la 6.1.x. Los cambios que más se notan al migrar desde JUnit 5 son pocos pero conviene conocerlos: el mínimo pasa a ser Java 17 (y Kotlin 2.2 si usas Kotlin), Platform, Jupiter y Vintage comparten ya un único número de versión, y el motor Vintage, el que ejecuta tests de JUnit 4, queda deprecado y pensado solo como puente temporal. También desaparecieron APIs antiguas, como MethodOrderer.Alphanumeric, y los nombres de los tests parametrizados muestran los argumentos como nombre = valor.
La API de escritura sigue siendo la de Jupiter, así que un test de JUnit 5 compila igual. Un ejemplo con test parametrizado, AssertJ y un mock de Mockito:
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
interface ServicioDescuentos {
BigDecimal porcentaje(String codigoCliente);
}
class Calculadora {
private final ServicioDescuentos descuentos;
Calculadora(ServicioDescuentos descuentos) {
this.descuentos = descuentos;
}
BigDecimal totalConDescuento(String cliente, BigDecimal importe) {
if (importe.signum() < 0) {
throw new IllegalArgumentException("El importe no puede ser negativo");
}
BigDecimal pct = descuentos.porcentaje(cliente);
return importe.subtract(importe.multiply(pct).movePointLeft(2));
}
}
@ExtendWith(MockitoExtension.class)
class CalculadoraTest {
@Mock
ServicioDescuentos descuentos;
@InjectMocks
Calculadora calculadora;
@ParameterizedTest
@CsvSource({"100.00, 10, 90.00", "50.00, 0, 50.00", "200.00, 25, 150.00"})
void aplicaElDescuentoDelCliente(String importe, String pct, String esperado) {
when(descuentos.porcentaje("C-1")).thenReturn(new BigDecimal(pct));
BigDecimal total = calculadora.totalConDescuento("C-1", new BigDecimal(importe));
assertThat(total).isEqualByComparingTo(esperado);
}
@Test
void rechazaImportesNegativos() {
assertThatThrownBy(() -> calculadora.totalConDescuento("C-1", new BigDecimal("-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("negativo");
}
}
Las dependencias de Maven son org.junit.jupiter:junit-jupiter (6.1.3), org.mockito:mockito-junit-jupiter (5.24.0) y org.assertj:assertj-core (3.27.x; la 4.0 todavía está en milestone). Si tu proyecto es Spring Boot, spring-boot-starter-test ya trae las tres y gestiona las versiones.
Con Mockito en JDK recientes (21 o superior) verás un aviso porque la librería se engancha a sí misma a la JVM en tiempo de ejecución, algo que el JDK quiere restringir en futuras versiones. La solución estándar es cargar Mockito como agente en Surefire y Failsafe:
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals><goal>properties</goal></goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
</plugin>
Si ya usas JaCoCo, antepón @{argLine} para no pisar su agente. El mismo argLine hay que repetirlo en Failsafe si ejecutas ahí las pruebas de integración.
Regla práctica: si un test unitario necesita levantar Spring, abrir un socket o tocar disco, ya no es unitario. Los mocks sirven para aislar colaboradores; si acabas simulando media aplicación, mejor sube de nivel.
Nivel de integración: Spring Boot Test y Testcontainers
Aquí se prueba lo que los mocks no pueden demostrar: que la consulta JPA hace lo que crees, que la migración de Flyway aplica y que el mapeo a PostgreSQL funciona con el dialecto real. Testcontainers arranca la base de datos en Docker durante el test y la destruye después. Con la versión 2.0 todos los módulos llevan el prefijo testcontainers- (por ejemplo org.testcontainers:testcontainers-postgresql), se eliminó el soporte de JUnit 4 y las clases viven en paquetes por módulo, como org.testcontainers.postgresql.PostgreSQLContainer.
Spring Boot conecta el contenedor con la aplicación sin escribir propiedades a mano gracias a @ServiceConnection:
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
@Testcontainers
@SpringBootTest
class PedidoRepositoryIT {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");
@Autowired
PedidoRepository repositorio;
@Test
void guardaYRecuperaUnPedido() {
Pedido guardado = repositorio.save(new Pedido("C-1", 4200));
assertThat(repositorio.findById(guardado.getId()))
.get()
.extracting(Pedido::getCliente)
.isEqualTo("C-1");
}
}
La documentación de Spring Boot avisa de un detalle: con la extensión de JUnit, los contenedores estáticos se paran al terminar la clase, pero Spring puede cachear el contexto y reutilizarlo en otra clase de test, que entonces apuntaría a un contenedor ya apagado. Si compartes contexto entre varias clases, declara el contenedor como bean en una @TestConfiguration y ponlo con @Import en cada test.
Para probar solo la capa web, sin base de datos, Spring Boot 4 ofrece slices. Ojo con los paquetes: @WebMvcTest vive ahora en org.springframework.boot.webmvc.test.autoconfigure y los mocks de beans se declaran con @MockitoBean, que viene de Spring Framework:
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(PedidoController.class)
class PedidoControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
PedidoService servicio;
@Test
void devuelveElPedido() {
when(servicio.buscar(7L)).thenReturn(new PedidoDto(7L, "C-1"));
assertThat(mvc.get().uri("/pedidos/7"))
.hasStatusOk()
.bodyJson()
.extractingPath("$.cliente").isEqualTo("C-1");
}
}
Solo se admite una anotación de slice por test. Si necesitas combinar capas, elige una y añade a mano las anotaciones @AutoConfigure
que falten. Si quieres repasar cómo se estructura el resto del stack, la introducción a Spring Boot en 2026 da el contexto.
Nivel de API: REST Assured
REST Assured ofrece un DSL given / when / then para validar servicios HTTP: códigos de estado, cabeceras y JSON con JsonPath. La versión 6.0.0 (diciembre de 2025) elevó el mínimo a Java 17, pasó a Groovy 5 y añadió soporte para Spring 7 y Jackson 3. Hoy la última es la 6.3.0.
Se combina bien con un servidor levantado en un puerto aleatorio, porque prueba el contrato real, serialización incluida:
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PedidosApiIT {
@Value("${local.server.port}")
int puerto;
@BeforeEach
void configurar() {
RestAssured.baseURI = "http://localhost";
RestAssured.port = puerto;
}
@Test
void creaYListaPedidos() {
given()
.contentType("application/json")
.body("{"cliente":"C-1","importe":4200}")
.when()
.post("/pedidos")
.then()
.statusCode(201);
given()
.when()
.get("/pedidos?cliente=C-1")
.then()
.statusCode(200)
.body("$", hasSize(1))
.body("[0].cliente", equalTo("C-1"));
}
}
Si el servicio vive en Spring, Spring Boot 4 incluye también RestTestClient, que cubre buena parte de lo mismo sin dependencia externa. REST Assured gana cuando el equipo de QA ya lo usa o cuando el servicio bajo prueba no es una aplicación Spring.
Extremo a extremo: Playwright o Selenium
Los tests de navegador son los más caros de mantener, así que conviene reservarlos para dos o tres flujos que no pueden romperse (alta de usuario, pago, login). Hoy hay dos opciones sólidas en Java.
Playwright
La versión Java (com.microsoft.playwright:playwright, 1.64.0) descarga los navegadores por sí sola y trae esperas automáticas: antes de hacer clic comprueba que el elemento sea visible y estable, con lo que desaparecen la mayoría de los sleep de los tests de Selenium. Sus aserciones también reintentan hasta que se cumplen o vence el tiempo:
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.options.AriaRole;
import java.util.regex.Pattern;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NavegacionE2ETest {
static Playwright playwright;
static Browser browser;
@BeforeAll
static void abrir() {
playwright = Playwright.create();
browser = playwright.chromium().launch();
}
@AfterAll
static void cerrar() {
playwright.close();
}
@Test
void laPortadaEnlazaConLaDocumentacion() {
Page page = browser.newPage();
page.navigate("https://playwright.dev");
assertThat(page).hasTitle(Pattern.compile("Playwright"));
page.getByRole(AriaRole.LINK, new Page.GetByRoleOptions().setName("Get started")).click();
assertThat(page).hasURL(Pattern.compile(".*intro"));
}
}
Selenium
Selenium (org.seleniumhq.selenium:selenium-java, 4.52.0) sigue siendo el estándar en equipos con muchos tests heredados, Grid propio o necesidad de navegadores y proveedores en la nube concretos. A partir de la 4.14 exige Java 11 como mínimo y Selenium Manager descarga el driver adecuado automáticamente. Con Selenium las esperas son tu responsabilidad, y se hacen de forma explícita:
import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import static org.assertj.core.api.Assertions.assertThat;
class LoginSeleniumTest {
WebDriver driver;
@BeforeEach
void abrir() {
ChromeOptions opciones = new ChromeOptions();
opciones.addArguments("--headless=new");
driver = new ChromeDriver(opciones);
}
@AfterEach
void cerrar() {
driver.quit();
}
@Test
void elUsuarioEntraEnSuPanel() {
driver.get("http://localhost:8080/login");
driver.findElement(By.name("usuario")).sendKeys("ana");
driver.findElement(By.name("clave")).sendKeys("secreta");
driver.findElement(By.cssSelector("button[type=submit]")).click();
new WebDriverWait(driver, Duration.ofSeconds(5))
.until(ExpectedConditions.visibilityOfElementLocated(By.id("panel")));
assertThat(driver.getTitle()).contains("Panel");
}
}
Cómo elegir: en un proyecto nuevo, Playwright da tests más estables con menos código. Selenium compensa si ya tienes una suite grande, un Grid en marcha o requisitos de compatibilidad que Playwright no cubre.
BDD: Cucumber-JVM
Cucumber describe el comportamiento en Gherkin, un lenguaje casi natural que pueden leer y revisar personas que no programan. Solo aporta valor si negocio participa de verdad en escribir esos escenarios; si únicamente los lee el equipo técnico, es una capa extra de indirección sobre un test normal.
La versión 8 (la 8.0.0 llegó tras unos cinco años de la 7) exige Java 17, usa anotaciones de nulabilidad de JSpecify y deja de empaquetar Jackson, que ahora debes añadir tú si usas los plugins de informes JSON o HTML. Con JUnit Platform se necesitan cucumber-java, cucumber-junit-platform-engine y junit-platform-suite. Gherkin admite español:
# language: es
Característica: Descuentos por cliente
Escenario: Un cliente habitual recibe su descuento
Dado un cliente con un 10% de descuento
Cuando compra por 100 euros
Entonces paga 90 euros
import static org.assertj.core.api.Assertions.assertThat;
import io.cucumber.java.es.Cuando;
import io.cucumber.java.es.Dado;
import io.cucumber.java.es.Entonces;
import java.math.BigDecimal;
public class DescuentosSteps {
private BigDecimal porcentaje;
private BigDecimal total;
@Dado("un cliente con un {int}% de descuento")
public void clienteConDescuento(int pct) {
porcentaje = BigDecimal.valueOf(pct);
}
@Cuando("compra por {int} euros")
public void compraPor(int importe) {
BigDecimal base = BigDecimal.valueOf(importe);
total = base.subtract(base.multiply(porcentaje).movePointLeft(2));
}
@Entonces("paga {int} euros")
public void pagaEuros(int esperado) {
assertThat(total).isEqualByComparingTo(BigDecimal.valueOf(esperado));
}
}
Para lanzarlo con el motor de JUnit Platform basta una clase de suite que apunte a los .feature y al paquete de los pasos:
import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;
import org.junit.platform.suite.api.ConfigurationParameter;
import org.junit.platform.suite.api.IncludeEngines;
import org.junit.platform.suite.api.SelectClasspathResource;
import org.junit.platform.suite.api.Suite;
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.ejemplo.pasos")
public class EjecutarCucumberTest {
}
Carga: Gatling y JMeter
Las pruebas de carga responden a una pregunta distinta: cuánto aguanta el sistema antes de degradarse. Dos opciones, dos filosofías.
Gatling (3.16.0) define las simulaciones como código, con un DSL disponible en Java, y se integra en el control de versiones y en la integración continua como cualquier otro fichero fuente. Se ejecuta con el plugin io.gatling:gatling-maven-plugin (mvn gatling:test):
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
import io.gatling.javaapi.core.ScenarioBuilder;
import io.gatling.javaapi.core.Simulation;
import io.gatling.javaapi.http.HttpProtocolBuilder;
public class CargaPedidosSimulation extends Simulation {
HttpProtocolBuilder protocolo = http.baseUrl("http://localhost:8080");
ScenarioBuilder escenario = scenario("Consultar pedidos")
.exec(http("Listado").get("/pedidos"))
.pause(1)
.exec(http("Detalle").get("/pedidos/7"));
{
setUp(escenario.injectOpen(rampUsers(50).during(30)).protocols(protocolo));
}
}
JMeter (5.6.3) trabaja con planes de prueba en XML (.jmx) que se diseñan en una interfaz gráfica. Tiene un ecosistema enorme de plugins y soporta muchos protocolos (HTTP, JDBC, JMS, LDAP), pero los planes son difíciles de revisar en un pull request. La práctica habitual es diseñar con la GUI y ejecutar siempre en modo consola, que consume bastante menos:
jmeter -n -t plan-pedidos.jmx -l resultados.jtl -e -o informe/
Gatling encaja mejor si el equipo es de desarrollo y quiere las pruebas de carga en el mismo repositorio, revisadas como código. JMeter es más cómodo cuando los probadores no programan o hay que cubrir protocolos que no sean HTTP.
Cómo repartir el esfuerzo
No hay una proporción mágica, pero sí una tendencia consolidada: muchos tests unitarios, un número razonable de integración y muy pocos de navegador. Algunos criterios para decidir dónde poner cada prueba:
- Si la lógica se puede ejecutar sin infraestructura, escríbela como test unitario. Es el único nivel que da retroalimentación en segundos.
- Si el riesgo está en cómo se habla con una base de datos, una cola o una caché, usa Testcontainers en lugar de H2 u otras sustitutas: los bugs de dialecto solo aparecen con el motor real.
- Usa REST Assured o
RestTestClientcuando importe el contrato JSON y los códigos de error. - Reserva Playwright o Selenium para los recorridos de usuario que no puedes permitirte romper, y mantenlos pocos.
- Adopta Cucumber solo si negocio escribe o valida los escenarios.
- Lanza Gatling o JMeter en un entorno parecido a producción y con una cadencia fija (por ejemplo, antes de cada versión), no en cada commit.
Un último consejo operativo: separa las pruebas por sufijo (*Test con Surefire para las rápidas, *IT con Failsafe para las de integración) y ejecuta las lentas en una fase propia de la integración continua. Así un cambio pequeño se valida en menos de un minuto y el pipeline completo se reserva para lo que lo necesita. Si todavía no tienes clara la construcción del proyecto, la comparativa de Maven y Gradle en 2026 ayuda a decidir cómo organizar esas fases.
Imagen: Pexels / Daniil Komov
