Tutoriel·11 min de lecture·Par Solingo

Tests de Smart Contracts avec Foundry — Guide Complet

Maîtrisez les tests de smart contracts avec Foundry : unit tests, fuzz testing, invariant testing et fork testing.

# 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

  • Testez TOUT : Chaque fonction, chaque edge case
  • Utilisez des noms descriptifs : testDeposit_RevertsIfAmountZero
  • Un test = une assertion (idéalement)
  • Fuzz testez les inputs : Ne faites pas confiance aux valeurs codées en dur
  • Invariant testez les propriétés critiques
  • Fork testez l'intégration avec les protocoles existants
  • Mesurez le gas : Optimisez avant le déploiement
  • 100% de coverage n'est pas suffisant, mais c'est un bon début
  • Conclusion

    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é.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement