Saltar al contenido principal

Primeros tests

Código Completo
El resultado final del código desarrollado en este documento se puede encontrar en el monorepo de GitHub springboot-demo-projects, en el/los commit(s) Testing Java, Testing Kotlin, Testing Groovy.

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:

Hexagonal Arch Simple Diagram 12d64fec5e72a98ed1fdf4a26df84e03
  • 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 @RestControllerAdvice se prueban con una prueba MockMvc Standalone 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.
◀─────────────────── ESPECTRO DE PRUEBAS ───────────────────▶

UNITARIO INTEGRACIÓN
RÁPIDO LENTO
AISLADO CONECTADO

⚪──────────⚪──────────⚪──────────⚪
│ │ │ │
│ │ │ └── Spring Boot Completo
│ │ └── Solo Capa Web
│ └── MockMvc Independiente
└── Prueba Unitaria Pura
Archivos a Crear/Modificar
File Tree
.
├── build.gradle
├── ...
└── src
├── main
│ └── java
│ └── dev
│ └── pollito
│ └── spring_java
│ ├── config
│ │ └── web
│ │ └── ControllerAdvice.java
│ └── ...
└── test
├── java
│ └── dev
│ └── pollito
│ └── spring_java
│ ├── common
│ │ └── util
│ │ └── EnumUtilsTest.java
│ ├── config
│ │ └── web
│ │ └── ControllerAdviceMockMvcTest.java
│ └── sakila
│ └── film
│ ├── adapter
│ │ └── in
│ │ └── rest
│ │ └── FilmRestControllerMockMvcTest.java
│ └── domain
│ └── service
│ └── FilmUseCasesImplTest.java
└── util
└── MockMvcResultMatchers.java

Dependencias

build.gradle (plugin block)
id 'jacoco'
build.gradle (new jacoco blocks + updated test block)
jacoco {
toolVersion = "0.8.14"
}

jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
}

classDirectories.setFrom(
files(classDirectories.files.collect {
fileTree(it) {
exclude(
// Generated code
'**/generated/**',
'**/openapitools/**',

// Application entry point
'**/*Application*',

// Domain models (POJOs)
'**/domain/model/**',

// Log
'**/log/**',

// MapStruct
'**/*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
}

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.

Manténlo real

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/dev/pollito/spring_java/sakila/film/domain/service/FilmUseCasesImplTest.java
package dev.pollito.spring_java.sakila.film.domain.service;

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 FilmUseCasesImplTest {
@InjectMocks private FilmUseCasesImpl useCases;

@Test
void getFilmReturnsADomainModel() {
assertNotNull(useCases.getFilm(1));
}
}

Test de la utilidad Enum

java/dev/pollito/spring_java/common/util/EnumUtilsTest.java
package dev.pollito.spring_java.common.util;

import static dev.pollito.spring_java.common.util.EnumUtils.fromValue;
import static org.junit.jupiter.api.Assertions.*;

import dev.pollito.spring_java.common.ValuedEnum;
import org.junit.jupiter.api.Test;

class EnumUtilsTest {

private enum Color implements ValuedEnum<String> {
RED("red"),
GREEN("green"),
BLUE("blue");

private final String value;

Color(String value) {
this.value = value;
}

@Override
public String getValue() {
return value;
}
}

@Test
void fromValue_returnsMatchingConstant() {
assertEquals(Color.RED, fromValue(Color.class, "red"));
assertEquals(Color.GREEN, fromValue(Color.class, "green"));
assertEquals(Color.BLUE, fromValue(Color.class, "blue"));
}

@Test
void fromValue_throwsIllegalArgumentExceptionForUnknownValue() {
IllegalArgumentException ex =
assertThrows(IllegalArgumentException.class, () -> fromValue(Color.class, "yellow"));
assertTrue(ex.getMessage().contains("Color"));
assertTrue(ex.getMessage().contains("yellow"));
}
}

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/dev/pollito/spring_java/test/util/MockMvcResultMatchers.java
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);
}
}

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/dev/pollito/spring_java/sakila/film/adapter/in/rest/FilmRestControllerMockMvcTest.java
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.delete;
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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import dev.pollito.spring_java.config.web.ControllerAdvice;
import dev.pollito.spring_java.sakila.film.domain.model.Film;
import dev.pollito.spring_java.sakila.film.domain.port.in.FilmUseCases;
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 {

public static final String PATH = "/api/films";
public static final String CONTENT_BODY =
"""
{
"title": "ACADEMY DINOSAUR",
"language": "English",
"rentalDuration": 3,
"rentalRate": 4.99,
"replacementCost": 20.99
}
""";

@SuppressWarnings("unused")
@Autowired
private MockMvc mockMvc;

@SuppressWarnings("unused")
@MockitoBean
private FilmUseCases filmUseCases;

@SuppressWarnings("unused")
@MockitoSpyBean
private FilmRestMapper mapper;

@Test
void getFilmReturnsOK() throws Exception {
Integer filmId = 1;
Film film = mock(Film.class);
when(film.getId()).thenReturn(filmId);

when(filmUseCases.getFilm(anyInt())).thenReturn(film);

mockMvc
.perform(get(PATH + "/{id}", filmId).accept(APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, OK));
}

@Test
void getFilmsReturnsINTERNAL_SERVER_ERROR() throws Exception {
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(get(PATH).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH, status))
.andExpect(hasErrorFields(status));
}

@Test
void createFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(
post(PATH).contentType(APPLICATION_JSON).content(CONTENT_BODY).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH, status))
.andExpect(hasErrorFields(status));
}

@Test
void deleteFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
Integer filmId = 1;
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(delete(PATH + "/{id}", filmId).accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, status))
.andExpect(hasErrorFields(status));
}

@Test
void updateFilmReturnsINTERNAL_SERVER_ERROR() throws Exception {
Integer filmId = 1;
HttpStatus status = INTERNAL_SERVER_ERROR;
mockMvc
.perform(
put(PATH + "/{id}", filmId)
.contentType(APPLICATION_JSON)
.content(CONTENT_BODY)
.accept(APPLICATION_JSON))
.andExpect(hasStandardApiResponseFields(PATH + "/" + filmId, status))
.andExpect(hasErrorFields(status));
}
}

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/dev/pollito/spring_java/config/web/ControllerAdviceMockMvcTest.java
package dev.pollito.spring_java.config.web;

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.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
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());
}

@GetMapping("/method-arg-not-valid")
@SuppressWarnings({"unused"})
public void throwMethodArgumentNotValidException() throws Exception {
throw new MethodArgumentNotValidException(
new MethodParameter(
FakeController.class.getMethod("throwMethodArgumentNotValidException"), -1),
mock(BindingResult.class));
}
}

@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),
Arguments.of("/fake/method-arg-not-valid", 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));
}
}

No te olvidés de actualizar la clase ControllerAdvice para manejar las excepciones testeadas.

java/dev/pollito/spring_java/config/web/ControllerAdvice.java (new exception handlers)
@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);
}

Ver el reporte de JaCoCo

Package / ClassCoverageComplexityLinesMethodsClasses
Instr. %Branch %MissedTotalMissedTotalMissedTotalMissedTotal
dev.pollito.spring_java.common.util100%100%03040101
dev.pollito.spring_java.config.web93%66%181190601
dev.pollito.spring_java.sakila.film.adapter.in.rest100%n/a050100501
dev.pollito.spring_java.sakila.film.domain.service100%n/a020160201
Total97%85%11814901404

Acá está lo que significa cada columna en un reporte de cobertura de JaCoCo:

ColumnaSignificadoNotas / Cuándo Importar
NameEl package, clase, archivo, o método siendo medidoLa fila Total agrega todas las entradas
Instruction CoveragePorcentaje de instrucciones de bytecode JVM ejecutadasMuy detallado; puede ser engañoso para Groovy
Branch CoveragePorcentaje de branches condicionales ejecutados (if, switch, loops)Genial para atrapar caminos de lógica no testeados
Missed ComplexityComplejidad ciclomática no cubierta por testsIndica flujo de control riesgoso o no testeado
Total ComplexityComplejidad ciclomática total del códigoValores más altos significan lógica más compleja
Missed LinesNúmero de líneas ejecutables de código no corridaBasado en líneas con bytecode
Total LinesTotal de líneas ejecutables de códigoMétrica de coverage más intuitiva
Missed MethodsMétodos nunca invocados por testsBueno para detectar APIs no testeadas
Total MethodsNúmero total de métodosIncluye constructores y lambdas
Missed ClassesClases nunca cargadas durante testsIndicador de completitud de tests de alto nivel
Total ClassesNúmero total de clasesIncluye 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.