Pruebas de mutación en PHP

¿Qué son?

Las pruebas de mutación son un tipo de pruebas realizadas sobre los test unitarios que de manera intencionada añaden defectos a tu código y detectan si tus tests reaccionan a estos cambios. Si un código modificado hace fallar el test, se denomina que se ha matado a la mutación, en el otro caso significa que tu test no cubre todos los casos de uso y no cubre la regresión de las pruebas.

Con este tipo de pruebas se pretende comprobar la calidad de los test y no su cobertura, demostrando que tener un 100% de cobertura de código no implica que tu código esté probado correctamente, sólo indica que las ejecución de los test han pasado por todas las líneas de código al menos una vez, pero no que estas líneas hayan sido probadas de manera certera.

Ejemplo

Podemos probar las declaraciones anteriores con un pequeño ejemplo:

class Foo
{
    public function bar(int $a): bool
    {
        return $a > 0;
    }
}

Para esta función, un test que nos daría un 100% de cobertura de código sería:

class FooTest extends TestCase
{
    public function testBar(): void
    {
        $a = new Foo();
        $this->assertTrue($a->bar(10));
    }
}

¡Listo! Ya tenemos 100% cobertura de código. ¡Podemos poner el badge de “100% Code Coverage” en la portada de nuestro repositorio de GitHub! Sí, pero el código no está probado correctamente ni mucho menos. Aquí es donde las pruebas de mutación entran en juego. Estas pruebas modificarán nuestro código para comprobar si los tests siguen pasando en los casos límite, algunas de las modificaciones que se harían en el código anterior serían:

  • Cambiar $a > 0 por $a > 1
  • Cambiar $a > 0 por $a >= 0
  • Cambiar $a > 0 por $a <= 0
  • Cambiar public por protected

Y por cada cambio ejecutar la batería de test. En los dos primeros casos el test seguirá estando verde y pasando, lo que significa que tenemos un mutante. En los otros dos casos el test fallará, lo que significa que hemos conseguido matar el mutante con nuestro test.

Librerías en PHP y su estado actual

Actualmente en el ecosistema de PHP existen dos librerías que están lo suficientemente maduras para ser consideradas, estas son Infection y Humbug.

En Finizens hemos descargado y probado ambas, con resultados muy similares entre ellas. Para los siguientes ejemplos, usaré las ejecuciones de Infection, ya que considero que tiene ciertas cualidades que la sitúan por encima de Humbug (a pesar de que se trata de una librería igualmente válida). Éstas son:

  • Tiene versión estable
  • Realiza más tipos de mutaciones
  • Tiene soporte para PHPUnit y PHPSpec (Soporte para Codeception próximamente)
  • Es más rápida por el uso de AST internamente para mutar el código.

Instalando y ejecutando Infection

En su repositorio podemos encontrar todas las opciones disponibles para instalar Infection, tanto descargando el .phar directamente, clonando el repositorio de Git o a través de composer. Vamos a usar esta última opción e instalarlo globalmente en nuestra máquina, ya que estas pruebas son muy lentas vamos a ejecutar estos test en local, no en el sistema de integración continua ni como dependencia del proyecto.

composer global require 'infection/infection'

En la raíz de nuestro proyecto vamos a generar el archivo de configuración la primera vez que ejecutemos el análisis.

> infection

Nos va a preguntar datos de nuestro proyecto, dónde están los archivos fuente, qué directorios queremos excluir y dónde guardar el log de errores entre otras cosas. Una vez configurado va a ejecutarlo automáticamente nuestro proyecto. Por defecto, va a leer el archivo phpunit.xml(.dist) del mismo directorio desde donde se ejecuta el comando, normalmente va a ser en la raíz del proyecto.

Ahora mejor que vayas a coger algo de comer y beber porque va a tardar un rato, especialmente con una batería de tests grande, ya que debe ejecutar múltiples veces distintos casos de test después de hacer las mutaciones y comprobar los resultados. Una lista de las mutaciones que actualmente realiza Infection puedes encontrarlas aquí: https://infection.github.io/guide/mutators.html

Conceptos de mutantes

En la ejecución de nuestro ejemplo anterior vamos a obtener un resultado como este:

Phpunit version: 6.4.3
6 [============================] < 1 sec
Generate mutants…
Processing source code files: 1/1
Creating mutated files and processes: 4/4
.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out
MM.. (4 / 4)
4 mutations were generated:
    2 mutants were killed
    0 mutants were not covered by tests
    2 covered mutants were not detected
    0 errors were encountered
    0 time outs were encountered
Metrics:
    Mutation Score Indicator (MSI): 50%
    Mutation Code Coverage: 100%
    Covered Code MSI: 50%

Se han generado 4 mutaciones distintas, de las cuales 2 fueron atrapadas por nuestros test (killed) y dos de ellas no han sido detectadas, en el archivo de log (infector-log.txt por defecto) podemos ver en detalle la línea que se ha modificado y ha hecho fallar el test.

De manera adicional tenemos unas métricas de nuestra batería de test.

  • Mutation Score Indicator (MSI): Métrica principal, indica el porcentaje de mutaciones que son detectadas por nuestros test, cuanto más alto mejor, en este caso tenemos la mitad de mutaciones detectadas por nuestras pruebas.
  • Mutation Code Coverage: Este porcentaje representa las mutaciones que han sido ejecutadas que cuentan con alguna prueba unitaria. Debería de salir un número bastante similar la de cobertura de código. Si se realiza una mutación, algún test tiene que pasar a través de estas líneas de código, de otro modo no se cuenta en este porcentaje.
  • Covered Code MSI: Es la primera métrica pero aplicada solo a el código que realmente está cubierto por algún test, no tiene en cuenta el código que no está dentro de la cobertura de código.

Arreglando un caso de test mutante

En este ejemplo que hemos estado revisando podemos ver que aunque la cobertura es del 100%, dos mutaciones en el código hacen que los tests sigan ejecutándose y pasando, por lo que no están cubriendo los casos de uso correctamente.

Para cubrir todos los casos vamos a modificar nuestros tests:

class FooTest extends TestCase
{
    public function testBar(): void
    {
        $a = new Foo();

        $this->assertTrue($a->bar(1));
        $this->assertFalse($a->bar(0));
        $this->assertFalse($a->bar(-1));
    }
}

Y ejecutar de nuevo infection

Processing source code files: 1/1
Creating mutated files and processes: 4/4
.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out
…. (4 / 4)
4 mutations were generated:
    4 mutants were killed
    0 mutants were not covered by tests
    0 covered mutants were not detected
    0 errors were encountered
    0 time outs were encountered
Metrics:
    Mutation Score Indicator (MSI): 100%
    Mutation Code Coverage: 100%
    Covered Code MSI: 100%

Ahora los números están mucho mejor, nuestro test es capaz de matar todas las mutaciones, por lo que no solo tenemos una cobertura del 100% sino que estamos seguros de que estamos probando todos los casos posibles para la condición de ejemplo.

Falsos positivos

Es muy común obtener falsos positivos, cambios que hacen que los tests sigan funcionando pero realmente así debe ser, estos casos son inevitables y es trabajo de la librería ir reduciéndolos en la mayor medida posible, algunos casos frecuentes que hemos encontrado muy comunes en nuestra base de código de la API de Finizens:

Comparación de tipado estricta en funciones

Un ejemplo claro que tenemos en nuestro código son las comprobaciones de tipado estrictro en funciones como `in_array`, que como tercer parámetro indicamos que la comparación se haga de manera estricta, en un método como este:

public function test(int $a): bool
{
    return \in_array($a, [1, 2, 3], true);
}

La prueba de mutación cambiará el último parámetro de in_array por false y el test que pueda tener ese método seguirá funcionando, porque en ningún caso vamos a poder pasar como parámetro de $a otra cosa distinta a un int.

Fluent setters

En los DTO que manejamos no realizamos test de los setters y getters por lo general, aún así algunas clases mantenían setters fluidos (retornan $this después de hacer un set, de manera que pueden concatenarse varios setters en una sola llamada). Este tipo de setters requieren probar no solo que el valor se establece correctamente sino comprobar el valor de retorno. En este caso hemos optado por eliminar los pocos setters que teníamos de este tipo en nuestro código, bajar la complejidad de los DTOs y eliminar estas comprobaciones extra en las pruebas.

Cambio de visibilidad en métodos que implementan interfaces

Cuando la visibilidad de un método viene definida por la interfaz que estás implementando Infector va a intentar modificar la visibilidad y comprobar si el test sigue fallando, estos test fallan directamente por no implementar correctamente la interfaz, actualmente hay un PR preparado para solventar este y otros issues relacionados.

Conclusión

No solo es importante realizar test, que estos test cubran el mayor porcentaje de nuestro código posible si no que estos test sean útiles y que cubran los casos de uso. Con estas herramientas es posible hoy en día realizarlo en PHP y aporta un gran valor revisar nuestros test de manera periódica.

En Finizens hemos podido mejorar nuestros tests usando estas pruebas sobre partes concretas el código, especialmente en la capa de dominio, ya que con la batería de test que tenemos (de más de 3.500 tests a día de hoy) el proceso es muy lento. Queríamos publicar la mejora en los porcentajes tras la eliminación de distintos mutantes pero al analizar tanta cantidad de código han permanecido prácticamente iguales, actualmente nuestros números son los siguientes y trabajaremos para mejorarlos, especialmente el último valor (Covered Code MSI):

10558 mutations were generated:
    5782 mutants were killed
    2117 mutants were not covered by tests
    2156 covered mutants were not detected
    434 errors were encountered
    69 time outs were encountered
Metrics:
    Mutation Score Indicator (MSI): 60%
    Mutation Code Coverage: 80%
    Covered Code MSI: 74%