Primeros tests
Estrategia de testing
Elegí un framework
- Para la aplicación Java Spring Boot vamos a elegir JUnit + Mockito: es el estándar de facto, el que siempre preguntan en entrevistas, y el que se espera que conozcas.
- Para la aplicación Kotlin Spring Boot vamos a elegir JUnit + MockK: herramientas nativas de kotlin + mocking pensado para kotlin.
- Para el proyecto Groovy Spring Boot vamos a elegir Spock, ya que es la elección natural con menos esfuerzo.
JaCoCo
JaCoCo (Java Code Coverage) te dice qué líneas de código tus tests realmente ejecutan. Es la herramienta estándar de cobertura para proyectos JVM.
Cuando corrés la tarea test o jacocoTestReport, JaCoCo va a generar reportes en HTML (para humanos) y XML (para herramientas de CI) en build/reports/jacoco/test.
Qué archivos testear
¿Debo crear una prueba por cada archivo de paquete principal? ¿Puedo agrupar las pruebas? ¿Qué tipo de prueba es mejor? Ninguna de estas preguntas tiene una única respuesta definitiva. Varía según el equipo y el desarrollador. Aquí comparto mi enfoque; sentite libre de seguirlo, adaptarlo y modificarlo según sea necesario.
Dado un proyecto CRUD REST + Base de datos con arquitectura hexagonal:
- Las clases relacionadas con Adapter In que adaptan REST se pueden agrupar y probar juntas bajo una prueba de segmento de capa web
@WebMvcTest. - Las clases relacionadas con Adapter Out que adaptan JPA se pueden agrupar y probar juntas bajo una prueba de segmento de capa JPA
@DataJpaTest. - Las implementaciones de casos de uso de dominio se pueden agrupar y probar juntas bajo una prueba unitaria.
- Las clases de Configuración son complicadas. La mayoría de los equipos simplemente las ignoran. Mi opinión al respecto es la siguiente:
- Las clases web
@RestControllerAdvicese prueban con una pruebaMockMvcStandalone de segmento de capa web@WebMvcTest. - He visto a desarrolladores probar clases relacionadas con logs en pruebas de integración. Mi opinión es evitar probar filtros y aspectos relacionados con logs. Demasiado esfuerzo para tan poco beneficio.
- Las clases web
◀─────────────────── ESPECTRO DE PRUEBAS ───────────────────▶
UNITARIO INTEGRACIÓN
RÁPIDO LENTO
AISLADO CONECTADO
⚪──────────⚪──────────⚪──────────⚪
│ │ │ │
│ │ │ └── Spring Boot Completo
│ │ └── Solo Capa Web
│ └── MockMvc Independiente
└── Prueba Unitaria Pura
- Java
- Kotlin
- Groovy
Dependencias
- Java
- Kotlin
- Groovy
id 'jacoco'
jacoco
testImplementation("com.ninja-squad:springmockk:5.0.1")
testImplementation("io.mockk:mockk:1.14.9")
id 'jacoco'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
testImplementation 'org.spockframework:spock-spring:2.4-groovy-5.0'
Tests unitarios
Los tests unitarios no involucran a Spring en absoluto. Estos son más rápidos de correr y perfectos para testear lógica de negocio en completo aislamiento.
Los tests mostrados aquí son intencionalmente simples. Demuestran el cableado y aserciones básicas, no una validación exhaustiva. Qué tan profundo vas con el testing es, en última instancia, una filosofía de equipo (y de cada desarrollador). Es muy fácil escribir tests triviales que pinten cada línea de verde en un reporte de JaCoCo sin realmente probar nada significativo. Trata la cobertura como una guía, no como un objetivo.
Test de la implementación de los casos de uso
FilmUseCasesImpl.getFilm() actualmente solo devuelve una película hardcodeada. No tiene mucho sentido hacer testing profundo acá todavía, pero al menos verifiquemos que el servicio se instancie y devuelva algo:
- Java
- Kotlin
- Groovy
Test de la utilidad Enum
- Java
- Kotlin
- Groovy
Slice Tests
Capa Web
Estamos testeando tres cosas acá:
- Comportamiento del endpoint REST: ¿El controller mapea requests correctamente y devuelve las respuestas esperadas?
- Manejo de excepciones: Cuando las cosas salen mal, ¿la estructura de respuesta de error se ve bien?
- Estructura de respuesta JSON: ¿El JSON final coincide con lo que promete nuestro contrato de API?
Para hacer nuestras aserciones más legibles, primero vamos a crear algunos matchers custom para la estructura de respuesta de nuestra API:
- Java
- Kotlin
- Groovy
Al extender MockMvcResultMatchersDsl, nuestros matchers custom se
integran perfectamente con el DSL de Kotlin de Spring. Se pueden llamar
directamente dentro del bloque andExpect, haciendo que el código de test
lea como una parte nativa del framework.
Un trait es como una interfaz con implementación que se puede mezclar en clases. Al implementar este trait en tus especificaciones Spock, los métodos matcher se vuelven disponibles como si estuvieran definidos directamente en tu clase de test, sin necesidad de imports estáticos. A diferencia de las clases base abstractas, podés implementar múltiples traits, manteniendo tu jerarquía de tests flexible.
Ahora escribamos el test del @RestController propiamente dicho usando un enfoque de slice testing con @WebMvcTest, que crea un contexto mínimo de Spring enfocado en la capa web.
- Java
- Kotlin
- Groovy
MockMvc Standalone
El objetivo de estos tests es verificar que nuestro ControllerAdvice maneja diferentes escenarios de excepciones correctamente. Testeamos que los errores de validación devuelvan códigos de estado HTTP y estructuras de respuesta apropiadas, asegurando que nuestra API proporcione respuestas de error consistentes en diferentes modos de falla.
- Java
- Kotlin
- Groovy
No te olvidés de actualizar la clase ControllerAdvice para manejar las excepciones testeadas.
- Java
- Kotlin
- Groovy
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Error> handle(ConstraintViolationException e) {
return buildProblemDetail(e, BAD_REQUEST);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Error> handle(MethodArgumentNotValidException e) {
return buildProblemDetail(e, BAD_REQUEST);
}
@ExceptionHandler(ConstraintViolationException::class)
fun handle(e: ConstraintViolationException): ResponseEntity<Error> {
return buildErrorResponse(e, BAD_REQUEST)
}
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handle(e: MethodArgumentNotValidException): ResponseEntity<Error> {
return buildErrorResponse(e, BAD_REQUEST)
}
@ExceptionHandler(ConstraintViolationException)
ResponseEntity<Error> handle(ConstraintViolationException e) {
buildErrorResponse(e, BAD_REQUEST)
}
@ExceptionHandler(MethodArgumentNotValidException)
ResponseEntity<Error> handle(MethodArgumentNotValidException e) {
buildErrorResponse(e, BAD_REQUEST)
}
Ver el reporte de JaCoCo
- Java
- Kotlin
- Groovy
| Package / Class | Coverage | Complexity | Lines | Methods | Classes | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Instr. % | Branch % | Missed | Total | Missed | Total | Missed | Total | Missed | Total | |
| dev.pollito.spring_java.common.util | 100% | 100% | 0 | 3 | 0 | 4 | 0 | 1 | 0 | 1 |
| dev.pollito.spring_java.config.web | 93% | 66% | 1 | 8 | 1 | 19 | 0 | 6 | 0 | 1 |
| dev.pollito.spring_java.sakila.film.adapter.in.rest | 100% | n/a | 0 | 5 | 0 | 10 | 0 | 5 | 0 | 1 |
| dev.pollito.spring_java.sakila.film.domain.service | 100% | n/a | 0 | 2 | 0 | 16 | 0 | 2 | 0 | 1 |
| Total | 97% | 85% | 1 | 18 | 1 | 49 | 0 | 14 | 0 | 4 |
| Package / Class | Coverage | Complexity | Lines | Methods | Classes | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Instr. % | Branch % | Missed | Total | Missed | Total | Missed | Total | Missed | Total | |
| dev.pollito.spring_kotlin.common.util | 100% | 100% | 0 | 2 | 0 | 2 | 0 | 1 | 0 | 1 |
| dev.pollito.spring_kotlin.config.web | 92% | 75% | 3 | 13 | 1 | 22 | 2 | 11 | 0 | 2 |
| dev.pollito.spring_kotlin.sakila.film.adapter.in.rest | 100% | n/a | 0 | 6 | 0 | 16 | 0 | 6 | 0 | 1 |
| dev.pollito.spring_kotlin.sakila.film.domain.service | 100% | n/a | 0 | 2 | 0 | 15 | 0 | 2 | 0 | 1 |
| Total | 96% | 83% | 3 | 23 | 1 | 55 | 2 | 20 | 0 | 5 |
| Package / Class | Coverage | Complexity | Lines | Methods | Classes | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Instr. % | Branch % | Missed | Total | Missed | Total | Missed | Total | Missed | Total | |
| dev.pollito.spring_groovy.common.util | 87% | 100% | 1 | 3 | 1 | 5 | 1 | 2 | 0 | 1 |
| dev.pollito.spring_groovy.config.web | 92% | 75% | 1 | 9 | 2 | 26 | 0 | 7 | 0 | 1 |
| dev.pollito.spring_groovy.sakila.film.adapter.in.rest | 100% | n/a | 0 | 8 | 0 | 19 | 0 | 8 | 0 | 2 |
| dev.pollito.spring_groovy.sakila.film.domain.service | 100% | n/a | 0 | 2 | 0 | 14 | 0 | 2 | 0 | 1 |
| Total | 95% | 83% | 2 | 22 | 3 | 64 | 1 | 19 | 0 | 5 |
JaCoCo funciona a nivel de bytecode, pero Groovy genera bytecode significativamente diferente al que escribís en código fuente. Para proyectos Groovy (especialmente con Spring Boot), deberías generalmente ignorar las columnas "Instructions" y "Branches" y enfocarte casi exclusivamente en Line Coverage.
Acá está lo que significa cada columna en un reporte de cobertura de JaCoCo:
| Columna | Significado | Notas / Cuándo Importar |
|---|---|---|
| Name | El package, clase, archivo, o método siendo medido | La fila Total agrega todas las entradas |
| Instruction Coverage | Porcentaje de instrucciones de bytecode JVM ejecutadas | Muy detallado; puede ser engañoso para Groovy |
| Branch Coverage | Porcentaje de branches condicionales ejecutados (if, switch, loops) | Genial para atrapar caminos de lógica no testeados |
| Missed Complexity | Complejidad ciclomática no cubierta por tests | Indica flujo de control riesgoso o no testeado |
| Total Complexity | Complejidad ciclomática total del código | Valores más altos significan lógica más compleja |
| Missed Lines | Número de líneas ejecutables de código no corrida | Basado en líneas con bytecode |
| Total Lines | Total de líneas ejecutables de código | Métrica de coverage más intuitiva |
| Missed Methods | Métodos nunca invocados por tests | Bueno para detectar APIs no testeadas |
| Total Methods | Número total de métodos | Incluye constructores y lambdas |
| Missed Classes | Clases nunca cargadas durante tests | Indicador de completitud de tests de alto nivel |
| Total Classes | Número total de clases | Incluye clases internas y anónimas |
Ahora tenemos tests unitarios para lógica de dominio y utilidades enum, tests de segmento para la capa web, y seguimiento de cobertura con JaCoCo en cada build.