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, bajo el tag 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

File Tree
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
SímboloTipo de PruebaDescripción
🔴@SpringBootTestPrueba de integración con contexto completo
🟡@WebMvcTestPrueba de capa web (slice test)
🔶MockMvc StandaloneMockMvc sin contexto completo de Spring
🟢Prueba UnitariaPrueba unitaria pura
IgnoradoNo 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)
Archivos a Crear/Modificar
FileTree
├── 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

Dependencias

build.gradle
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
}
// ...

🟢 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/dev/pollito/spring_java/sakila/film/domain/port/in/FindByIdPortInImplTest.java
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));
}
}

🔶 🟡 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.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));
}
}

Cuando corremos los tests, encontramos que este falla:

Terminal
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)

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/dev/pollito/spring_java/config/advice/ControllerAdvice.java
// ...
import jakarta.validation.ConstraintViolationException;
// ...
public class ControllerAdvice {
// ...
@ExceptionHandler(ConstraintViolationException.class)
public 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/dev/pollito/spring_java/config/advice/ControllerAdviceMockMvcTest.java
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));
}
}

🔴 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/dev/pollito/spring_java/SanityCheckSpringBootTest.java
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);
}
}

Ver el reporte de JaCoCo

Package / ClassCoverageComplexityLinesMethodsClasses
Instr. %Branch %MissedTotalMissedTotalMissedTotalMissedTotal
dev.pollito.spring_java.config.advice93%66%171180501
dev.pollito.spring_java.config.log93%52%93146212204
dev.pollito.spring_java.sakila.film.adapter.in.rest100%n/a02070201
dev.pollito.spring_java.sakila.film.domain.port.in100%n/a020100201
Total94%55%104259713107

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