# Tests de Smart Contracts avec Foundry — Guide Complet
Les bugs dans les smart contracts peuvent coûter des millions. Un code non testé est un code dangereux. Foundry est devenu l'outil de référence pour tester les smart contracts grâce à sa vitesse, sa puissance et son approche "test en Solidity".
Dans ce guide, nous allons maîtriser tous les types de tests avec Foundry : unit tests, fuzz testing, invariant testing, et fork testing.
Pourquoi Foundry pour les Tests ?
Comparé à Hardhat (tests en JavaScript), Foundry offre :
- Vitesse : 10-100x plus rapide (écrit en Rust)
- Tests en Solidity : Pas besoin de JavaScript
- Fuzz testing natif
- Invariant testing intégré
- Fork de mainnet ultra-rapide
- Gas snapshots automatiques
- Cheatcodes puissants pour manipuler l'EVM
Installation
# Installation de Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
# Vérification
forge --version
# forge 0.2.0
# Créer un nouveau projet
forge init my-project
cd my-project
Structure générée :
my-project/
├── src/
│ └── Counter.sol
├── test/
│ └── Counter.t.sol
├── script/
│ └── Counter.s.sol
└── foundry.toml
Les Bases : Unit Tests
Anatomie d'un Test Foundry
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import "../src/Token.sol";
contract TokenTest is Test {
Token token;
address alice = address(1);
address bob = address(2);
// Exécuté avant chaque test
function setUp() public {
token = new Token("Test Token", "TST");
token.mint(alice, 1000 ether);
}
// Test simple
function testBalance() public {
assertEq(token.balanceOf(alice), 1000 ether);
}
// Test avec expectRevert
function testTransferInsufficientBalance() public {
vm.prank(alice);
vm.expectRevert("Insufficient balance");
token.transfer(bob, 2000 ether);
}
// Test d'event
function testTransferEmitsEvent() public {
vm.prank(alice);
vm.expectEmit(true, true, false, true);
emit Transfer(alice, bob, 100 ether);
token.transfer(bob, 100 ether);
}
}
Assertions Disponibles
assertEq(a, b); // a == b
assertEq(a, b, "error"); // avec message personnalisé
assertNotEq(a, b); // a != b
assertGt(a, b); // a > b
assertGe(a, b); // a >= b
assertLt(a, b); // a < b
assertLe(a, b); // a <= b
assertTrue(a); // a == true
assertFalse(a); // a == false
Exécuter les Tests
# Tous les tests
forge test
# Tests avec verbosité
forge test -vvvv
# Un test spécifique
forge test --match-test testBalance
# Un fichier spécifique
forge test --match-path test/Token.t.sol
# Avec gas report
forge test --gas-report
Cheatcodes : Manipuler l'EVM
Les cheatcodes Foundry permettent de contrôler l'EVM pendant les tests.
Changer l'Identité de l'Appelant
// vm.prank(address) : Change msg.sender pour le PROCHAIN call uniquement
function testPrank() public {
vm.prank(alice);
token.transfer(bob, 100); // msg.sender = alice
token.transfer(bob, 100); // msg.sender = address(this)
}
// vm.startPrank(address) : Change msg.sender jusqu'à vm.stopPrank()
function testStartPrank() public {
vm.startPrank(alice);
token.transfer(bob, 100); // msg.sender = alice
token.approve(bob, 1000); // msg.sender = alice
vm.stopPrank();
}
Manipuler le Temps
function testVesting() public {
Vesting vesting = new Vesting(alice, 365 days);
// Avancer de 100 jours
vm.warp(block.timestamp + 100 days);
uint256 unlocked = vesting.unlockedAmount();
assertGt(unlocked, 0);
}
Gérer les ETH
function testPayable() public {
// Donner 100 ETH à alice
vm.deal(alice, 100 ether);
assertEq(alice.balance, 100 ether);
// Tester une fonction payable
vm.prank(alice);
crowdsale.contribute{value: 10 ether}();
}
Expectations (Attendre un Revert ou un Event)
// Attendre un revert
function testRevert() public {
vm.expectRevert("Not authorized");
token.mint(alice, 1000); // Doit revert
}
// Avec custom error
function testRevertCustomError() public {
vm.expectRevert(Unauthorized.selector);
token.mint(alice, 1000);
}
// Attendre un event
function testEvent() public {
vm.expectEmit(true, true, false, true);
emit Transfer(alice, bob, 100);
token.transfer(bob, 100);
}
Store & State Manipulation
// Modifier directement le storage
function testStorageManipulation() public {
bytes32 slot = bytes32(uint256(0)); // Slot du storage
bytes32 value = bytes32(uint256(999));
vm.store(address(token), slot, value);
}
// Lire le storage
function testLoadStorage() public {
bytes32 value = vm.load(address(token), bytes32(uint256(0)));
}
// Snapshot & Revert
function testSnapshot() public {
uint256 snapshot = vm.snapshot();
token.transfer(bob, 100);
assertEq(token.balanceOf(bob), 100);
vm.revertTo(snapshot);
assertEq(token.balanceOf(bob), 0); // État restauré
}
Fuzz Testing
Le fuzz testing génère automatiquement des inputs aléatoires pour tester toutes les combinaisons possibles.
Fuzz Test de Base
// Foundry va appeler cette fonction avec des valeurs aléatoires
function testFuzz_Transfer(uint256 amount) public {
// Limiter les inputs si nécessaire
vm.assume(amount <= token.balanceOf(alice));
vm.prank(alice);
token.transfer(bob, amount);
assertEq(token.balanceOf(bob), amount);
}
Par défaut, Foundry exécute 256 runs. Configurable :
# foundry.toml
[fuzz]
runs = 10000
Fuzz Test avec Plusieurs Paramètres
function testFuzz_TransferMulti(
address to,
uint256 amount
) public {
// Filtrer les cas invalides
vm.assume(to != address(0));
vm.assume(to != alice);
vm.assume(amount <= token.balanceOf(alice));
uint256 aliceBalanceBefore = token.balanceOf(alice);
uint256 toBalanceBefore = token.balanceOf(to);
vm.prank(alice);
token.transfer(to, amount);
assertEq(token.balanceOf(alice), aliceBalanceBefore - amount);
assertEq(token.balanceOf(to), toBalanceBefore + amount);
}
Bounded Fuzz Testing
// Utiliser bound() pour limiter les valeurs
function testFuzz_Mint(uint256 amount) public {
amount = bound(amount, 1, 1_000_000 ether);
token.mint(alice, amount);
assertEq(token.balanceOf(alice), amount);
}
Invariant Testing
Les invariants sont des propriétés qui doivent toujours être vraies, peu importe les actions effectuées.
Exemple : Token ERC-20
Invariant : La somme de tous les balances doit toujours égaler totalSupply.
contract TokenInvariantTest is Test {
Token token;
Handler handler;
function setUp() public {
token = new Token("Test", "TST");
handler = new Handler(token);
// Cibler uniquement le handler pour les invariant tests
targetContract(address(handler));
}
// Cet invariant sera vérifié après chaque action
function invariant_TotalSupplyEqualsBalances() public {
uint256 totalBalance = 0;
for (uint i = 0; i < handler.actors.length; i++) {
totalBalance += token.balanceOf(handler.actors[i]);
}
assertEq(totalBalance, token.totalSupply());
}
}
// Handler : définit les actions possibles
contract Handler {
Token token;
address[] public actors;
constructor(Token _token) {
token = _token;
actors.push(address(1));
actors.push(address(2));
actors.push(address(3));
}
function mint(uint256 actorSeed, uint256 amount) public {
address actor = actors[actorSeed % actors.length];
amount = bound(amount, 0, 1e6 ether);
token.mint(actor, amount);
}
function transfer(uint256 fromSeed, uint256 toSeed, uint256 amount) public {
address from = actors[fromSeed % actors.length];
address to = actors[toSeed % actors.length];
uint256 balance = token.balanceOf(from);
amount = bound(amount, 0, balance);
vm.prank(from);
token.transfer(to, amount);
}
}
Lancer les Invariant Tests
forge test --match-test invariant
Configuration :
[invariant]
runs = 256
depth = 15
fail_on_revert = false
Fork Testing
Tester sur un fork de mainnet permet de vérifier l'intégration avec les protocoles existants.
Fork une Blockchain
contract ForkTest is Test {
IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
address constant WHALE = 0x123...; // Adresse avec beaucoup d'USDC
function setUp() public {
// Fork mainnet au dernier block
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"));
}
function testUSDCTransfer() public {
uint256 amount = 1000e6; // 1000 USDC
vm.prank(WHALE);
USDC.transfer(address(this), amount);
assertEq(USDC.balanceOf(address(this)), amount);
}
}
Fork à un Block Spécifique
function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 17_000_000);
}
Tester l'Interaction avec Uniswap
contract UniswapForkTest is Test {
IUniswapV2Router02 router = IUniswapV2Router02(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
IERC20 constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"));
vm.deal(address(this), 100 ether);
}
function testSwapETHForUSDC() public {
address[] memory path = new address[](2);
path[0] = address(WETH);
path[1] = address(USDC);
uint256 amountIn = 1 ether;
router.swapExactETHForTokens{value: amountIn}(
0,
path,
address(this),
block.timestamp
);
assertGt(USDC.balanceOf(address(this)), 0);
}
}
Coverage
Mesurer la couverture de code :
forge coverage
# Générer un rapport HTML
forge coverage --report lcov
genhtml lcov.info -o coverage/
open coverage/index.html
Gas Optimization
Gas Snapshots
forge snapshot
# Compare avec le snapshot précédent
forge snapshot --diff
Génère un fichier .gas-snapshot :
TokenTest:testTransfer() (gas: 45678)
TokenTest:testMint() (gas: 52341)
Gas Report par Fonction
forge test --gas-report
Sortie :
| Function | Calls | Min | Avg | Max |
|---------------|-------|------|------|------|
| transfer | 100 | 5234 | 5789 | 6234 |
| mint | 50 | 4123 | 4456 | 4789 |
Debugging
Traces Détaillées
# -vv : logs
# -vvv : stack traces
# -vvvv : traces complètes
# -vvvvv : traces + setup traces
forge test --match-test testName -vvvv
Debugger Interactif
forge test --match-test testName --debug
Lance un debugger TUI (Terminal UI) qui permet :
- Step-by-step execution
- Inspection du stack
- Inspection de la mémoire
- Inspection du storage
Exemple Complet : Vault DeFi
// src/Vault.sol
contract Vault {
IERC20 public immutable token;
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
constructor(IERC20 _token) {
token = _token;
}
function deposit(uint256 amount) external {
require(amount > 0, "Amount must be > 0");
token.transferFrom(msg.sender, address(this), amount);
balanceOf[msg.sender] += amount;
totalSupply += amount;
}
function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "Insufficient balance");
balanceOf[msg.sender] -= amount;
totalSupply -= amount;
token.transfer(msg.sender, amount);
}
}
Tests complets :
// test/Vault.t.sol
contract VaultTest is Test {
Vault vault;
ERC20Mock token;
address alice = address(1);
address bob = address(2);
function setUp() public {
token = new ERC20Mock();
vault = new Vault(token);
token.mint(alice, 1000 ether);
token.mint(bob, 1000 ether);
}
// Unit tests
function testDeposit() public {
vm.startPrank(alice);
token.approve(address(vault), 100 ether);
vault.deposit(100 ether);
vm.stopPrank();
assertEq(vault.balanceOf(alice), 100 ether);
assertEq(vault.totalSupply(), 100 ether);
}
function testWithdraw() public {
vm.startPrank(alice);
token.approve(address(vault), 100 ether);
vault.deposit(100 ether);
vault.withdraw(50 ether);
vm.stopPrank();
assertEq(vault.balanceOf(alice), 50 ether);
assertEq(token.balanceOf(alice), 950 ether);
}
// Fuzz tests
function testFuzz_Deposit(uint256 amount) public {
amount = bound(amount, 1, 1000 ether);
vm.startPrank(alice);
token.approve(address(vault), amount);
vault.deposit(amount);
vm.stopPrank();
assertEq(vault.balanceOf(alice), amount);
}
// Invariant test
function invariant_TotalSupplyMatchesTokenBalance() public {
assertEq(vault.totalSupply(), token.balanceOf(address(vault)));
}
}
Best Practices
testDeposit_RevertsIfAmountZeroConclusion
Foundry a révolutionné les tests de smart contracts grâce à :
- ✅ Vitesse inégalée
- ✅ Tests en Solidity (pas de context switch)
- ✅ Fuzz testing natif
- ✅ Invariant testing intégré
- ✅ Fork testing ultra-rapide
- ✅ Cheatcodes puissants
Un code non testé est un code dangereux. Avec Foundry, vous n'avez plus d'excuse.
Apprenez Solidity et les tests sur Solingo — Devenez un développeur blockchain qui écrit du code sûr et testé.