Ethereum: de historia a labs

Artículo 20 de 48

42%

completado al llegar aquí

Sistemas distribuidosNota de investigación15 min de lectura

Testing serio: unit, fork, fuzz e invariantes

Unit, fuzz, invariant y fork tests en Foundry: qué evidencia da cada uno, cómo escribir handlers y ghost variables, y un bug real que solo encuentra el fuzzer.

Ruta Ethereum: 0/48

Un contrato con 100 % de cobertura de líneas puede perder dinero igual. La cobertura dice qué líneas se ejecutaron, no qué combinaciones de llamadas, montos y usuarios se probaron. En Ethereum eso importa más que en un backend tradicional: el código desplegado no se parchea en caliente, y cualquiera puede llamar cualquier función pública en cualquier orden.

Este capítulo trata los tests como evidencia. Cada nivel, unit, fuzz, invariante y fork, responde una pregunta distinta. Los ejemplos usan Foundry, la herramienta del capítulo de taller local, y todos se ejecutaron con Foundry 1.5.1 y Solidity 0.8.26 antes de publicarse.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Fuzzer y handler

Forge genera inputs y secuencias; el handler decide qué llamadas son válidas.

Evidencia

Contraejemplo reducido

Un test que falla deja la secuencia mínima de llamadas que rompe la propiedad.

Estado

Storage del contrato bajo prueba

Balances y totales que la invariante compara después de cada llamada.

Preguntas guía

  • ¿Qué propiedad de tu contrato debe ser cierta después de cualquier secuencia de llamadas?
  • ¿Qué diferencia hay entre probar un monto elegido a mano y probar miles de montos aleatorios?
  • ¿Por qué un fuzzer de invariantes necesita un handler en lugar de llamar al contrato directamente?
  • Si un test de invariante pasa con miles de reverts, ¿qué te está ocultando?
  • ¿Cuándo hace falta un fork de mainnet y qué lo vuelve reproducible?

Cuatro niveles de evidencia: unit, fuzz, invariante y fork

Un unit test fija un escenario elegido a mano: Alice deposita 1 ether, retira 0,4 y le queda 0,6. Es rápido y legible, pero solo prueba los valores que el autor imaginó. En Forge, un test es una función que empieza con test dentro de un contrato que hereda de Test, y setUp() se ejecuta antes de cada test.

Un fuzz test es un test con parámetros. Forge genera valores aleatorios para esos parámetros y ejecuta la función muchas veces, 256 por defecto. Si alguna corrida falla, informa el input y la semilla, que se puede fijar con --fuzz-seed o en foundry.toml para reproducir el fallo.

Un test de invariante va un paso más allá: genera secuencias aleatorias de llamadas y comprueba una propiedad después de cada una. Un fork test ejecuta los tests contra el estado real de una red, leído por RPC, sin enviar transacciones a esa red.

Comparación

Qué pregunta responde cada nivel

Unit

Un escenario concreto, con valores elegidos a mano.

Ideal para
Documentar comportamiento esperado y caminos de error.
Ojo con
Solo prueba lo que el autor imaginó.

Fuzz

Una función, muchos inputs aleatorios contra una propiedad.

Ideal para
Bordes numéricos, redondeos y rangos.
Ojo con
Cada corrida empieza desde setUp; no explora secuencias.
Recomendado

Invariante

Secuencias aleatorias de llamadas; la propiedad se chequea tras cada una.

Ideal para
Contabilidad global: solvencia, conservación, suma de saldos.
Ojo con
Sin handler, la mayoría de las llamadas revierten y no exploran nada.
Precaución

Fork

Tus contratos contra el estado real de mainnet en un bloque fijo.

Ideal para
Integraciones con tokens, oráculos y protocolos existentes.
Ojo con
Depende de un RPC; sin bloque fijo no es reproducible.
Ningún nivel reemplaza a los otros; cada uno cubre un tipo de error distinto.

Traza de un fuzz test: inputs aleatorios contra una propiedad

Usaremos un banco mínimo: cada usuario deposita ether, puede retirarlo y el contrato lleva totalDeposits. Es deliberadamente simple; el objetivo es la técnica de prueba, no el contrato.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
contract Bank {
    mapping(address => uint256) public balanceOf;
    uint256 public totalDeposits;
 
    error InsufficientBalance();
 
    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
        totalDeposits += msg.value;
    }
 
    function withdraw(uint256 amount) external {
        if (balanceOf[msg.sender] < amount) revert InsufficientBalance();
        balanceOf[msg.sender] -= amount;
        totalDeposits -= amount;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "ETH transfer failed");
    }
}

El primer test es unit; el segundo es la misma idea convertida en propiedad. Fíjate en bound: transforma cualquier uint256 en un valor dentro de un rango. La alternativa, vm.assume, descarta inputs que no cumplen una condición; la documentación la recomienda solo para exclusiones puntuales, porque descartar rangos amplios desperdicia corridas.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import {Test} from "forge-std/Test.sol";
import {Bank} from "../src/Bank.sol";
 
contract BankUnitTest is Test {
    Bank bank;
    address alice = makeAddr("alice");
 
    function setUp() public {
        bank = new Bank();
        vm.deal(alice, 10 ether);
    }
 
    function test_DepositThenWithdraw() public {
        vm.startPrank(alice);
        bank.deposit{value: 1 ether}();
        bank.withdraw(0.4 ether);
        vm.stopPrank();
        assertEq(bank.balanceOf(alice), 0.6 ether);
    }
 
    function test_RevertWhen_WithdrawMoreThanBalance() public {
        vm.prank(alice);
        vm.expectRevert(Bank.InsufficientBalance.selector);
        bank.withdraw(1);
    }
 
    function testFuzz_WithdrawNeverExceedsDeposit(uint256 dep, uint256 wd) public {
        dep = bound(dep, 1, 10 ether);
        wd = bound(wd, 0, dep);
        vm.startPrank(alice);
        bank.deposit{value: dep}();
        bank.withdraw(wd);
        vm.stopPrank();
        assertEq(bank.balanceOf(alice), dep - wd);
        assertEq(address(bank).balance, bank.totalDeposits());
    }
}

vm.prank hace que la siguiente llamada salga desde otra dirección, y vm.expectRevert exige que la próxima llamada revierta con el error indicado. El camino de error es parte del comportamiento: un retiro mayor al saldo debe fallar, y el test lo deja escrito. Con forge test -vvv, Forge muestra la traza de llamadas de los tests que fallan.

Invariantes con handler: secuencias, ghost variables y shrinking

El fuzz test anterior siempre empieza desde setUp: un depósito, un retiro, fin. Los bugs de contabilidad suelen aparecer después de varias operaciones de varios usuarios. Para eso, Forge ofrece funciones invariant_*: elige llamadas al azar sobre los contratos objetivo y evalúa la invariante después de cada llamada.

Si el fuzzer llama al banco directamente, casi todo revierte: retira sin haber depositado o envía ether desde cuentas sin saldo. Por eso se escribe un handler, un contrato de prueba que envuelve al banco, elige un actor de una lista, acota los montos con bound y registra ghost variables, contadores que existen solo en el test, como el total depositado y retirado. Con targetContract(address(handler)), el fuzzer solo llama al handler.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import {Test} from "forge-std/Test.sol";
import {Bank} from "../src/Bank.sol";
 
contract BankHandler is Test {
    Bank public bank;
    address[] public actors;
    uint256 public ghost_deposited;
    uint256 public ghost_withdrawn;
 
    constructor(Bank _bank) {
        bank = _bank;
        for (uint256 i = 0; i < 3; i++) {
            actors.push(makeAddr(string(abi.encodePacked("actor", vm.toString(i)))));
            vm.deal(actors[i], 100 ether);
        }
    }
 
    function deposit(uint256 actorSeed, uint256 amount) external {
        address actor = actors[bound(actorSeed, 0, actors.length - 1)];
        amount = bound(amount, 0, actor.balance);
        vm.prank(actor);
        bank.deposit{value: amount}();
        ghost_deposited += amount;
    }
 
    function withdraw(uint256 actorSeed, uint256 amount) external {
        address actor = actors[bound(actorSeed, 0, actors.length - 1)];
        amount = bound(amount, 0, bank.balanceOf(actor));
        vm.prank(actor);
        bank.withdraw(amount);
        ghost_withdrawn += amount;
    }
 
    function actorCount() external view returns (uint256) {
        return actors.length;
    }
}
 
contract BankInvariantTest is Test {
    Bank bank;
    BankHandler handler;
 
    function setUp() public {
        bank = new Bank();
        handler = new BankHandler(bank);
        targetContract(address(handler));
    }
 
    function invariant_Solvent() public view {
        assertGe(address(bank).balance, bank.totalDeposits());
    }
 
    function invariant_Conservation() public view {
        assertEq(bank.totalDeposits(), handler.ghost_deposited() - handler.ghost_withdrawn());
    }
 
    function invariant_SumOfBalances() public view {
        uint256 sum;
        for (uint256 i = 0; i < handler.actorCount(); i++) {
            sum += bank.balanceOf(handler.actors(i));
        }
        assertEq(sum, bank.totalDeposits());
    }
}
67 líneas

Tres invariantes, tres preguntas distintas. Solvencia: el contrato tiene al menos el ether que debe. Conservación: el total registrado es lo que entró menos lo que salió. Suma de saldos: los saldos individuales suman el total. Las dos últimas parecen redundantes; el caso siguiente muestra por qué no lo son.

En la referencia de configuración actual, [invariant] usa por defecto 256 corridas (runs) de 500 llamadas cada una (depth), y fail_on_revert es falso. Nuestra corrida local lo confirmó: cada invariante reportó 256 corridas y 128 000 llamadas.

Caso de estudio de invariante: el bug que el unit test no ve

Agreguemos una función transfer con un error clásico. Lee los dos saldos al principio y los escribe al final. Si el destino es el propio remitente, la segunda escritura pisa a la primera y el saldo crece en amount:

solidity
// Fragmento ilustrativo con un bug deliberado: no copiar a producción.
function transfer(address to, uint256 amount) external {
    uint256 fromBal = balanceOf[msg.sender];
    uint256 toBal = balanceOf[to];
    if (fromBal < amount) revert InsufficientBalance();
    balanceOf[msg.sender] = fromBal - amount;
    balanceOf[to] = toBal + amount; // si to == msg.sender, gana amount
}

Un unit test de Alice a Bob pasa. Ahora sumamos transfer al handler, con remitente y destino elegidos entre los mismos tres actores, y ejecutamos forge test. En nuestra corrida, invariant_SumOfBalances falló y Foundry redujo una secuencia de 33 llamadas a 2: un depósito y una transferencia de un actor a sí mismo. Esa reducción se llama shrinking: el fuzzer elimina y simplifica llamadas mientras el fallo se mantenga.

Figura 1

Una campaña de invariantes y su contraejemplo reducido

Fuentes:

[2][3]
Una campaña de invariantes y su contraejemplo reducido
Cómo leer esta figura

Diagrama en dos filas. Fila superior, titulada una corrida: una caja setUp seguida de llamadas deposit, withdraw, transfer, puntos suspensivos y una llamada k marcada en rojo. Bajo cada llamada hay una caja verde que dice invariante ok, salvo bajo la llamada k, donde una caja roja dice invariante falla. Una nota indica hasta depth llamadas por corrida y runs corridas por test. Fila inferior, titulada shrinking: una caja secuencia original de 33 llamadas apunta, con la etiqueta quitar y simplificar, a una caja roja secuencia mínima de 2 llamadas: deposit de a y luego transfer de a hacia a. Una nota explica que la transferencia a sí mismo infla el saldo de a.

Visual original de TrautsLab. Arriba, una corrida: después de setUp, el fuzzer llama al handler en orden aleatorio y evalúa la invariante tras cada llamada. Abajo, el shrinking observado en nuestra corrida local: 33 llamadas reducidas a 2. Los números exactos dependen de la semilla.

Lo interesante es lo que no falló. invariant_Solvent y invariant_Conservation pasaron, con miles de retiros revertidos. ¿Por qué? Cuando un actor con saldo inflado intenta retirar más de lo depositado en total, totalDeposits -= amount hace underflow, y desde Solidity 0.8.0 la aritmética está chequeada por defecto: la operación revierte en lugar de dar la vuelta. El dinero no salió, pero la contabilidad ya estaba rota y bloqueaba retiros legítimos.

La lección tiene dos partes. Primero, una invariante solo detecta lo que mira: hacía falta la suma de saldos. Segundo, los reverts son señal. Con fail_on_revert = false, el valor por defecto, una campaña puede pasar mientras una parte creciente de las llamadas falla. Revisa la tabla de llamadas y reverts por selector que Forge imprime, y explica cada revert.

Fork tests: probar contra el estado real sin tocar mainnet

Tu contrato rara vez vive solo. Si integra un token existente o un oráculo, un mock escrito por ti prueba tu suposición, no el contrato real. Un fork test lee por RPC el estado de una red real y ejecuta los tests localmente sobre esa copia, sin enviar transacciones.

Bash
# Fork reproducible: siempre el mismo bloque
forge test --fork-url "$MAINNET_RPC_URL" --fork-block-number 18000000 -vvv
 
# Repetir un fallo de fuzzing con la misma semilla
forge test --match-test testFuzz_WithdrawNeverExceedsDeposit --fuzz-seed 0x1234

Sin --fork-block-number, el fork usa el bloque más reciente y el mismo test puede dar resultados distintos mañana. Con el bloque fijo, el estado de partida es el mismo para todo el equipo. Dentro del fork, vm.prank permite impersonar cualquier dirección, por ejemplo un holder grande de un token, para preparar escenarios realistas. La semilla tiene un límite: la documentación aclara que por sí sola no garantiza los mismos inputs si cambian la versión de Foundry, el código, la configuración o el número de workers.

Riesgo operacional: lo que una suite verde no garantiza

Una suite en verde es evidencia acotada. Para que sea útil en un pipeline de CI, el equipo debería poder responder estas preguntas por escrito:

Revisión de la suite

Antes de confiar en un deploy

0 de 6 revisados

Cada punto apunta a una forma concreta de obtener falsa confianza.

El último punto conecta con el capítulo anterior: una invariante de layout o de balances vale doble si se verifica también a través de un upgrade simulado. Y conecta con el siguiente: la reentrada es justamente un tipo de bug que aparece en secuencias de llamadas, no en una sola función aislada.

Conexiones dentro de la serie

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    Foundry Contributors, "Testing," Foundry Book, Forge guide.

    Documentation siteID: FOUNDRY-TESTINGAbrir fuente (abre en otra pestaña)

    Convenciones de tests, aislamiento de llamadas, niveles de verbosidad, fuzzing con 256 corridas por defecto, semillas, vm.assume y bound.

  2. [2]

    Foundry Contributors, "Invariant Testing," Foundry Book, Guides.

    Documentation siteID: FOUNDRY-INVARIANT-GUIDEAbrir fuente (abre en otra pestaña)

    targetContract, patrón handler, ghost variables, afterInvariant, configuración runs/depth/fail_on_revert y shrinking.

  3. [3]

    Foundry Contributors, "Testing," Foundry Book, Config reference.

    Documentation siteID: FOUNDRY-CONFIG-TESTINGAbrir fuente (abre en otra pestaña)

    Valores por defecto de [fuzz] y [invariant]: runs, depth, fail_on_revert, timeout y call_override.

  4. [4]

    Foundry Contributors, "Fork Testing," Foundry Book, Guides.

    Documentation siteID: FOUNDRY-FORK-TESTINGAbrir fuente (abre en otra pestaña)

    --fork-url, --fork-block-number para reproducibilidad, configuración en foundry.toml e impersonación con vm.prank.

  5. [5]

    Foundry Contributors, "assume," Foundry Book, Cheatcodes reference.

    Documentation siteID: FOUNDRY-ASSUMEAbrir fuente (abre en otra pestaña)

    vm.assume descarta el input actual; para rangos amplios recomienda bound.

  6. [6]

    Foundry Contributors, "expectRevert," Foundry Book, Cheatcodes reference.

    Documentation siteID: FOUNDRY-EXPECT-REVERTAbrir fuente (abre en otra pestaña)

    expectRevert afirma que la siguiente llamada revierte con los datos esperados.

  7. [7]

    Solidity Team, "Checked or Unchecked Arithmetic," Solidity Documentation, Expressions and Control Structures.

    Documentation siteID: SOLIDITY-CHECKED-ARITHMETICAbrir fuente (abre en otra pestaña)

    Desde Solidity 0.8.0, overflow y underflow revierten por defecto salvo dentro de bloques unchecked.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

Rutas vecinas

Si este tema te abrió hambre, sigue por aquí.

Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.