Primeros tests
testing.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 fricción.
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
- Java
- Kotlin
- Groovy
spring_java/
├── SpringJavaApplication.java ← 🔴 @SpringBootTest (context loads)
├── config/
│ ├── advice/
│ │ └── ControllerAdvice.java ← 🔶 Standalone MockMvc
│ ├── log/
│ │ ├── LogAspect.java ┐
│ │ ├── LogFilter.java │ 🔴 @SpringBootTest (single test)
│ │ ├── MaskingPatternLayout.java │
│ │ └── TraceIdFilter.java ┘
│ └── mapper/
│ └── MapperSpringConfig.java ← ⚪ Ignored (Config)
└── sakila/
└── film/
├── adapter/in/rest/
│ ├── FilmRestMapper.java ← ⚪ Ignored (Inteface)
│ └── FilmRestController.java ← 🟡 @WebMvcTest
└── domain/
├── model/
│ └── Film.java ← ⚪ Ignored (POJO)
└── port/in/
├── FindByIdPortIn.java ← ⚪ Ignored (Interface)
└── FindByIdPortInImpl.java ← 🟢 Unit Test
spring_kotlin/
├── SpringKotlinApplication.kt ← 🔴 @SpringBootTest (context loads)
├── config/
│ ├── advice/
│ │ └── ControllerAdvice.kt ← 🔶 Standalone MockMvc
│ ├── log/
│ │ ├── LogAspect.kt ┐
│ │ ├── LogFilter.kt │ 🔴 @SpringBootTest (single test)
│ │ ├── MaskingPatternLayout.kt │
│ │ └── TraceIdFilter.kt ┘
│ └── mapper/
│ └── MapperSpringConfig.kt ← ⚪ Ignored (Config)
└── sakila/
└── film/
├── adapter/in/rest/
│ ├── FilmRestMapper.kt ← ⚪ Ignored (Interface)
│ └── FilmRestController.kt ← 🟡 @WebMvcTest
└── domain/
├── model/
│ └── Film.kt ← ⚪ Ignored (data class)
└── port/in/
├── FindByIdPortIn.kt ← ⚪ Ignored (Interface)
└── FindByIdPortInImpl.kt ← 🟢 Unit Test
spring_groovy/
├── SpringGroovyApplication.groovy ← 🔴 @SpringBootTest (context loads)
├── config/
│ ├── advice/
│ │ └── ControllerAdvice.groovy ← 🔶 Standalone MockMvc
│ ├── log/
│ │ ├── LogAspect.groovy ┐
│ │ ├── LogFilter.groovy │ 🔴 @SpringBootTest (single test)
│ │ ├── MaskingPatternLayout.groovy │
│ │ └── TraceIdFilter.groovy ┘
│ └── mapper/
│ └── ModelMapperConfig.groovy ← ⚪ Ignored (Config)
└── sakila/
└── film/
├── adapter/in/rest/
│ ├── FilmRestMapper.groovy ┐
│ └── FilmRestController.groovy ┘ 🟡 @WebMvcTest (single test)
└── domain/
├── model/
│ └── Film.groovy ← ⚪ Ignored (POJO)
└── port/in/
├── FindByIdPortIn.groovy ← ⚪ Ignored (Interface)
└── FindByIdPortInImpl.groovy ← 🟢 Unit Test
| Símbolo | Tipo de Prueba | Descripción |
|---|---|---|
| 🔴 | @SpringBootTest | Prueba de integración con contexto completo |
| 🟡 | @WebMvcTest | Prueba de capa web (slice test) |
| 🔶 | MockMvc Standalone | MockMvc sin contexto completo de Spring |
| 🟢 | Prueba Unitaria | Prueba unitaria pura |
| ⚪ | Ignorado | No requiere prueba (config/interfaz/POJO) |
◀─────────────────── ESPECTRO DE PRUEBAS ───────────────────▶
UNITARIO INTEGRACIÓN
RÁPIDO LENTO
AISLADO CONECTADO
🟢──────────🔶──────────🟡──────────🔴
│ │ │ │
│ │ │ └── Spring Boot Completo
│ │ └── Solo Capa Web
│ └── MockMvc Independiente
└── Prueba Unitaria Pura
⚪ = Fuera del espectro (no necesita prueba)
- Java
- Kotlin
- Groovy
├── build.gradle
└── src/
├── main/java/.../spring_java/
│ └── config/advice/
│ └── ControllerAdvice.java
└── test/java/.../spring_java/
├── SanityCheckSpringBootTest.java
├── config/advice/
│ └── ControllerAdviceMockMvcTest.java
├── sakila/film/
│ ├── adapter/in/rest/
│ │ └── FilmRestControllerMockMvcTest.java
│ └── domain/port/in/
│ └── FindByIdPortInImplTest.java
└── test/util/
└── MockMvcResultMatchers.java
├── build.gradle.kts
└── src/
├── main/kotlin/.../spring_kotlin/
│ └── config/advice/
│ └── ControllerAdvice.kt
└── test/kotlin/.../spring_kotlin/
├── SanityCheckSpringBootTest.kt
├── config/advice/
│ └── ControllerAdviceMockMvcTest.kt
├── sakila/film/
│ ├── adapter/in/rest/
│ │ └── FilmRestControllerMockMvcTest.kt
│ └── domain/port/in/
│ └── FindByIdPortInImplTest.kt
└── test/util/
└── MockMvcResultMatchersDsl.kt
├── build.gradle
└── src/
├── main/groovy/.../spring_groovy/
│ └── config/advice/
│ └── ControllerAdvice.groovy
└── test/groovy/.../spring_groovy/
├── SanityCheckSpringBootSpec.groovy
├── config/advice/
│ └── ControllerAdviceMockMvcSpec.groovy
├── sakila/film/
│ ├── adapter/in/rest/
│ │ └── FilmRestControllerMockMvcSpec.groovy
│ └── domain/port/in/
│ └── FindByIdPortInImplSpec.groovy
└── test/util/
└── MockMvcResultMatchersTrait.groovy
Dependencias
- Java
- Kotlin
- Groovy
plugins {
// ...
id 'jacoco'
}
// ...
jacoco {
toolVersion = "0.8.14"
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}
classDirectories.setFrom(
files(classDirectories.files.collect {
fileTree(it) {
exclude(
// OpenAPI generated code
'**/generated/**',
// Application entry point
'**/*Application*',
// Domain models (POJOs)
'**/domain/model/**',
// MapStruct
'**/config/mapper/**',
'**/*MapperImpl*',
)
}
})
)
}
jacocoTestCoverageVerification {
dependsOn jacocoTestReport
classDirectories.setFrom(jacocoTestReport.classDirectories)
violationRules {
rule {
limit {
counter = 'LINE'
minimum = 0.8
}
limit {
counter = 'BRANCH'
minimum = 0.5
}
}
}
}
tasks.named('check') {
dependsOn jacocoTestCoverageVerification
}
tasks.named('test') {
useJUnitPlatform()
jvmArgs '-XX:+EnableDynamicAgentLoading'
jvmArgumentProviders.add({
def mockitoAgent = configurations.testRuntimeClasspath.resolvedConfiguration
.resolvedArtifacts
.find { it.name == 'mockito-core' }
?.file
mockitoAgent ? ["-javaagent:${mockitoAgent}"] : []
} as CommandLineArgumentProvider)
finalizedBy jacocoTestReport
}
// ...
plugins {
// ...
jacoco
}
// ...
dependencies {
// ...
testImplementation("com.ninja-squad:springmockk:5.0.1")
testImplementation("io.mockk:mockk:1.14.7")
}
// ...
tasks.withType<Test> {
useJUnitPlatform()
jvmArgs("-XX:+EnableDynamicAgentLoading", "-Xshare:off")
finalizedBy(tasks.jacocoTestReport)
}
jacoco { toolVersion = "0.8.14" }
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required.set(true)
html.required.set(true)
}
classDirectories.setFrom(
files(
classDirectories.files.map {
fileTree(it) {
exclude(
// OpenAPI generated code
"**/generated/**",
"**/openapitools/**",
// Application entry point
"**/*Application*",
// Domain models (POJOs)
"**/domain/model/**",
// MapStruct
"**/config/mapper/**",
"**/*MapperImpl*",
)
}
}
)
)
}
tasks.jacocoTestCoverageVerification {
dependsOn(tasks.jacocoTestReport)
violationRules {
rule {
limit {
counter = "LINE"
minimum = "0.8".toBigDecimal()
}
limit {
counter = "BRANCH"
minimum = "0.5".toBigDecimal()
}
}
}
classDirectories.setFrom(tasks.jacocoTestReport.get().classDirectories)
}
tasks.named("check") { dependsOn(tasks.jacocoTestCoverageVerification) }
// ...
plugins {
// ...
id 'jacoco'
}
// ...
dependencies {
// ...
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
testImplementation 'org.spockframework:spock-spring:2.4-groovy-5.0'
}
configurations.testImplementation {
exclude group: 'org.mockito'
}
tasks.named('test') {
useJUnitPlatform()
finalizedBy jacocoTestReport
}
jacoco {
toolVersion = "0.8.14"
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}
classDirectories.setFrom(
files(classDirectories.files.collect {
fileTree(it) {
exclude(
// OpenAPI generated code
'**/generated/**',
// Groovy Internal Artifacts
'**/*$*_closure*',
'**/*__*$*',
'**/*__*',
// Application entry point
'**/*Application*',
// Domain models (POJOs)
'**/domain/model/**',
// ModelMapper
'**/config/mapper/**',
)
}
})
)
}
jacocoTestCoverageVerification {
dependsOn jacocoTestReport
classDirectories.setFrom(jacocoTestReport.classDirectories)
violationRules {
rule {
limit {
counter = 'LINE'
minimum = 0.8
}
}
}
}
tasks.named('check') {
dependsOn jacocoTestCoverageVerification
}
// ...
🟢 Unit Tests
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.
FindByIdPortInImpl 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
package dev.pollito.spring_java.sakila.film.domain.port.in;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class FindByIdPortInImplTest {
@InjectMocks private FindByIdPortInImpl findByIdPortIn;
@Test
void findByIdReturnsADomainModel() {
assertNotNull(findByIdPortIn.findById(1));
}
}
package dev.pollito.spring_kotlin.sakila.film.domain.port.`in`
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.junit5.MockKExtension
import kotlin.test.Test
import kotlin.test.assertNotNull
import org.junit.jupiter.api.extension.ExtendWith
@ExtendWith(MockKExtension::class)
class FindByIdPortInImplTest {
@InjectMockKs private lateinit var findByIdPortInImpl: FindByIdPortInImpl
@Test
fun `findById returns a domain model`() {
assertNotNull(findByIdPortInImpl.findById(1))
}
}
package dev.pollito.spring_groovy.sakila.film.domain.port.in
import spock.lang.Specification
import spock.lang.Subject
class FindByIdPortInImplSpec extends Specification {
@Subject FindByIdPortInImpl findByIdPortIn = new FindByIdPortInImpl()
def "findById returns a domain model"() {
when: "findById is called"
def result = findByIdPortIn.findById(1)
then: "a domain model is returned"
result != null
}
}
🔶 🟡 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
package dev.pollito.spring_java.test.util;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.ResultMatcher;
public final class MockMvcResultMatchers {
private MockMvcResultMatchers() {}
public static ResultMatcher hasStandardApiResponseFields(
String expectedInstance, HttpStatus expectedStatus) {
return result -> {
jsonPath("$.instance").value(expectedInstance).match(result);
jsonPath("$.status").value(expectedStatus.value()).match(result);
jsonPath("$.timestamp").exists().match(result);
jsonPath("$.trace").exists().match(result);
};
}
public static ResultMatcher hasErrorFields(HttpStatus expectedStatus) {
return result -> jsonPath("$.title").value(expectedStatus.getReasonPhrase()).match(result);
}
}
package dev.pollito.spring_kotlin.test.util
import org.springframework.http.HttpStatus
import org.springframework.test.web.servlet.MockMvcResultMatchersDsl
fun MockMvcResultMatchersDsl.hasStandardApiResponseFields(
expectedInstance: String,
expectedStatus: HttpStatus,
) {
jsonPath("$.instance") { value(expectedInstance) }
jsonPath("$.status") { value(expectedStatus.value()) }
jsonPath("$.timestamp") { exists() }
jsonPath("$.trace") { exists() }
}
fun MockMvcResultMatchersDsl.hasErrorFields(extectedStatus: HttpStatus) {
jsonPath("$.title") { value(extectedStatus.reasonPhrase) }
}
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.
package dev.pollito.spring_groovy.test.util
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.http.HttpStatus
import org.springframework.test.web.servlet.ResultMatcher
trait MockMvcResultMatchersTrait {
ResultMatcher hasStandardApiResponseFields(String expectedInstance, HttpStatus expectedStatus) {
{ result ->
jsonPath('$.instance').value(expectedInstance).match(result)
jsonPath('$.status').value(expectedStatus.value()).match(result)
jsonPath('$.timestamp').exists().match(result)
jsonPath('$.trace').exists().match(result)
} as ResultMatcher
}
ResultMatcher hasErrorFields(HttpStatus expectedStatus) {
{ result ->
jsonPath('$.title').value(expectedStatus.reasonPhrase).match(result)
} as ResultMatcher
}
}
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
package dev.pollito.spring_java.sakila.film.adapter.in.rest;
import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasErrorFields;
import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasStandardApiResponseFields;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.*;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.pollito.spring_java.config.advice.ControllerAdvice;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FindByIdPortIn;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(FilmRestController.class)
@Import({ControllerAdvice.class, FilmRestMapperImpl.class})
class FilmRestControllerMockMvcTest {
private static final String FILMS_PATH = "/api/films";
private static final String FILM_BY_ID_TEMPLATE = FILMS_PATH + "/{id}";
@SuppressWarnings("unused")
@Autowired
private MockMvc mockMvc;
@SuppressWarnings("unused")
@MockitoBean
private FindByIdPortIn findByIdPortIn;
@SuppressWarnings("unused")
@MockitoSpyBean
private FilmRestMapper mapper;
private static String filmPath(Integer id) {
return FILMS_PATH + "/" + id;
}
@Test
void findByIdReturnsOK() throws Exception {
Integer filmId = 1;
Film film = mock(Film.class);
when(film.getId()).thenReturn(filmId);
when(findByIdPortIn.findById(anyInt())).thenReturn(film);
mockMvc
.perform(get(FILM_BY_ID_TEMPLATE, filmId).accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(filmPath(filmId), OK))
.andExpect(jsonPath("$.data.id").value(filmId));
}
@Test
void findByIdWithInvalidIdReturnsBAD_REQUEST() throws Exception {
Integer invalidId = 0;
HttpStatus status = BAD_REQUEST;
mockMvc
.perform(get(FILM_BY_ID_TEMPLATE, invalidId).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(filmPath(invalidId), status))
.andExpect(hasErrorFields(status));
}
@Test
void findAllReturnsINTERNAL_SERVER_ERROR() throws Exception {
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(get(FILMS_PATH).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(FILMS_PATH, status))
.andExpect(hasErrorFields(status));
}
}
package dev.pollito.spring_kotlin.sakila.film.adapter.`in`.rest
import com.ninjasquad.springmockk.MockkBean
import dev.pollito.spring_kotlin.config.advice.ControllerAdvice
import dev.pollito.spring_kotlin.sakila.film.domain.model.Film
import dev.pollito.spring_kotlin.sakila.film.domain.port.`in`.FindByIdPortIn
import dev.pollito.spring_kotlin.test.util.hasErrorFields
import dev.pollito.spring_kotlin.test.util.hasStandardApiResponseFields
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR
import org.springframework.http.HttpStatus.OK
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
@WebMvcTest(FilmRestController::class)
@Import(ControllerAdvice::class, FilmRestMapperImpl::class)
class FilmRestControllerMockMvcTest {
companion object {
private const val API_FILMS = "/api/films"
}
@MockkBean private lateinit var findByIdPortIn: FindByIdPortIn
@Autowired private lateinit var mockMvc: MockMvc
@Test
fun `findById returns OK`() {
val filmId = 1
val film = mockk<Film>(relaxed = true)
every { film.id } returns filmId
every { findByIdPortIn.findById(filmId) } returns film
mockMvc
.get("$API_FILMS/$filmId") { accept = APPLICATION_JSON }
.andExpect {
status { isOk() }
jsonPath("$.data.id") { value(filmId) }
hasStandardApiResponseFields("$API_FILMS/$filmId", OK)
}
}
@Test
fun `findById with invalid id returns BAD_REQUEST`() {
val invalidId = 0L
mockMvc
.get("$API_FILMS/$invalidId") { accept = APPLICATION_JSON }
.andExpect {
status { isBadRequest() }
hasStandardApiResponseFields("$API_FILMS/$invalidId", BAD_REQUEST)
hasErrorFields(BAD_REQUEST)
}
}
@Test
fun `findAll returns INTERNAL_SERVER_ERROR`() {
mockMvc
.get(API_FILMS) { accept = APPLICATION_JSON }
.andExpect {
status { isInternalServerError() }
hasStandardApiResponseFields(API_FILMS, INTERNAL_SERVER_ERROR)
hasErrorFields(INTERNAL_SERVER_ERROR)
}
}
}
package dev.pollito.spring_groovy.sakila.film.adapter.in.rest
import static org.springframework.http.HttpStatus.*
import static org.springframework.http.MediaType.APPLICATION_JSON
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import dev.pollito.spring_groovy.config.advice.ControllerAdvice
import dev.pollito.spring_groovy.config.mapper.ModelMapperConfig
import dev.pollito.spring_groovy.sakila.film.domain.model.Film
import dev.pollito.spring_groovy.sakila.film.domain.port.in.FindByIdPortIn
import dev.pollito.spring_groovy.test.util.MockMvcResultMatchersTrait
import org.spockframework.spring.SpringBean
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.context.annotation.Import
import org.springframework.test.web.servlet.MockMvc
import spock.lang.Specification
@WebMvcTest(FilmRestController)
@Import([ControllerAdvice, FilmRestMapper, ModelMapperConfig])
class FilmRestControllerMockMvcSpec extends Specification implements MockMvcResultMatchersTrait {
private static final String FILMS_PATH = "/api/films"
private static final String FILM_BY_ID_TEMPLATE = FILMS_PATH + "/{id}"
@Autowired
MockMvc mockMvc
@SpringBean
FindByIdPortIn findByIdPortIn = Mock()
private static String filmPath(Integer id) {
"${FILMS_PATH}/${id}"
}
def "findById returns OK"() {
given: "a mocked domain model and primary port behavior"
def filmId = 1
def film = Stub(Film) {getId() >> filmId}
findByIdPortIn.findById(filmId) >> film
when: "findById is requested"
def result = mockMvc.perform(
get(FILM_BY_ID_TEMPLATE, filmId)
.accept(APPLICATION_JSON)
)
then: "response is OK"
result
.andExpect(status().isOk())
.andExpect(content().contentType(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(filmPath(filmId), OK))
.andExpect(jsonPath('$.data.id').value(filmId))
}
def "findById with invalid id returns BAD_REQUEST"() {
given: "an invalid film id"
def invalidId = 0
when: "findById is requested"
def result = mockMvc.perform(
get(FILM_BY_ID_TEMPLATE, invalidId)
.accept(APPLICATION_JSON)
)
then: "response is BAD_REQUEST"
result
.andExpect(hasStandardApiResponseFields(filmPath(invalidId), BAD_REQUEST))
.andExpect(hasErrorFields(BAD_REQUEST))
}
def "findAll returns INTERNAL_SERVER_ERROR"() {
when: "findAll is requested"
def result = mockMvc.perform(
get(FILMS_PATH)
.accept(APPLICATION_JSON)
)
then: "response is INTERNAL_SERVER_ERROR"
result
.andExpect(hasStandardApiResponseFields(FILMS_PATH, INTERNAL_SERVER_ERROR))
.andExpect(hasErrorFields(INTERNAL_SERVER_ERROR))
}
}
Cuando corremos los tests, encontramos que este falla:
- Java
- Kotlin
- Groovy
JSON path "$.status"
Expected :400
Actual :500
<Click to see difference>
java.lang.AssertionError: JSON path "$.status" expected:<400> but was:<500>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:62)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:129)
at org.springframework.test.util.JsonPathExpectationsHelper.assertValue(JsonPathExpectationsHelper.java:172)
at org.springframework.test.web.servlet.result.JsonPathResultMatchers.lambda$value$2(JsonPathResultMatchers.java:111)
at dev.pollito.spring_java.test.util.MockMvcResultMatchers.lambda$hasStandardApiResponseFields$0(MockMvcResultMatchers.java:19)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:212)
at dev.pollito.spring_java.sakila.film.adapter.in.rest.FilmControllerTest.whenFindByIdWithInvalidId_thenReturnsBadRequest(FilmControllerTest.java:72)
Status
Expected :400
Actual :500
<Click to see difference>
java.lang.AssertionError: Status expected:<400> but was:<500>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:62)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:129)
at org.springframework.test.web.servlet.result.StatusResultMatchers.lambda$matcher$0(StatusResultMatchers.java:600)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:212)
at org.springframework.test.web.servlet.result.StatusResultMatchersDsl.isBadRequest(StatusResultMatchersDsl.kt:243)
at dev.pollito.spring_kotlin.sakila.film.adapter.in.rest.FilmControllerTest.when_find_by_invalid_id_then_returns_bad_request$lambda$1$0(FilmControllerTest.kt:63)
at org.springframework.test.web.servlet.MockMvcResultMatchersDsl.status(MockMvcResultMatchersDsl.kt:97)
at dev.pollito.spring_kotlin.sakila.film.adapter.in.rest.FilmControllerTest.when_find_by_invalid_id_then_returns_bad_request$lambda$1(FilmControllerTest.kt:63)
Condition failed with Exception:
result .andExpect(hasStandardApiResponseFields(filmPath(invalidId), BAD_REQUEST)) .andExpect(hasErrorFields(BAD_REQUEST))
| | | | | |
| | | | 0 400 BAD_REQUEST
| | | /api/films/0
| | dev.pollito.spring_groovy.test.util.MockMvcResultMatchersTrait$Trait$Helper$_hasStandardApiResponseFields_closure1@11577ab8
| java.lang.AssertionError: JSON path "$.status" expected:<400> but was:<500>
| at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:62)
| at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:129)
| at org.springframework.test.util.JsonPathExpectationsHelper.assertValue(JsonPathExpectationsHelper.java:172)
| at org.springframework.test.web.servlet.result.JsonPathResultMatchers.lambda$value$2(JsonPathResultMatchers.java:111)
| at dev.pollito.spring_groovy.test.util.MockMvcResultMatchersTrait$Trait$Helper.hasStandardApiResponseFields_closure1(MockMvcResultMatchersTrait.groovy:13)
| at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:212)
| at dev.pollito.spring_groovy.sakila.film.adapter.in.rest.FilmControllerSpec.when findById with invalid id then returns bad request(FilmControllerSpec.groovy:75)
<org.springframework.test.web.servlet.MockMvc$1@73fe7483 val$mvcResult=inaccessible this$0=inaccessible>
Esperábamos un 400 (Bad Request) cuando pasamos un ID inválido (0), pero obtuvimos un 500. Esto nos dice que nuestro ControllerAdvice no está manejando ConstraintViolationException correctamente. Está siendo atrapado por el manejador genérico de Exception y devolviendo un estado 500.
Arreglemos el ControllerAdvice agregando un manejador explícito para ConstraintViolationException que devuelve BAD_REQUEST (400) en vez de dejarlo pasar al manejador genérico de 500:
- Java
- Kotlin
- Groovy
// ...
import jakarta.validation.ConstraintViolationException;
// ...
public class ControllerAdvice {
// ...
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Error> handle(ConstraintViolationException e) {
// ...
}
}
// ...
import jakarta.validation.ConstraintViolationException
// ...
class ControllerAdvice(private val request: HttpServletRequest) {
// ...
@ExceptionHandler(ConstraintViolationException::class)
fun handle(e: ConstraintViolationException): ResponseEntity<Error> {
// ...
}
}
// ...
import jakarta.validation.ConstraintViolationException
// ...
class ControllerAdvice {
// ...
@ExceptionHandler(ConstraintViolationException)
ResponseEntity<Error> handle(ConstraintViolationException e) {
// ...
}
}
Después de este arreglo, todos los tests pasan. Ahora tenemos confianza de que nuestro controller maneja correctamente los caminos felices, errores de validación, y excepciones inesperadas.
🔶 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
package dev.pollito.spring_java.config.advice;
import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasErrorFields;
import static dev.pollito.spring_java.test.util.MockMvcResultMatchers.hasStandardApiResponseFields;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpStatus.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import java.util.Set;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.resource.NoResourceFoundException;
class ControllerAdviceMockMvcTest {
private MockMvc mockMvc;
private final HttpServletRequest request = mock(HttpServletRequest.class);
@RestController
@RequestMapping("/fake")
private static class FakeController {
@GetMapping("/not-found")
@SuppressWarnings("unused")
public void throwNoResourceFoundException() throws NoResourceFoundException {
throw new NoResourceFoundException(GET, "/fake", "no-resource-found");
}
@GetMapping("/error")
@SuppressWarnings("unused")
public void throwException() throws Exception {
throw new Exception("Test exception");
}
@GetMapping("/bad-request")
@SuppressWarnings("unused")
public void throwConstraintViolationException() {
throw new ConstraintViolationException("Constraint violation", Set.of());
}
}
@BeforeEach
void setUp() {
mockMvc =
standaloneSetup(new FakeController())
.setControllerAdvice(new ControllerAdvice(request))
.build();
}
static @NonNull Stream<Arguments> testCases() {
return Stream.of(
Arguments.of("/fake/not-found", NOT_FOUND),
Arguments.of("/fake/error", INTERNAL_SERVER_ERROR),
Arguments.of("/fake/bad-request", BAD_REQUEST));
}
@ParameterizedTest
@MethodSource("testCases")
void exceptionHandlingReturnsCorrectStatus(String path, @NonNull HttpStatus expectedStatus)
throws Exception {
when(request.getRequestURI()).thenReturn(path);
mockMvc
.perform(get(path))
.andExpect(status().is(expectedStatus.value()))
.andExpect(hasStandardApiResponseFields(path, expectedStatus))
.andExpect(hasErrorFields(expectedStatus));
}
}
package dev.pollito.spring_kotlin.config.advice
import dev.pollito.spring_kotlin.test.util.hasErrorFields
import dev.pollito.spring_kotlin.test.util.hasStandardApiResponseFields
import io.mockk.every
import io.mockk.mockk
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.ConstraintViolationException
import java.util.stream.Stream
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.http.HttpMethod.GET
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatus.*
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.servlet.resource.NoResourceFoundException
class ControllerAdviceMockMvcTest {
private lateinit var mockMvc: MockMvc
private val request = mockk<HttpServletRequest>()
@RestController
@RequestMapping("/fake")
class FakeController {
@GetMapping("/not-found")
fun throwNoResourceFoundException() {
throw NoResourceFoundException(GET, "/fake/not-found", "no-resource-found")
}
@GetMapping("/error")
fun throwException() {
throw Exception("Test exception")
}
@GetMapping("/bad-request")
fun throwConstraintViolationException() {
throw ConstraintViolationException("Constraint violation", emptySet())
}
}
companion object {
@JvmStatic
fun testCases(): Stream<Arguments> =
Stream.of(
Arguments.of("/fake/not-found", NOT_FOUND),
Arguments.of("/fake/error", INTERNAL_SERVER_ERROR),
Arguments.of("/fake/bad-request", BAD_REQUEST),
)
}
@BeforeEach
fun setUp() {
mockMvc =
standaloneSetup(FakeController()).setControllerAdvice(ControllerAdvice(request)).build()
}
@ParameterizedTest(name = "{1}")
@MethodSource("testCases")
fun `exception handling returns correct status`(expectedInstance: String, status: HttpStatus) {
every { request.requestURI } returns expectedInstance
mockMvc.get(expectedInstance).andExpect {
status { isEqualTo(status.value()) }
hasStandardApiResponseFields(expectedInstance, status)
hasErrorFields(status)
}
}
}
package dev.pollito.spring_groovy.config.advice
import static org.springframework.http.HttpMethod.GET
import static org.springframework.http.HttpStatus.*
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup
import dev.pollito.spring_groovy.test.util.MockMvcResultMatchersTrait
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.ConstraintViolationException
import org.springframework.test.web.servlet.MockMvc
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.servlet.resource.NoResourceFoundException
import spock.lang.Specification
import spock.lang.Unroll
class ControllerAdviceMockMvcSpec extends Specification implements MockMvcResultMatchersTrait {
MockMvc mockMvc
HttpServletRequest request = Mock()
@RestController
@RequestMapping("/fake")
static class FakeController {
@GetMapping("/not-found")
@SuppressWarnings("unused")
static void throwNoResourceFoundException() throws NoResourceFoundException {
throw new NoResourceFoundException(GET, "/fake", "no-resource-found")
}
@GetMapping("/error")
@SuppressWarnings("unused")
static void throwException() throws Exception {
throw new Exception("Test exception")
}
@GetMapping("/bad-request")
@SuppressWarnings("unused")
static void throwConstraintViolationException() {
throw new ConstraintViolationException("Constraint violation", Set.of())
}
}
def setup() {
mockMvc = standaloneSetup(new FakeController())
.setControllerAdvice(new ControllerAdvice(request))
.build()
}
@Unroll
def "#exceptionType returns #httpStatus"() {
given:
request.getRequestURI() >> endpoint
expect:
mockMvc.perform(get(endpoint))
.andExpect(status().is(httpStatus.value()))
.andExpect(hasStandardApiResponseFields(endpoint, httpStatus))
.andExpect(hasErrorFields(httpStatus))
where:
endpoint | httpStatus || exceptionType
"/fake/not-found" | NOT_FOUND || "NoResourceFoundException"
"/fake/error" | INTERNAL_SERVER_ERROR || "Exception"
"/fake/bad-request" | BAD_REQUEST || "ConstraintViolationException"
}
}
🔴 Integration Tests
Este test de integración levanta todo el contexto de la aplicación Spring Boot para verificar que todas las piezas encajan y funcionan como se espera en un entorno de ejecución real.
A diferencia de los slice tests (@WebMvcTest) que solo cargan partes específicas de la aplicación, este test con @SpringBootTest carga todo: todos los beans, configuraciones, filtros, aspectos y el cableado completo. Es nuestro chequeo de cordura de que la aplicación realmente arranca y se comporta correctamente de extremo a extremo.
Por ahora, estamos usando este test de integración para verificar que nuestra infraestructura de logging funciona a lo largo de todo el ciclo de vida de la petición: desde el momento en que llega una petición (LogFilter), pasando por la ejecución de métodos (LogAspect), incluyendo preocupaciones de seguridad como el enmascaramiento de headers sensibles (MaskingPatternLayout), y la configuración de trazabilidad distribuida (TraceIdFilter).
- Java
- Kotlin
- Groovy
package dev.pollito.spring_java;
import static java.util.regex.Pattern.compile;
import static java.util.regex.Pattern.quote;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(OutputCaptureExtension.class)
class SanityCheckSpringBootTest {
@SuppressWarnings("unused")
@Autowired
private MockMvc mockMvc;
record TestCase(
@NonNull HttpMethod method,
@NonNull String url,
@NonNull List<Object> pathParams,
@NonNull Map<String, String> headers,
@NonNull Map<String, String> queryParams,
@Nullable String requestBody) {}
static @NonNull Stream<TestCase> sanityCheckTestCases() {
return Stream.of(
new TestCase(
HttpMethod.GET,
"/api/films/{id}",
List.of(1),
/* we don't have yet any endpoint with sensible headers to mask, so let's use this one for now */
Map.of("Authorization", "Bearer secret-token", "X-Api-Key", "my-secret-key"),
Collections.emptyMap(),
null));
}
private MockHttpServletRequestBuilder buildRequest(
@NonNull HttpMethod method, @NonNull String url, @NonNull List<Object> pathParams) {
Object[] params = pathParams.toArray();
return switch (method.name()) {
case "GET" -> get(url, params);
case "POST" -> post(url, params);
case "PUT" -> put(url, params);
case "PATCH" -> patch(url, params);
case "DELETE" -> delete(url, params);
default -> throw new IllegalArgumentException("Unsupported HTTP method: " + method);
};
}
private String resolvePathParameters(@NonNull String url, @NonNull List<Object> pathParams) {
String resolved = url;
for (Object param : pathParams) {
resolved = resolved.replaceFirst("\{[^}]+}", String.valueOf(param));
}
return resolved;
}
private long countMatches(@NonNull String text, @NonNull String regex) {
return compile(regex).matcher(text).results().count();
}
private void assertLogFilterOutput(
@NonNull String logOutput, @NonNull HttpMethod method, @NonNull String url) {
String methodAndUri = String.format(">>>> Method: %s; URI: %s", method.name(), url);
assert countMatches(
logOutput, quote(methodAndUri) + "; QueryString: [^;\n]*; Headers: \{[^\n]*}")
== 1
: "LogFilter should log request details with method, URI, QueryString, and Headers exactly once";
assert countMatches(logOutput, "<<<< Response Status: \d+") == 1
: "LogFilter should log response status exactly once";
}
private void assertLogAspectOutput(@NonNull String logOutput) {
assert countMatches(logOutput, "\[[\w.]+\([..]*\)] Args: \[") == 1
: "LogAspect should log args with format [ClassName.methodName(..)] Args: [...] exactly once";
assert countMatches(logOutput, "\[[\w.]+\([..]*\)] Response: <") == 1
: "LogAspect should log response with format [ClassName.methodName(..)] Response: <...> exactly once";
}
private void assertMaskingPatternLayoutOutput(@NonNull String logOutput) {
if (logOutput.contains("Authorization:") || logOutput.contains("X-Api-Key:")) {
assert !logOutput.contains("secret-token")
: "MaskingPatternLayout should mask Authorization value";
assert !logOutput.contains("my-secret-key")
: "MaskingPatternLayout should mask X-Api-Key value";
assert logOutput.contains("Authorization: ****")
: "MaskingPatternLayout should show masked Authorization";
assert logOutput.contains("X-Api-Key: ****")
: "MaskingPatternLayout should show masked X-Api-Key";
}
}
private void assertTraceIdFilterOutput(@NonNull String logOutput) {
assert logOutput.matches("(?s).*(trace_id=|trace_id=[a-f0-9]{32}).*")
: "TraceIdFilter should add trace_id to MDC (if present, must be exactly 32 hex characters)";
assert logOutput.matches("(?s).*(span_id=|span_id=[a-f0-9]{16}).*")
: "TraceIdFilter should add span_id to MDC (if present, must be exactly 16 hex characters)";
assert logOutput.matches("(?s).*trace_flags=(|00|01).*")
: "TraceIdFilter should add trace_flags to MDC (empty, 00, or 01)";
}
@ParameterizedTest
@MethodSource("sanityCheckTestCases")
void sanityCheck(@NonNull TestCase testCase, @NonNull CapturedOutput output) throws Exception {
MockHttpServletRequestBuilder requestBuilder =
buildRequest(testCase.method(), testCase.url(), testCase.pathParams());
testCase.headers().forEach(requestBuilder::header);
testCase.queryParams().forEach(requestBuilder::param);
if (testCase.requestBody() != null) {
requestBuilder.content(testCase.requestBody()).contentType(APPLICATION_JSON);
}
mockMvc.perform(requestBuilder.accept(APPLICATION_JSON));
String logOutput = output.getOut();
assertLogFilterOutput(
logOutput, testCase.method(), resolvePathParameters(testCase.url(), testCase.pathParams()));
assertLogAspectOutput(logOutput);
assertMaskingPatternLayoutOutput(logOutput);
assertTraceIdFilterOutput(logOutput);
}
}
package dev.pollito.spring_kotlin
import java.util.regex.Pattern
import java.util.regex.Pattern.compile
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.put
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(OutputCaptureExtension::class)
class SanityCheckSpringBootTest {
@Autowired private lateinit var mockMvc: MockMvc
data class TestCase(
val method: HttpMethod,
val url: String,
val pathParams: List<Any> = emptyList(),
val headers: Map<String, String> = emptyMap(),
val queryParams: Map<String, String> = emptyMap(),
val requestBody: String? = null,
)
companion object {
@JvmStatic
fun sanityCheckTestCases(): List<TestCase> =
listOf(
TestCase(
method = HttpMethod.GET,
url = "/api/films/{id}",
pathParams = listOf(1),
headers =
mapOf(
"Authorization" to "Bearer secret-token",
"X-Api-Key" to "my-secret-key",
),
)
)
}
private fun resolvePathParameters(url: String, pathParams: List<Any>): String {
var resolved = url
for (param in pathParams) {
resolved = resolved.replaceFirst("\{[^}]+}".toRegex(), param.toString())
}
return resolved
}
private fun countMatches(text: String, regex: String): Long =
compile(regex).matcher(text).results().count()
private fun assertLogFilterOutput(logOutput: String, method: HttpMethod, url: String) {
val methodAndUri = ">>>> Method: ${method.name()}; URI: $url"
assert(
countMatches(
logOutput,
Pattern.quote(methodAndUri) + "; QueryString: [^;\n]*; Headers: \{[^\n]*}",
) == 1L
) {
"LogFilter should log request details with method, URI, QueryString, and Headers exactly once"
}
assert(countMatches(logOutput, "<<<< Response Status: \d+") == 1L) {
"LogFilter should log response status exactly once"
}
}
private fun assertLogAspectOutput(logOutput: String) {
assert(countMatches(logOutput, "\[[\w.]+\([..]*\)] Args: \[") == 1L) {
"LogAspect should log args with format [ClassName.methodName(..)] Args: [...] exactly once"
}
assert(countMatches(logOutput, "\[[\w.]+\([..]*\)] Response: <") == 1L) {
"LogAspect should log response with format [ClassName.methodName(..)] Response: <...> exactly once"
}
}
private fun assertMaskingPatternLayoutOutput(logOutput: String) {
if (logOutput.contains("Authorization:") || logOutput.contains("X-Api-Key:")) {
assert(!logOutput.contains("secret-token")) {
"MaskingPatternLayout should mask Authorization value"
}
assert(!logOutput.contains("my-secret-key")) {
"MaskingPatternLayout should mask X-Api-Key value"
}
assert(logOutput.contains("Authorization: ****")) {
"MaskingPatternLayout should show masked Authorization"
}
assert(logOutput.contains("X-Api-Key: ****")) {
"MaskingPatternLayout should show masked X-Api-Key"
}
}
}
private fun assertTraceIdFilterOutput(logOutput: String) {
assert(logOutput.matches(Regex("(?s).*(trace_id=|trace_id=[a-f0-9]{32}).*"))) {
"TraceIdFilter should add trace_id to MDC (if present, must be exactly 32 hex characters)"
}
assert(logOutput.matches(Regex("(?s).*(span_id=|span_id=[a-f0-9]{16}).*"))) {
"TraceIdFilter should add span_id to MDC (if present, must be exactly 16 hex characters)"
}
assert(logOutput.matches(Regex("(?s).*trace_flags=(|00|01).*"))) {
"TraceIdFilter should add trace_flags to MDC (empty, 00, or 01)"
}
}
@ParameterizedTest
@MethodSource("sanityCheckTestCases")
fun sanityCheck(testCase: TestCase, output: CapturedOutput) {
val resolvedUrl = resolvePathParameters(testCase.url, testCase.pathParams)
val params = testCase.pathParams.toTypedArray()
mockMvc.perform(
testCase.method,
testCase.url,
params,
testCase.headers,
testCase.queryParams,
testCase.requestBody,
)
val logOutput = output.out
assertLogFilterOutput(logOutput, testCase.method, resolvedUrl)
assertLogAspectOutput(logOutput)
assertMaskingPatternLayoutOutput(logOutput)
assertTraceIdFilterOutput(logOutput)
}
private fun MockMvc.perform(
method: HttpMethod,
url: String,
pathParams: Array<Any>,
headers: Map<String, String>,
queryParams: Map<String, String>,
requestBody: String?,
) {
when (method.name()) {
"GET" ->
get(url, *pathParams) {
accept = APPLICATION_JSON
headers.forEach { (k, v) -> header(k, v) }
queryParams.forEach { (k, v) -> param(k, v) }
}
"POST" ->
post(url, *pathParams) {
accept = APPLICATION_JSON
headers.forEach { (k, v) -> header(k, v) }
queryParams.forEach { (k, v) -> param(k, v) }
requestBody?.let {
content = it
contentType = APPLICATION_JSON
}
}
"PUT" ->
put(url, *pathParams) {
accept = APPLICATION_JSON
headers.forEach { (k, v) -> header(k, v) }
queryParams.forEach { (k, v) -> param(k, v) }
requestBody?.let {
content = it
contentType = APPLICATION_JSON
}
}
"PATCH" ->
patch(url, *pathParams) {
accept = APPLICATION_JSON
headers.forEach { (k, v) -> header(k, v) }
queryParams.forEach { (k, v) -> param(k, v) }
requestBody?.let {
content = it
contentType = APPLICATION_JSON
}
}
"DELETE" ->
delete(url, *pathParams) {
accept = APPLICATION_JSON
headers.forEach { (k, v) -> header(k, v) }
queryParams.forEach { (k, v) -> param(k, v) }
}
else -> throw IllegalArgumentException("Unsupported HTTP method: $method")
}
}
}
package dev.pollito.spring_groovy
import static java.util.regex.Pattern.compile
import static java.util.regex.Pattern.quote
import static org.springframework.http.MediaType.APPLICATION_JSON
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc
import org.springframework.http.HttpMethod
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
import spock.lang.Specification
import spock.lang.Unroll
@SpringBootTest
@AutoConfigureMockMvc
class SanityCheckSpringBootSpec extends Specification {
@Autowired
MockMvc mockMvc
ByteArrayOutputStream outputCapture
PrintStream originalOut
PrintStream originalErr
def setup() {
outputCapture = new ByteArrayOutputStream()
originalOut = System.out
originalErr = System.err
def printStream = new PrintStream(outputCapture)
System.setOut(printStream)
System.setErr(printStream)
}
def cleanup() {
System.setOut(originalOut)
System.setErr(originalErr)
}
private static MockHttpServletRequestBuilder buildRequest(HttpMethod method, String url, List<Object> pathParams) {
Object[] params = pathParams.toArray()
switch (method.name()) {
case "GET": return get(url, params)
case "POST": return post(url, params)
case "PUT": return put(url, params)
case "PATCH": return patch(url, params)
case "DELETE": return delete(url, params)
default: throw new IllegalArgumentException("Unsupported HTTP method: ${method}")
}
}
private static String resolvePathParameters(String url, List<Object> pathParams) {
String resolved = url
for (param in pathParams) {
resolved = resolved.replaceFirst('\{[^}]+}', String.valueOf(param))
}
resolved
}
private static long countMatches(String text, String regex) {
compile(regex).matcher(text).results().count()
}
private static void assertLogFilterOutput(String logOutput, HttpMethod method, String url) {
String methodAndUri = ">>>> Method: ${method.name()}; URI: ${url}"
assert countMatches(
logOutput, quote(methodAndUri) + '; QueryString: [^;\n]*; Headers: \{[^\n]*}') == 1:
'LogFilter should log request details with method, URI, QueryString, and Headers exactly once'
assert countMatches(logOutput, '<<<< Response Status: \d+') == 1:
'LogFilter should log response status exactly once'
}
private static void assertLogAspectOutput(String logOutput) {
assert countMatches(logOutput, '\[[\w.]+\([..]*\)] Args: ') == 1:
'LogAspect should log args with format [ClassName.methodName(..)] Args: ... exactly once'
assert countMatches(logOutput, '\[[\w.]+\([..]*\)] Response: <') == 1:
'LogAspect should log response with format [ClassName.methodName(..)] Response: <...> exactly once'
}
private static void assertMaskingPatternLayoutOutput(String logOutput) {
if (logOutput.contains('Authorization:') || logOutput.contains('X-Api-Key:')) {
assert !logOutput.contains('secret-token'):
'MaskingPatternLayout should mask Authorization value'
assert !logOutput.contains('my-secret-key'):
'MaskingPatternLayout should mask X-Api-Key value'
assert logOutput.contains('Authorization: ****'):
'MaskingPatternLayout should show masked Authorization'
assert logOutput.contains('X-Api-Key: ****'):
'MaskingPatternLayout should show masked X-Api-Key'
}
}
private static void assertTraceIdFilterOutput(String logOutput) {
assert logOutput ==~ /(?s).*(trace_id=|trace_id=[a-f0-9]{32}).*/
assert logOutput ==~ /(?s).*(span_id=|span_id=[a-f0-9]{16}).*/
assert logOutput ==~ /(?s).*trace_flags=(|00|01).*/
}
@Unroll
def "sanityCheck #method #url"() {
given:
def requestBuilder = buildRequest(method, url, pathParams)
headers.each { k, v -> requestBuilder.header(k, v) }
queryParams.each { k, v -> requestBuilder.param(k, v) }
if (requestBody != null) {
requestBuilder.content(requestBody).contentType(APPLICATION_JSON)
}
when:
mockMvc.perform(requestBuilder.accept(APPLICATION_JSON))
then:
def logOutput = outputCapture.toString()
assertLogFilterOutput(logOutput, method, resolvePathParameters(url, pathParams))
assertLogAspectOutput(logOutput)
assertMaskingPatternLayoutOutput(logOutput)
assertTraceIdFilterOutput(logOutput)
// Reset output capture for next iteration
outputCapture.reset()
where:
method | url | pathParams | headers | queryParams | requestBody
HttpMethod.GET | "/api/films/{id}" | [1] | ["Authorization": "Bearer secret-token", "X-Api-Key": "my-secret-key"] as Map | [:] as Map | null
}
}
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.config.advice | 93% | 66% | 1 | 7 | 1 | 18 | 0 | 5 | 0 | 1 |
| dev.pollito.spring_java.config.log | 93% | 52% | 9 | 31 | 4 | 62 | 1 | 22 | 0 | 4 |
| dev.pollito.spring_java.sakila.film.adapter.in.rest | 100% | n/a | 0 | 2 | 0 | 7 | 0 | 2 | 0 | 1 |
| dev.pollito.spring_java.sakila.film.domain.port.in | 100% | n/a | 0 | 2 | 0 | 10 | 0 | 2 | 0 | 1 |
| Total | 94% | 55% | 10 | 42 | 5 | 97 | 1 | 31 | 0 | 7 |
| Package / Class | Coverage | Complexity | Lines | Methods | Classes | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Instr. % | Branch % | Missed | Total | Missed | Total | Missed | Total | Missed | Total | |
| dev.pollito.spring_kotlin.config.advice | 91% | 75% | 3 | 12 | 1 | 21 | 2 | 10 | 0 | 2 |
| dev.pollito.spring_kotlin.config.log | 93% | 57% | 15 | 38 | 3 | 56 | 3 | 24 | 0 | 6 |
| dev.pollito.spring_kotlin.sakila.film.adapter.in.rest | 100% | n/a | 0 | 3 | 0 | 13 | 0 | 3 | 0 | 1 |
| dev.pollito.spring_kotlin.sakila.film.domain.port.in | 100% | n/a | 0 | 2 | 0 | 9 | 0 | 2 | 0 | 1 |
| Total | 93% | 59% | 18 | 55 | 4 | 99 | 5 | 39 | 0 | 10 |
| Package / Class | Coverage | Complexity | Lines | Methods | Classes | |||||
|---|---|---|---|---|---|---|---|---|---|---|
| Instr. % | Branch % | Missed | Total | Missed | Total | Missed | Total | Missed | Total | |
| dev.polito.spring_groovy.config.advice | 94% | 75% | 1 | 8 | 2 | 24 | 0 | 6 | 0 | 1 |
| dev.polito.spring_groovy.config.log | 93% | 58% | 15 | 30 | 4 | 47 | 1 | 13 | 0 | 4 |
| dev.polito.spring_groovy.sakila.film.adapter.in.rest | 100% | n/a | 0 | 5 | 0 | 16 | 0 | 5 | 0 | 2 |
| dev.polito.spring_groovy.sakila.film.domain.port.in | 100% | n/a | 0 | 1 | 0 | 8 | 0 | 1 | 0 | 1 |
| Total | 95% | 60% | 19 | 47 | 6 | 96 | 1 | 25 | 0 | 8 |
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 |