Cobertura de código y mutation testing en pruebas unitarias con JaCoCo y PIT en Java

Escrito por el .
java planeta-codigo programacion
Comentarios

En el caso extremo una cobertura de código del cien por cien pero que no tenga ningún assert pasa los teses pero que en realidad no comprueba nada así que por si sola no es garantía de tener teses efectivos. Mutation testing da una medida adicional a la cobertura de los teses más completa y efectiva que simplemente la cobertura de código ejecutado por los teses unitarios.

Java

Una medida que se suele emplear para medir la calidad o efectividad de los teses unitarios es su cobertura de código que consiste en la cantidad de código ejercitado del total por las pruebas unitarias con los casos de prueba y fixtures empleados. Sin embargo, la cobertura de código no es una medida fiable para conocer si los casos de prueba empleados son precisos y completos. La cobertura de código puede seguir siendo del cien por cien si se sustituye un un mayor que por un mayor que e igual o faltan casos de prueba que ejerciten los límites de las condiciones, los teses seguirán siendo correctos.

Para complementar la cobertura de código y obtener una medida de la precisión y completitud de los teses se emplea mutation testing. Esta forma de pruebas analiza el código, realiza operaciones de mutación en el código y posteriormente ejecuta las pruebas contra el código mutado y genera un resultado en el que indica cuáles de las mutaciones realizadas ha sobrevivido pasando todos los teses o cuantas mutaciones han muerto porque los teses han fallado, si alguna mutación sobrevive los teses no son precisos y completos al cien por cien.

PIT es una librería que permite realizar mutation testing en Java. Las operaciones de mutación que realiza en el código pueden ser en los condicionales, incrementos, invertir negativos, matemáticas, negar condicionales, cambiar los valores de retorno o eliminar llamadas a métodos sin retorno. Un ejemplo de mutaciones son realizar mutaciones en los límites de comparaciones, cambiando un < por un <= y comprobar si con operador mutado la mutación sobrevive pasando todos los teses.

OriginalMutación
<<=
<=<
>>=
>=>

En siguiente ejemplo, la clase TicketPriceCalculator calcula el precio de los billetes de un grupo de viajeros. La lógica del calculador de precios determina el precio en función de la edad de los pasajeros y de si cumplen la condición de familia se les aplica un descuento.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package io.github.picodotdev.blogbitix.mutationtesting;

public class Passenger {

    private int age;

    public Passenger(int age) {
        this.age = age;
    }

    public int getAge() {
        return age;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package io.github.picodotdev.blogbitix.mutationtesting;

import java.util.List;

public class TicketPriceCalculator {

    public static double FAMILY_DISCOUNT = 0.05;

    private static int ADULT_AGE = 18;
    private static int FREE_TICKET_AGE_BELOW = 3;

    public double calculatePrice(List<Passenger> passengers, int adultTicketPrice, int childTicketPrice) {
        double price = countAdults(passengers) * adultTicketPrice + countChildrens(passengers) * childTicketPrice;
        double discount = (isFamily(passengers)) ? FAMILY_DISCOUNT : 0d;
        return price * (1 - discount);
    }

    private long countAdults(List<Passenger> passengers) {
        return passengers.stream().filter(this::isAdult).count();
    }

    private long countChildrens(List<Passenger> passengers) {
        return passengers.stream().filter(this::isChildren).count();
    }

    private boolean isAdult(Passenger passenger) {
        return passenger.getAge() > ADULT_AGE;
    }

    private boolean isChildren(Passenger passenger) {
        return passenger.getAge() > FREE_TICKET_AGE_BELOW && passenger.getAge() <= ADULT_AGE;
    }

    private boolean isFamily(List<Passenger> passengers) {
        return countAdults(passengers) >= 2 && countChildrens(passengers) >= 2;
    }
}

La siguiente batería de teses proporciona una cobertura de teses del cien por cien tanto para la cobertura del código como para las mutaciones como se muestran en los informes de JaCoCo para la cobertura de código y de PIT para la cobertura de mutaciones, después de haber realizado cambios tanto en el código como en los teses para obtener estos resultados.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package io.github.picodotdev.blogbitix.mutationtesting;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import org.junit.jupiter.api.Assertions;

public class TicketPriceCalculatorTest {

    private static int ADULT_TICKET_PRICE = 40;
    private static int CHILD_TICKER_PRICE = 20;

    private TicketPriceCalculator calculator;

    @BeforeEach
    public void before() {
        calculator = new TicketPriceCalculator();
    }

    @Test
    public void calculatePriceForOneAdult() {
        List<Passenger> passengers = new ArrayList<>();
        Passenger passenger = new Passenger(20);
        passengers.add(passenger);
        double price = calculator.calculatePrice(passengers, ADULT_TICKET_PRICE, CHILD_TICKER_PRICE);
        Assertions.assertEquals(ADULT_TICKET_PRICE, price, 0);
    }

    @Test
    public void calculatePriceForChild() {
        List<Passenger> passengers = new ArrayList<>();
        Passenger childPassenger = new Passenger(15);
        passengers.add(childPassenger);
        double price = calculator.calculatePrice(passengers, ADULT_TICKET_PRICE, CHILD_TICKER_PRICE);
        Assertions.assertEquals(CHILD_TICKER_PRICE, price, 0);
    }

    @Test
    public void calculatePriceForFamily() {
        List<Passenger> passengers = new ArrayList<>();
        Passenger adultPassenger1 = new Passenger(20);
        Passenger adultPassenger2 = new Passenger(20);
        Passenger childPassenger3 = new Passenger(12);
        Passenger childPassenger4 = new Passenger(4);
        passengers.add(adultPassenger1);
        passengers.add(adultPassenger2);
        passengers.add(childPassenger3);
        passengers.add(childPassenger4);
        double price = calculator.calculatePrice(passengers, ADULT_TICKET_PRICE, CHILD_TICKER_PRICE);
        Assertions.assertEquals((2 * ADULT_TICKET_PRICE + 2 * CHILD_TICKER_PRICE) * (1 - TicketPriceCalculator.FAMILY_DISCOUNT), price, 0);
    }

    @Test
    public void calculatePriceForNoFamilyByNoAdults() {
        List<Passenger> passengers = new ArrayList<>();
        Passenger adultPassenger1 = new Passenger(20);
        Passenger childPassenger2 = new Passenger(12);
        Passenger childPassenger3 = new Passenger(4);
        passengers.add(adultPassenger1);
        passengers.add(childPassenger2);
        passengers.add(childPassenger3);
        double price = calculator.calculatePrice(passengers, ADULT_TICKET_PRICE, CHILD_TICKER_PRICE);
        Assertions.assertEquals(1 * ADULT_TICKET_PRICE + 2 * CHILD_TICKER_PRICE, price, 0);
    }

    @Test
    public void calculatePriceForNoFamilyByNoChildren() {
        List<Passenger> passengers = new ArrayList<>();
        Passenger adultPassenger1 = new Passenger(20);
        Passenger adultPassenger2 = new Passenger(20);
        Passenger childPassenger3 = new Passenger(12);
        passengers.add(adultPassenger1);
        passengers.add(adultPassenger2);
        passengers.add(childPassenger3);
        double price = calculator.calculatePrice(passengers, ADULT_TICKET_PRICE, CHILD_TICKER_PRICE);
        Assertions.assertEquals(2 * ADULT_TICKET_PRICE + 1 * CHILD_TICKER_PRICE, price, 0);
    }

    @Test
    public void calculatePriceForChildNarrowCase() {
        List<Passenger> passengers = new ArrayList<>();
        Passenger childPassenger = new Passenger(18);
        passengers.add(childPassenger);
        double price = calculator.calculatePrice(passengers, ADULT_TICKET_PRICE, CHILD_TICKER_PRICE);
        Assertions.assertEquals(CHILD_TICKER_PRICE, price, 0);
    }

    @Test
    public void calculatePriceForFreeTicketNarrowCase() {
        List<Passenger> passengers = new ArrayList<>();
        Passenger childPassenger = new Passenger(3);
        passengers.add(childPassenger);
        double price = calculator.calculatePrice(passengers, ADULT_TICKET_PRICE, CHILD_TICKER_PRICE);
        Assertions.assertEquals(0, price, 0);
    }
}

Sin los casos de prueba calculatePriceForChildNarrowCase y calculatePriceForFreeTicketNarrowCase los teses son correctos, pero si PIT con una edad de 16 realiza una operación de mutación cambiando los límites de la condición de _passenger.getAge() > FREE_TICKET_AGE_BELOW && passenger.getAge() <= ADULTAGE, la mutación de <= a <_ sobrevive, esto inidica que los teses y casos de prueba no son totalmente precisos. Para que esta mutación no sobreviva hay que añadir estos dos teses que se encargan de comprobar los límites de las condiciones. El valor del caso de prueba que se debe utilizar es el valor del límite a partir del cual una persona se considera adulta, es un niño si su edad está comprendida a partir de 3 y menor e igual que 18.

Informe de teses correcto y de PIT incorrecto

El caso de prueba calculatePriceForFamily prueba que una familia esté formada por 2 adultos y 2 menores, PIT realiza las mutaciones para considerar una familia en el caso de ser de 3 adultos o 3 menores, la prueba de calculatePriceForFamily mata estas mutaciones haciendo que los teses sean precisos y completos. La cobertura de teses de mutación llega al cien por cien. En el informe de PIT se observa una descripción y número de mutaciones que ha realizado entre ellas divisiones en vez de multiplicaciones, substracciones en vez de sumas, reemplazo de valores de retorno o cambios y negaciones en condicionales. Los teses calculatePriceForNoFamilyByNoAdults y calculatePriceForNoFamilyByNoChildren completan la cobertura de todas las ramas del método isFamily.

Informe de pruebas de JUnit, de cobertura de JaCoCo y de mutación de PIT

Para generar los informes de cobertura de código y de mutación en Java y usando Gradle como herramienta de construcción las herramientas JaCoCo y PIT proporcionan un complemento o plugin que hay que añadir al archivo de construcción además de proporcionar algunas opciones de configuración en la sección pitest, entre estas propiedades está mutators en la que se puede indicar los mutators que PIT emplea para lanzar los teses con mutaciones. Los informes se generan en el directorio build/reports/. Realizar mutation testing solo requiere cierta configuración en el archivo de construcción del proyecto.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
buildscript {
   repositories {
        jcenter()
   }
   configurations.maybeCreate('pitest')
   dependencies {
       classpath 'info.solidsoft.gradle.pitest:gradle-pitest-plugin:1.4.5'
       pitest 'org.pitest:pitest-junit5-plugin:0.8'
   }
}

plugins {
    id 'java'
    id 'jacoco'
    id 'info.solidsoft.pitest' version '1.4.5'
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.4.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.4.2'
}

repositories {
    jcenter()
}

compileJava {
    sourceCompatibility = 11
    targetCompatibility = 11
}

test {
    useJUnitPlatform()
}

pitest {
    testPlugin = 'junit5'
    targetClasses = ['io.github.picodotdev.blogbitix.*']
    threads = 4
    outputFormats = ['HTML']
    timestampedReports = false
    mutators = ['DEFAULTS']
}
1
$ ./gradlew test jacocoTestReport pitest

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando el comando ./gradlew test jacocoTestReport pitest.