Las pruebas de mutación (mutation testing) son un método para verificar la calidad de la suite de pruebas mediante la introducción intencional de errores en el código para asegurar que las pruebas los detecten.
El tipo de errores que se introducen son bastante directos. Considera los siguientes ejemplos:
// original function
function mint() external payable {
require(msg.value >= PRICE, "insufficient msg value");
}
// mutated function
function mint() external public {
require(msg.value < PRICE, "insufficient msg value");
}
En el ejemplo anterior, se invirtió el operador de desigualdad. Si las pruebas unitarias aún pasan, entonces simplemente están ofreciendo una falsa sensación de seguridad.
Es importante que los errores sean sintácticamente válidos, es decir, que sigan resultando en código Solidity compilable. Si el código no compila, entonces no será posible ejecutar las pruebas unitarias.
Cobertura de líneas sin pruebas
Usemos el ejemplo predeterminado que proporciona Foundry después de ejecutar forge init y comentemos las declaraciones assert.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
counter.setNumber(0);
}
function testIncrement() public {
counter.increment();
//assertEq(counter.number(), 1);
}
function testSetNumber(uint256 x) public {
counter.setNumber(x);
//assertEq(counter.number(), x);
}
}
Si ejecutamos forge coverage, obtenemos la siguiente tabla:

¡Supuestamente, tenemos un 100% de cobertura de líneas y ramas en Counter.sol a pesar de no tener declaraciones assert! Esto significa que podemos introducir errores a voluntad y las pruebas seguirán pasando.
Ahora bien, por supuesto, este es un claro ejemplo de lo que no se debe hacer. Pero es fácil cometer este error accidentalmente cuando se optimiza para obtener cobertura. La cobertura solo te dice que ejecutaste el código y no se revirtió. Quieres asegurarte de que todos los cambios de estado esperados realmente se estén llevando a cabo (consulta nuestra otra publicación para obtener más información sobre las mejores prácticas de pruebas unitarias en Solidity).
Tipos de mutantes
Aquí hay algunos tipos de mutaciones que pueden ser útiles:
- eliminar modificadores de funciones
- invertir comparaciones de desigualdad
- cambiar valores constantes o intercambiar constantes de cadena por cadenas vacías
- reemplazar true con false
- reemplazar
&&con||y&a nivel de bits con|a nivel de bits - intercambiar operadores aritméticos (por ejemplo,
+se convierte en-) - eliminar líneas
- intercambiar líneas
Pruebas de mutación automáticas
Sería bastante tedioso mutar manualmente el código de acuerdo con las reglas anteriores y luego ejecutar la suite de pruebas. Por lo tanto, existen herramientas que hacen esto automáticamente. Generan docenas de mutaciones potenciales, mutan el código, ejecutan la suite de pruebas, almacenan los resultados y generan un informe posteriormente. Puede haber tres resultados:
- el mutante sobrevivió
- mutante equivalente
- el mutante fue eliminado
Que el mutante sobreviva significa que el código fue modificado y la prueba aún así pasó. Un mutante equivalente ocurre cuando el bytecode no cambió después de ejecutar la mutación. Esto puede suceder si un símbolo es reemplazado aleatoriamente por el mismo símbolo, o si la mutación no altera la lógica de negocio y la optimización del compilador ignora el cambio.
Aquí hay un ejemplo de dónde podría ocurrir un mutante equivalente:
// before
x = x + 1;
y = y + 1;
// after
y = y + 1;
x = x + 1;
Bajo ciertas circunstancias, el compilador podría producir el mismo bytecode después de una mutación como esta. Esta es una mutación equivalente. Los mutantes equivalentes pueden ser una señal de código innecesario o muerto (dead code), como en el siguiente ejemplo:
require(false);
// anything that happens here doesn't matter
Finalmente, el escenario de mutante eliminado es el deseable. Significa que el código fue mutado y las pruebas fallaron. Por lo tanto, las pruebas realmente pueden detectar cuando algo sale mal. Si una mutación da como resultado un código que no compila, por ejemplo, eliminar la declaración de una variable que se usa más adelante, entonces el mutante se considera eliminado.
El 100% de cobertura de líneas y ramas es importante para las pruebas de mutación
Si una línea o rama no está cubierta, mutar naturalmente esta línea no causará que la prueba falle.
Considera el siguiente ejemplo:
function mint(address to_, string memory questId_) public onlyMinter {
// business logic
}
Hay una rama implícita aquí con el modificador onlyMinter. Si esto solo se prueba en una situación donde el minter fue quien llamó a la función, entonces eliminar onlyMinter no causará que la prueba falle. Si el modificador onlyMinter no bloquea a los que no son minters, entonces las pruebas unitarias no lo detectarán.
Por cierto, por muy rebuscado que parezca este ejemplo, está tomado de un informe real de codearena.
Errores “Off by One” y condiciones límite
Las pruebas de mutación pueden ser útiles para detectar errores “off-by-one” (errores de desplazamiento por uno). Considera la siguiente mutación:
uint256 public LIMIT = 5;
// original
function mint(uint256 amount) external {
require(amount < LIMIT, "exceeds limit");
}
// mutation
function mint(uint256 amount) external {
require(amount <= LIMIT, "exceeds limit");
}
Si nuestras pruebas unitarias establecen que amount sea 3 y 8, el código tendrá un 100% de cobertura de ramas con respecto a esta prueba. Sin embargo, las pruebas de mutación fallarán porque la desigualdad estricta fue reemplazada por una desigualdad y la prueba aún así pasó. Esto se debe a que las pruebas no expresan con precisión la funcionalidad prevista. Específicamente, las pruebas deberían validar si el límite superior es 4 o 5. Probar valores para amount como 3 u 8 no define completamente la especificación del contrato inteligente para esta función.
Vertigo-rs
RareSkills mantiene activamente una herramienta de pruebas de mutación para Solidity, vertigo-rs. Esta fue bifurcada (forked) del repositorio vertigo que ya no cuenta con mantenimiento. Se ha añadido soporte para el framework Foundry. La herramienta funciona con Foundry, Hardhat y Truffle. Las instrucciones para ejecutar la herramienta se encuentran en el Readme. No se requieren modificaciones en la base de código de Solidity ni en las pruebas. Simplemente clona el repositorio, instala las dependencias y luego ejecútalo en el proyecto Solidity que estés probando.
Otras herramientas de pruebas de mutación
Aunque vertigo-rs es la única herramienta que ejecuta automáticamente la suite de pruebas, existen otras herramientas notables para generar mutaciones (pero no admiten la re-ejecución automática de la suite de pruebas ni el resumen de los resultados).
- Gambit de Certora
- Universal Mutator de sambucha
Existen otras herramientas, pero aparentemente ya no reciben mantenimiento.
Puntuación de mutación (Mutation Score)
Las herramientas para lenguajes diferentes a Solidity a veces proporcionan un mutation score (puntuación de mutación). Este es el porcentaje de mutantes que fueron eliminados. Si el 100% de los mutantes fueron eliminados, entonces se puede confiar en que las pruebas unitarias detectarán cambios no deseados o accidentales en la base de código.
Para bases de código muy grandes, tener una puntuación del 100% puede ser poco práctico. Los contratos inteligentes en Solidity son bastante pequeños en comparación con las bases de código tradicionales, como la mayoría de las aplicaciones backend y frontend. Apuntar a una puntuación de mutación del 100% para bases de código tan grandes puede ser inviable. Pero debido a que los contratos inteligentes en Solidity son relativamente pequeños, y los errores son catastróficos, los mutantes supervivientes deben ser examinados minuciosamente.
Limitaciones de las pruebas de mutación
Debido a que las pruebas de mutación evalúan la calidad de las pruebas unitarias, y las pruebas unitarias son generalmente sin estado (stateless), las pruebas de mutación no pueden dilucidar de forma natural si la lógica de negocio con estado (stateful) se está probando adecuadamente.
Las pruebas de mutación pueden crear cientos de mutaciones, pero por cuestiones de tiempo, la mayoría de las herramientas solo ejecutan un subconjunto de ellas. Esto significa que podrían pasarse por alto mutaciones importantes que descubran errores en la suite de pruebas.
Aprende más
Este material es parte de nuestro Solidity bootcamp. También puedes aprender Solidity gratis con nuestro curso de Solidity gratuito.
Publicado originalmente el 14 de abril de 2023