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.