1. Pocos tests full-context MockMvc costosos
El contexto completo es como ir a un gran supermercado por un solo panecillo. Se puede, claro, hacer eso cada vez, pero en algún momento empezarás a odiar tanto los panecillos como el supermercado, y la idea misma de comer. En los tests es lo mismo: los tests full-context MockMvc son útiles, pero por definición son más costosos porque levantan más infraestructura y requieren una gestión de datos más «adulta».
El precio aquí no está solo en el tiempo de inicio del contexto. Preparar el estado se vuelve más caro (se necesita un estado real de la base de datos, migraciones, datos semilla), diagnosticar fallos (puede romperse la unión de capas, no solo una clase) y el mantenimiento (un pequeño cambio en la configuración puede afectar a muchos tests). Si conviertes el suite en «todo a través de @SpringBootTest», el equipo desarrollará bastante rápido una estrategia de protección psicológica: «bueno, localmente no ejecuto los tests, ya veremos en CI». Y ese es el momento en que los tests dejan de ser un cinturón de seguridad rápido y se convierten en una inspección técnica rara vez cada seis meses.
Para no caer en esa trampa, es útil tener en mente una economía simple de niveles — sin fanatismo ni guerras religiosas:
| Nivel del test | Pregunta típica | Qué demuestra | Coste |
|---|---|---|---|
| Unit (sin Spring) | «¿La regla es correcta?» | Lógica de negocio pura de una o pocas clases | Bajo |
@WebMvcTest, , |
«¿El límite de la capa es correcto?» | Contrato de la capa sin toda la aplicación | Medio |
| Full-context + MockMvc | «¿El acoplamiento de capas funciona?» | Unión real de beans, advice, serialización, filters | Alto |
Precisamente por eso el subconjunto full-context debe ser pequeño. Cierra muy bien el acoplamiento interno de la aplicación, pero no debe reemplazar la capa unit/slice y no está obligado a demostrar la ruta real servidor/cliente.
El sentido de la lección de hoy es aprender a seleccionar los tests costosos de modo que sean un «pequeño grupo de élite de fuerzas especiales», no «toda la población del país movilizada para una sola tarea».
2. Test full-context fuerte y ROI
Cuando los desarrolladores dicen «ROI», suenan como si fueran a venderte una franquicia de cafetería. Pero en las pruebas esta palabra es de repente muy útil, si la traducimos del lenguaje empresarial al humano. El ROI de un test es «cuánta confianza aporta» en comparación con lo que cuesta en ejecución y mantenimiento. Y sí, un test también tiene «rentabilidad»: un test puede detectar un error raro pero muy costoso, mientras que otro puede verificar algo que ya está probado de forma sólida por capas baratas.
En los ejemplos siguientes se asume JUnit 6 (Jupiter API).
Para entender si un escenario merece el modo full-context, es útil hacerse varias preguntas. Las formulo deliberadamente como «una conversación con uno mismo», porque así realmente se usan en la práctica, no en teoría.
| Pregunta que te haces | Si la respuesta es «sí» | Qué significa |
|---|---|---|
| «¿El escenario realmente atraviesa varias capas: controller → service → repository → advice?» | Es candidato | Es probable un defecto en la unión de capas que un slice no vería |
| «¿Me importa verificar la serialización/ajustes reales de Jackson, no “cómo mockeé el servicio”?» | Es candidato | El contrato se forma con beans reales, no con stubs |
| «¿El error debe transformarse en un ApiProblem estable, y quiero ver toda la cadena “excepción → advice → JSON”?» | Es candidato | No estás probando «404», sino «404 + payload correcto» |
| «¿Este endpoint es crítico para el negocio y una regresión será dolorosa?» | Es candidato | Alto valor del test incluso a un alto coste |
| «¿Esta regla ya está demostrada por un test unitario y solo quiero repetirla “por si acaso”?» | Más bien no candidato | Duplicación de cobertura costosa sin confianza adicional |
Si se desea un enfoque aún más mecánico, se puede tener un mini-árbol de decisiones. No sustituye el pensamiento, pero ayuda a detenerse rápidamente cuando la mano tiende a @SpringBootTest por costumbre:
flowchart TD
A[¿Hay riesgo/defecto?] --> B{¿Se puede probar sin Spring?}
B -->|Sí| U[Test unitario]
B -->|No| C{¿Es un contrato JSON puro de DTO?}
C -->|Sí| J["@JsonTest"]
C -->|No| D{¿Solo se necesita la capa web?}
D -->|Sí| W["@WebMvcTest"]
D -->|No| E{¿Solo se necesita persistencia?}
E -->|Sí| P["@DataJpaTest"]
E -->|No| F[Full-context + MockMvc]
En el punto actual del curso estamos justo en la rama F, pero la idea importante es que no todos los escenarios deben llegar allí, solo aquellos donde realmente se necesita la «unión de capas».
3. «Una historia de rotura — un test»: cómo no cultivar megatests
Los tests costosos provocan especialmente la tentación de hacer un megatest. La lógica es comprensible: «Ya que levantamos todo el contexto, probemos de una vez todo: crear borrador → enviar → aprobar → verificar la salida pública → verificar otros cinco errores». Suena económico, pero en la práctica es como intentar pagar con un solo cheque la compra semanal para 20 personas: sí, compraste todo, pero ahora estarás desglosando las bolsitas hasta la jubilación.
El problema del megatest no es solo que sea largo, aunque también eso. El problema es que se diagnostica mal. Falla — y al principio no entiendes dónde se rompió: ¿validación? ¿serialización? ¿repositorio? ¿filtro de seguridad? ¿no funcionó el advice (@ControllerAdvice)? Y además, casi inevitablemente se vuelve frágil: tiene demasiados pasos, y cualquier cambio en medio del escenario rompe todo el «tren».
En los tests full-context MockMvc, un estilo saludable suele ser: el test protege una «historia de rotura» concreta que realmente puedas imaginar. Por ejemplo, «si no se encuentra el slug, debe devolverse un ApiProblem con ARTICLE_NOT_FOUND», o «después de approve, el artículo aparece en la salida pública». Son historias que se pueden explicar con palabras a otra persona y a uno mismo dentro de un mes.
Una buena señal es si el nombre del test se parece a una oración, no a un plan de operación militar. Por ejemplo, este test unitario no tiene por qué existir en modo full-context, porque protege una regla local:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationPolicyTest {
@Test
void draft_canMoveToInReview() {
// Comprobamos una regla local (sin Spring y sin base de datos)
assertThat(PublicationPolicy.canMove("DRAFT", "IN_REVIEW")).isTrue();
}
}
En cambio, este escenario ya se parece a una «unión de capas» y puede ser candidato para full-context MockMvc: el controlador busca un artículo, el servicio lanza una excepción de dominio, @ControllerAdvice la convierte en ApiProblem, Jackson serializa, y todo esto con beans reales, no mocks.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void missingSlug_returnsApiProblem() throws Exception {
// se supone que mockMvc está levantado en un test full-context mediante @AutoConfigureMockMvc
mockMvc.perform(
// Importante: vamos por la URL real, como lo hace el cliente
get("/api/public/articles/missing-slug")
.accept(MediaType.APPLICATION_JSON)
)
// Viga portante: código HTTP correcto
.andExpect(status().isNotFound())
// Viga portante: contrato de error estable (campo clave)
.andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"));
}
Observa la filosofía de las expectativas: no verificamos una docena de campos solo porque podemos. Verificamos aquellos campos que son el sentido de la historia: estado y errorCode.
4. Datos para full-context: @Sql vs repositorios
Cuando pasas de slice a full-context, cambia la principal fuente de dolor. En @WebMvcTest el dolor es el cableado de dependencias y los mocks. En full-context MockMvc, lo más frecuente es que el dolor sean los datos. Porque las dependencias son reales, por lo tanto el endpoint leerá de una base de datos real, aunque sea de prueba, aplicará migraciones reales, mapeos reales y consultas reales.
Para los tests full-context de happy path, es conveniente aferrarse al mismo conjunto de datos: @Sql("/sql/public/published-article.sql") carga la categoría y el artículo publicado, y luego sobre eso se ejecutan escenarios GET. Así, el contexto completo verifica precisamente la historia HTTP, no la lucha contra un estado de datos aleatorio.
El error más común de los novatos aquí es: «Al principio del test, mediante repositorios crearé una categoría, luego crearé un artículo, luego adjuntos, luego actualizaré el estado...». Por un lado, es honesto. Por otro, rápidamente se convierte en un test que verifica todo de golpe, y además empiezas a repetir la lógica de la aplicación en el test. A esto se suma la magia de JPA: entidades gestionadas, flush, cascadas ocultas y toda esa alegría que ya viste en el nivel de datos.
Por eso, en los tests full-context, muy a menudo gana la preparación SQL: es corta, transparente y no pretende ser «parte de la aplicación». Dices directamente: «en la base de datos hay una categoría y un artículo publicado», y pasas al núcleo: el escenario HTTP.
Ejemplo de un esqueleto mínimo de test full-context con preparación de datos:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Sql("/sql/public/published-article.sql") // Preparación de datos con un archivo comprensible antes de los tests de la clase
class PublicArticleFullContextTest {
@Autowired
MockMvc mockMvc; // Punto de entrada para peticiones HTTP en modo full-context
}
Y aquí un ejemplo del fixture SQL en sí, que normalmente se encuentra en src/test/resources/sql/.... Lo hago deliberadamente «legible», sin magia innecesaria. En un proyecto real, las tablas y columnas coincidirán con las migraciones.
-- Preparación mínima: categoría para el artículo
INSERT INTO category(id, code, name) VALUES (1, 'JAVA', 'Java');
-- Preparación mínima: artículo publicado que entrega la API pública
INSERT INTO article(id, title, slug, summary, body, status, author_username, category_id, published_at)
VALUES (100, 'Spring Basics', 'spring-basics', '...', '...', 'PUBLISHED', 'alice', 1, now());
Sí, aquí hay now(), es decir, el valor exacto de la hora será diferente. Pero eso es normal si en el test no verificas una fecha concreta hasta el milisegundo, sino el hecho de que publishedAt está presente. Si necesitas una hora exacta, entonces deberías apoyarte en un Clock fijo en el perfil de test.
5. Conjunto mínimo full-context: 3–4 tests
La idea del «conjunto pequeño» no es ser asceta y escribir 2 tests para toda la aplicación. Ni ser un héroe y cubrir toda la API con tests full-context. La idea es dejar exactamente aquellos escenarios que realmente detectan roturas en la unión de capas y además protegen historias de usuario importantes de ContentHub.
A continuación, un ejemplo de tal cartera. No es un dogma, sino una orientación: es importante captar la lógica de selección y aprender a explicar por qué existe cada test.
| Test (clase/escenario) | Qué verifica | Por qué es full-context |
|---|---|---|
| Public details: GET /api/public/articles/{slug} | Ensamblaje real de la respuesta desde la BD + serialización + estado + campos importantes | Valor en la cadena completa «datos → DTO → JSON» |
| Public missing slug → ApiProblem | Traducción real de un error de dominio en un contrato de error estable | Importante la unión service/repository → advice → JSON |
| Public list (page wrapper) | Paginación/filtrado real y metadatos de página | Unión data/query → mapping → page response wrapper |
| Admin approve hace público el artículo | Cambio de estado + aparición en la API pública | Un endpoint cambia el estado, otro demuestra el efecto |
Public details: «escaparate» de lectura
Este es uno de los candidatos más fuertes: la ficha pública del artículo es importante para el producto, utiliza datos reales y debe ser estable por contrato.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void publicDetails_returnsPublishedArticle() throws Exception {
mockMvc.perform(
// Petición como la de un cliente real: GET + Accept: application/json
get("/api/public/articles/spring-basics")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk())
// Es importante verificar las «vigas portantes» del contrato, no todo el JSON al por mayor
.andExpect(jsonPath("$.slug").value("spring-basics"))
.andExpect(jsonPath("$.status").value("PUBLISHED"));
}
Aquí verificamos las «vigas portantes» de la respuesta: slug y status. Se puede añadir publishedAt.exists() si forma parte del contrato.
Missing slug → ApiProblem: el error como contrato
Un contrato de error estable es una de esas cosas que se rompen «en silencio». La aplicación sigue respondiendo 404, pero de repente el payload se vuelve diferente y el cliente empieza a entristecerse. El modo full-context es bueno porque ves toda la cadena: dónde surgió el error y cómo se convirtió en JSON.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void publicDetails_missingSlug_returnsApiProblem() throws Exception {
mockMvc.perform(
// Slug inválido/inexistente — esperamos un error de dominio convertido en ApiProblem
get("/api/public/articles/nope")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isNotFound())
// Campo clave del contrato de error (normalmente al que está vinculado el cliente)
.andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"))
// El tipo de respuesta también es parte del contrato (importante para clientes y para depuración)
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
}
Observa la última comprobación: el tipo de respuesta es parte del contrato. Y sí, es un detalle que en la práctica ahorra tiempo al analizar problemas en el cliente.
Public list + page metadata
Las listas con paginación a menudo se rompen «sin drama»: alguien cambió el tamaño de página por defecto, alguien renombró un campo de metadatos, alguien empezó accidentalmente a devolver borradores. Y precisamente por eso un test sobre la lista suele dar un alto ROI.
Si te gusta MockMvcTester, para una comprobación rápida de smoke de la lista, mantén el mismo ritmo: petición → exchange() → assertions. Lo principal es no organizar una mezcla caótica de estilos en una misma clase.
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
class PublicArticleListFullContextTest {
@Autowired
MockMvcTester mvc;
@Test
void publicList_returnsOk() {
var result = mvc.get()
.uri("/api/public/articles?page=0&size=10")
.accept(MediaType.APPLICATION_JSON)
.exchange();
Assertions.assertThat(result).hasStatusOk();
}
}
Y si quieres fijar una parte importante del contrato, es mejor verificar explícitamente los metadatos:
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void publicList_containsPageMetadata() throws Exception {
mockMvc.perform(
// Aquí es importante la estabilidad de los metadatos de página: page/size, etc.
get("/api/public/articles?page=0&size=10")
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.page").value(0))
.andExpect(jsonPath("$.size").value(10));
}
Approve hace público el artículo
Si hay que elegir un escenario de escritura para el modo full-context, elegiría aquel donde el efecto se ve desde fuera. Una cosa es verificar que el endpoint approve devolvió 200. Otra es demostrar que después de approve el artículo realmente se vuelve visible en la API pública. Eso es justo la «unión de capas» que cuesta caro pero aporta verdadera confianza.
Aquí es conveniente usar @Sql para preparar un artículo en estado IN_REVIEW. No estamos obligados en este test a recorrer todo el workflow desde el borrador: esa es otra historia de rotura y otro test.
import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WithMockUser(username = "admin", roles = "ADMIN")
@Test
void approve_makesArticlePublic() throws Exception {
mockMvc.perform(post("/api/admin/articles/100/approve"))
.andExpect(status().isOk());
mockMvc.perform(get("/api/public/articles/reviewed-article"))
.andExpect(status().isOk());
}
Sí, aquí hay dos peticiones en un test. Y está bien, porque sigue siendo una historia: «después de approve el artículo se vuelve público». Si en tu proyecto el slug/ID son diferentes, se sustituyen desde el fixture SQL.
6. Errores típicos al seleccionar tests full-context MockMvc
Error n.º 1: convertir full-context MockMvc en «la forma por defecto de probarlo todo».
Este es el escenario más frecuente: el modo parece cómodo porque «casi como real» y «menos mocks». Pero luego obtienes un suite que tarda demasiado en ejecutarse, por lo tanto se ejecuta con menos frecuencia. Al final, la confianza real disminuye, aunque haya más tests — la trampa clásica de cantidad sin calidad.
Error n.º 2: duplicar con tests costosos lo que ya está demostrado por capas unit/slice.
Si las transiciones de estados están sólidamente cubiertas por tests unitarios de PublicationPolicy, entonces un test full-context no debe repetir la matriz de transiciones. Si @WebMvcTest fija que los errores de validación se convierten en ApiProblem con violations, entonces un test full-context no está obligado a verificar todos esos mismos campos en cada endpoint. Un test caro debe añadir nueva confianza, no repetir la vieja.
Error n.º 3: escribir megatests de 30 pasos y luego heroicamente «arreglarlos» cada semana.
Los megatests crean una ilusión de ahorro, pero pagas después: mal diagnóstico, fragilidad, fallos incomprensibles y corrección eterna de fixtures. Cuando el test falla, pierdes tiempo no en corregir el defecto, sino en investigar dónde ocurrió exactamente.
Error n.º 4: hacer una preparación de datos «aleatoria» y esperar que sea estable de alguna manera.
Los tests full-context no toleran bien el caos. Si los datos se crean en el test mediante una docena de llamadas al repositorio, y además hay generación automática de tiempo/slug/ID, el test se vuelve no determinista. Las fixtures @Sql, un Clock fijo en el perfil de test y comprobaciones mínimas con sentido suelen dar una vida mucho más tranquila.
Error n.º 5: verificar demasiados «cosméticos» en JSON y provocar fallos falsos.
En cuanto empiezas a comparar todo el JSON completo o a verificar campos que no tienen carga semántica (orden de elementos, campos superfluos, valores técnicos internos), el test se convierte en una alarma que se activa con la brisa. En el modo full-context es especialmente importante verificar los elementos portantes del contrato: estado, errorCode, campos clave del DTO, headers críticos.
GO TO FULL VERSION