Inicio / Blockchain / Blockchain: De Cero a Desarrollador / Testing de Smart Contracts

Testing de Smart Contracts

Chai, fixtures, eventos, ETH payable, time helpers y coverage.

Intermedio
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Testing de Smart Contracts

El testing en smart contracts es aún más crítico que en software tradicional. Una vez desplegado, un bug no se puede parchear (a menos que uses un proxy). En esta lección aprenderás a escribir tests exhaustivos con Hardhat, Ethers.js y Chai.

Setup de Testing

// test/MiToken.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { loadFixture } = require("@nomicfoundation/hardhat-toolbox/network-helpers");

describe("MiToken", function () {
    // Fixture: setup reutilizable (se ejecuta una vez y se snapshottea)
    async function deployFixture() {
        const [owner, addr1, addr2] = await ethers.getSigners();

        const Token = await ethers.getContractFactory("MiToken");
        const token = await Token.deploy("Mi Token", "MTK", 1000000);

        return { token, owner, addr1, addr2 };
    }

    // Verifica el deploy
    describe("Deploy", function () {
        it("debería asignar el supply total al owner", async function () {
            const { token, owner } = await loadFixture(deployFixture);

            const ownerBalance = await token.balanceOf(owner.address);
            expect(await token.totalSupply()).to.equal(ownerBalance);
        });

        it("debería tener nombre y símbolo correctos", async function () {
            const { token } = await loadFixture(deployFixture);

            expect(await token.name()).to.equal("Mi Token");
            expect(await token.symbol()).to.equal("MTK");
        });
    });
});

Ejecutar Tests

# Ejecutar todos los tests
npx hardhat test

# Ejecutar un archivo específico
npx hardhat test test/MiToken.test.js

# Con gas reporting
REPORT_GAS=true npx hardhat test

# Con verbose
npx hardhat test --verbose

Testing de Transferencias

describe("Transferencias", function () {
    it("debería transferir tokens entre cuentas", async function () {
        const { token, owner, addr1, addr2 } = await loadFixture(deployFixture);

        // Transferir 100 tokens de owner a addr1
        await expect(token.transfer(addr1.address, 100))
            .to.changeTokenBalances(token, [owner, addr1], [-100, 100]);

        // Transferir 50 tokens de addr1 a addr2
        await expect(token.connect(addr1).transfer(addr2.address, 50))
            .to.changeTokenBalances(token, [addr1, addr2], [-50, 50]);
    });

    it("debería fallar si el sender no tiene suficientes tokens", async function () {
        const { token, owner, addr1 } = await loadFixture(deployFixture);
        const initialOwnerBalance = await token.balanceOf(owner.address);

        await expect(
            token.connect(addr1).transfer(owner.address, 1)
        ).to.be.revertedWith("Insufficient balance");

        // El balance del owner no debería cambiar
        expect(await token.balanceOf(owner.address)).to.equal(initialOwnerBalance);
    });

    it("debería emitir evento Transfer", async function () {
        const { token, owner, addr1 } = await loadFixture(deployFixture);

        await expect(token.transfer(addr1.address, 100))
            .to.emit(token, "Transfer")
            .withArgs(owner.address, addr1.address, 100);
    });
});

Testing de Aprobaciones

describe("Approve y TransferFrom", function () {
    it("debería aprobar y luego transferir", async function () {
        const { token, owner, addr1, addr2 } = await loadFixture(deployFixture);

        // Owner aprueba a addr1 para gastar 200 tokens
        await token.approve(addr1.address, 200);
        expect(await token.allowance(owner.address, addr1.address)).to.equal(200);

        // addr1 transfiere 100 tokens del owner a addr2
        await expect(
            token.connect(addr1).transferFrom(owner.address, addr2.address, 100)
        ).to.changeTokenBalances(token, [owner, addr2], [-100, 100]);

        // Allowance debería decrementar
        expect(await token.allowance(owner.address, addr1.address)).to.equal(100);
    });

    it("debería fallar si no hay allowance suficiente", async function () {
        const { token, owner, addr1, addr2 } = await loadFixture(deployFixture);

        await expect(
            token.connect(addr1).transferFrom(owner.address, addr2.address, 100)
        ).to.be.revertedWith("Insufficient allowance");
    });
});

Testing con ETH (payable)

describe("Contrato Payable", function () {
    async function deployVaultFixture() {
        const [owner, user] = await ethers.getSigners();
        const Vault = await ethers.getContractFactory("Vault");
        const vault = await Vault.deploy();
        return { vault, owner, user };
    }

    it("debería aceptar depósitos", async function () {
        const { vault, user } = await loadFixture(deployVaultFixture);
        const depositAmount = ethers.parseEther("1.0");

        await expect(
            vault.connect(user).deposit({ value: depositAmount })
        ).to.changeEtherBalances(
            [user, vault],
            [-depositAmount, depositAmount]
        );
    });

    it("debería permitir retiros", async function () {
        const { vault, user } = await loadFixture(deployVaultFixture);

        // Depositar primero
        await vault.connect(user).deposit({ value: ethers.parseEther("2.0") });

        // Retirar
        await expect(
            vault.connect(user).withdraw(ethers.parseEther("1.0"))
        ).to.changeEtherBalance(user, ethers.parseEther("1.0"));
    });
});

Testing de Custom Errors

describe("Custom Errors", function () {
    it("debería revertir con error personalizado", async function () {
        const { token, addr1 } = await loadFixture(deployFixture);

        await expect(
            token.connect(addr1).withdraw(100)
        ).to.be.revertedWithCustomError(token, "SaldoInsuficiente")
         .withArgs(0, 100);
    });
});

Manipulación de Tiempo y Bloques

const { time, mine } = require("@nomicfoundation/hardhat-toolbox/network-helpers");

describe("Time-dependent", function () {
    it("debería permitir retirar después del lockup", async function () {
        const { lock, unlockTime } = await loadFixture(deployFixture);

        // Avanzar el tiempo
        await time.increaseTo(unlockTime);

        // Ahora sí debería funcionar
        await expect(lock.withdraw()).not.to.be.reverted;
    });

    it("debería fallar antes del lockup", async function () {
        const { lock } = await loadFixture(deployFixture);

        await expect(lock.withdraw())
            .to.be.revertedWith("Too early");
    });
});

// Minar bloques
await mine(100); // Avanza 100 bloques

// Obtener timestamp actual
const latest = await time.latest();

Testing de Access Control

describe("Access Control", function () {
    it("solo el owner debería poder mintear", async function () {
        const { token, owner, addr1 } = await loadFixture(deployFixture);

        // Owner puede mintear
        await expect(token.mint(addr1.address, 1000)).not.to.be.reverted;

        // Otro usuario no puede
        await expect(
            token.connect(addr1).mint(addr1.address, 1000)
        ).to.be.revertedWith("Not owner");
    });

    it("debería transferir ownership", async function () {
        const { token, owner, addr1 } = await loadFixture(deployFixture);

        await token.transferOwnership(addr1.address);
        expect(await token.owner()).to.equal(addr1.address);
    });
});

Coverage

# Ejecutar coverage
npx hardhat coverage

# Output:
# ------------------|----------|----------|----------|----------|
# File              |  % Stmts | % Branch |  % Funcs |  % Lines |
# ------------------|----------|----------|----------|----------|
# contracts/        |      100 |    91.67 |      100 |      100 |
#   MiToken.sol     |      100 |    91.67 |      100 |      100 |
# ------------------|----------|----------|----------|----------|
# All files         |      100 |    91.67 |      100 |      100 |
# ------------------|----------|----------|----------|----------|

Mejores Prácticas

☐ Usar fixtures (loadFixture) para setup eficiente
☐ Testear happy paths Y edge cases
☐ Testear todos los require/revert
☐ Verificar emisión de eventos
☐ Testear con múltiples cuentas (roles)
☐ Testear límites (uint max, address(0))
☐ Testear reentrancy con contrato atacante
☐ Mantener coverage > 95%
☐ Usar natspec para documentar tests
☐ Organizar tests por funcionalidad (describe blocks)

Resumen

El testing de smart contracts usa Hardhat + Ethers.js + Chai para verificar comportamiento, balances, eventos, errores y access control. Los fixtures proporcionan setup eficiente y reproducible. Las utilidades de red permiten manipular tiempo y bloques. La cobertura de tests debe ser lo más cercana posible al 100% antes de cualquier deploy a mainnet.

🔒

Ejercicio práctico disponible

Framework de testing simplificado

Desbloquear ejercicios
// Framework de testing simplificado
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes