Pirámide de tests en Java: JUnit 6, Testcontainers, Playwright y Gatling, cada uno en su nivel

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 RestTestClient cuando 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

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP