
La gran mentira del line coverage
JaCoCo te dice que tienes un 92% de cobertura. El equipo lo celebra, SonarQube lo aprueba y el build pasa. Y, sin embargo, un cambio inocente en producción rompe un cálculo de descuentos que nadie detectó. La razón es que line coverage solo mide qué líneas se ejecutaron, no si los tests verifican el comportamiento de esas líneas.
Considera este servicio:
@Service
public class DescuentoService {
public BigDecimal calcular(BigDecimal importe, int antiguedadMeses) {
if (antiguedadMeses > 12) {
return importe.multiply(new BigDecimal("0.10"));
}
return BigDecimal.ZERO;
}
}
Un test "feliz" como este produce 100% de line coverage:
@Test
void calcula_descuento_para_cliente_antiguo() {
var resultado = service.calcular(new BigDecimal("100"), 24);
assertNotNull(resultado);
}
Pero el test no verifica el valor del descuento, ni el caso del límite, ni el caso por defecto. Si alguien cambia > 12 por >= 12, o 0.10 por 0.20, el test sigue pasando y la línea sigue cubierta.
Line coverage mide ejecución; mutation coverage mide verificación. Solo el segundo te dice si los tests harían sonar la alarma cuando el código cambia.
Qué es mutation testing
Mutation testing introduce mutaciones (cambios pequeños y deliberados) en el bytecode del código bajo test y vuelve a ejecutar la suite. Cada mutación debería romper al menos un test:
- Si algún test falla, la mutación está killed: la suite detecta el cambio.
- Si todos los tests pasan, la mutación está survived: la suite no detecta el cambio.
- Si la mutación entra en una rama no cubierta, está marcada como no coverage.
La métrica final es:
mutation coverage = mutaciones killed / mutaciones generadas
Un mutation coverage alto (>80%) indica que la suite verifica comportamiento real. Un valor bajo revela tests débiles, asserts ausentes o lógica no verificada.
graph LR
A[Codigo + Tests] --> B[PIT genera mutaciones]
B --> C[Ejecuta suite por mutacion]
C --> D{Algun test falla?}
D -- Si --> E[Killed: test detecta el cambio]
D -- No --> F[Survived: test ciego]
PIT: instalación con Maven
PIT es la herramienta más madura del ecosistema Java. Funciona sobre bytecode, lo que la hace rápida y compatible con Java 25 y Spring Boot 4. Se configura como plugin de Maven:
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.18.2</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>1.2.3</version>
</dependency>
</dependencies>
<configuration>
<targetClasses>
<param>com.empresa.empleados.service.*</param>
</targetClasses>
<targetTests>
<param>com.empresa.empleados.service.*Test</param>
</targetTests>
<outputFormats>
<param>HTML</param>
<param>XML</param>
</outputFormats>
<mutationThreshold>75</mutationThreshold>
<coverageThreshold>80</coverageThreshold>
<timestampedReports>false</timestampedReports>
</configuration>
</plugin>
El
pitest-junit5-pluginsigue siendo el conector vigente para JUnit 6, dado que JUnit 6 mantiene compatibilidad binaria con la API JUnit Jupiter de la línea 5.
Ejecutar el análisis:
mvn test pitest:mutationCoverage
PIT compila el proyecto, ejecuta la suite original para verificar que pasa, y luego genera mutaciones clase a clase. El reporte se guarda en target/pit-reports/index.html.
Los mutators que importan
PIT activa por defecto un set conservador de mutators. Los más relevantes para código de negocio:
| Mutator | Cambio | Ejemplo |
|---------|--------|---------|
| CONDITIONALS_BOUNDARY | Cambia < por <= y > por >= | if (x > 12) se convierte en if (x >= 12) |
| NEGATE_CONDITIONALS | Invierte la condición | if (x == 0) se convierte en if (x != 0) |
| REMOVE_CONDITIONALS | Reemplaza por true o false | if (cond) se convierte en if (true) |
| MATH | Sustituye operaciones aritméticas | a + b se convierte en a - b |
| INCREMENTS | Modifica incrementos | i++ se convierte en i-- |
| RETURN_VALS | Cambia valores de retorno | return true se convierte en return false |
| VOID_METHOD_CALLS | Elimina llamadas a métodos void | repository.save(x) desaparece |
| EMPTY_RETURNS | Reemplaza colecciones por vacías | return List.of(x) se convierte en return List.of() |
| NULL_RETURNS | Devuelve null donde aplica | return dto se convierte en return null |
Activar el grupo STRONGER añade mutators adicionales útiles para Spring:
<mutators>
<mutator>STRONGER</mutator>
</mutators>
Un primer reporte real
Imagina un EmpleadoValidator con varias reglas de negocio:
@Component
public class EmpleadoValidator {
public List<String> validar(EmpleadoDto dto) {
var errores = new ArrayList<String>();
if (dto.salario().compareTo(BigDecimal.ZERO) < 0) {
errores.add("Salario negativo");
}
if (dto.antiguedadMeses() < 0) {
errores.add("Antiguedad negativa");
}
if (dto.email() == null || dto.email().isBlank()) {
errores.add("Email requerido");
}
return errores;
}
}
Y un test "verde" típico:
@Test
void validar_devuelve_errores_para_dto_invalido() {
var dto = new EmpleadoDto(null, "", new BigDecimal("-1"), -5);
var errores = validator.validar(dto);
assertFalse(errores.isEmpty());
}
JaCoCo dirá 100% de cobertura de líneas. PIT generará alrededor de 12 mutaciones y la mayoría sobrevivirá: cambiar < 0 por <= 0 no romperá el test (sigue habiendo errores), invertir compareTo tampoco (la lista no se valida tamaño concreto). El reporte HTML mostrará ratios como mutation coverage 30%, evidenciando que el test apenas verifica nada.
Un test sano, dirigido por mutation coverage, sería:
@Test
void detecta_salario_negativo_pero_admite_cero() {
var conCero = new EmpleadoDto("a@b.com", new BigDecimal("0"), 0);
var conNegativo = new EmpleadoDto("a@b.com", new BigDecimal("-1"), 0);
assertThat(validator.validar(conCero)).doesNotContain("Salario negativo");
assertThat(validator.validar(conNegativo)).contains("Salario negativo");
}
Este test mata la mutación de CONDITIONALS_BOUNDARY porque distingue el límite 0.
Lectura del reporte HTML
El reporte se abre en target/pit-reports/index.html. Cada clase tiene su página con:
- La lista de mutaciones aplicadas, ubicadas en la línea concreta del código fuente.
- El estado de cada mutación: KILLED (verde), SURVIVED (rojo), NO_COVERAGE (amarillo), TIMED_OUT (naranja).
- El test que mató cada mutación, cuando aplica.
Los SURVIVED son la prioridad: indican código no verificado. Los TIMED_OUT son menos preocupantes (suelen ocurrir en bucles que la mutación convierte en infinitos), pero se revisan por si revelan lógica frágil.
Threshold incremental en CI
Configurar mutationThreshold desde el primer día rompe el build sin necesidad. La estrategia idiomática es incremental:
- Primer mes:
<mutationThreshold>0</mutationThreshold>solo para reportar. - A partir del baseline (digamos 55%): subir el threshold cada sprint en 2-3 puntos.
- Establecer un suelo definitivo en 75-80% para clases de negocio.
<configuration>
<mutationThreshold>70</mutationThreshold>
<coverageThreshold>85</coverageThreshold>
<failWhenNoMutations>false</failWhenNoMutations>
</configuration>
Para evitar análisis completos en cada PR (PIT puede tardar 5-15 minutos), usar el modo incremental analysis sobre el diff:
<features>
<feature>+CLASSLIMIT(limit[100])</feature>
</features>
<historyInputFile>target/pit-history.bin</historyInputFile>
<historyOutputFile>target/pit-history.bin</historyOutputFile>
Con el historial, PIT solo muta clases cambiadas o tests modificados, reduciendo el tiempo del 90%.
Integración con SonarQube
SonarQube no consume reportes XML de PIT directamente, pero existe el plugin sonar-pitest. Tras configurarlo, las mutaciones supervivientes aparecen en el panel de SonarQube como issues, alineadas con líneas concretas. La regla "Mutation coverage on lines" se incluye en el quality gate y bloquea PRs con regresión.
<outputFormats>
<param>XML</param>
</outputFormats>
sonar.pitest.mode: reuseReport
sonar.pitest.reportsDirectory: target/pit-reports
Coste contra beneficio
Mutation testing tiene un coste real:
- Tiempo de ejecución: una suite de 5 minutos puede tardar 30-60 minutos con PIT completo. El modo incremental lo reduce a 3-5 minutos por PR.
- Curva de aprendizaje: leer el reporte y refactorizar tests débiles requiere disciplina inicial.
- Tests más exigentes: dejar de aceptar
assertNotNull(result)como assert válido.
A cambio:
- Tests que detectan regresiones reales, no solo errores de compilación.
- Métricas que el equipo no puede inflar sin escribir asserts concretos.
- Refactor seguro: si los tests sobreviven a las mutaciones, sobreviven a un refactor agresivo.
En proyectos críticos (banca, salud, FUNDAE, contratos B2B), el coste de un sprint de adopción se amortiza en el primer bug evitado en producción. En proyectos pequeños o internos, basta con activar PIT en el módulo de dominio y dejar el resto fuera.
Buenas prácticas
- Empezar por el paquete
serviceodomain, no por todo el código. Los controladores y configuraciones aportan poco a la métrica. - Excluir DTOs, mappers y código generado con
<excludedClasses>: mutarequals/hashCodeautogenerados solo añade ruido. - Reportar mutation coverage, no obligar 100%. Un 78% bien repartido es mejor que un 95% acumulado en utilidades triviales.
- Revisar SURVIVED en code review: el reporte de PIT es un input excelente para detectar tests que no verifican nada.
- Incremental analysis siempre en CI: el análisis completo se reserva al pipeline nightly o al release.
- Combinar con JaCoCo, no sustituirlo: line coverage sigue siendo útil para detectar código nunca ejecutado, mientras que mutation coverage detecta código ejecutado pero no verificado.
Adoptar PIT cambia la conversación del equipo: deja de hablar de "cobertura" en abstracto y empieza a hablar de "qué garantías ofrecen los tests". Esa diferencia, mantenida durante meses, separa una suite de tests profesional de un teatro verde con bugs latentes.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Diferenciar line coverage de mutation coverage y entender por qué el primero engaña. Configurar el plugin pitest-maven con mutators activos y target classes. Interpretar el reporte HTML (mutaciones killed, survived, no coverage, timed out). Definir thresholds de mutation coverage en CI sin bloquear el build. Identificar tests débiles que pasan sin verificar nada relevante.