Zum Hauptinhalt springen
6 Min. Lesezeit R.S.

Smart Contract Testing: Von Unit Tests bis Fuzzing

Tests für Smart Contracts sind keine optionale Qualitätssicherung. Sie sind die einzige Sicherheitsleine vor einem irreversiblen Deployment. Dieser Beitrag beschreibt unsere Teststrategie — von einfachen Unit Tests über Integrationstests bis zu Fuzzing — mit konkreten Codebeispielen aus Foundry.

Die Testpyramide für Smart Contracts

              /\
             /  \        Formal Verification
            /----\       (Invarianten mathematisch bewiesen)
           /      \
          / Fuzzing \    Property-Based + Fuzz Testing
         /----------\    (Zufällige Inputs, tausende Runs)
        /            \
       / Integration  \  Multi-Contract-Interaktion
      /----------------\
     /                  \
    /    Unit Tests      \ Einzelne Funktionen isoliert
   /______________________\

Die Basis bilden Unit Tests: schnell, isoliert, deterministisch. Jede Ebene darüber findet Fehler, die die darunter liegende nicht finden kann.

Unit Tests mit Foundry

Foundry (forge) ist unser bevorzugtes Test-Framework. Tests werden in Solidity geschrieben — keine JavaScript-Übersetzungsschicht, keine ABI-Encoding-Überraschungen.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Vault.sol";

contract VaultTest is Test {
    Vault vault;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        vault = new Vault();
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
    }

    function test_Deposit() public {
        vm.prank(alice);
        vault.deposit{value: 1 ether}();

        assertEq(vault.balances(alice), 1 ether);
        assertEq(address(vault).balance, 1 ether);
    }

    function test_Withdraw() public {
        // Setup
        vm.prank(alice);
        vault.deposit{value: 5 ether}();

        // Act
        uint256 balanceBefore = alice.balance;
        vm.prank(alice);
        vault.withdraw();

        // Assert
        assertEq(alice.balance, balanceBefore + 5 ether);
        assertEq(vault.balances(alice), 0);
    }

    function test_RevertWhen_WithdrawWithoutBalance() public {
        vm.prank(bob);
        vm.expectRevert("No balance");
        vault.withdraw();
    }
}

Wichtige Foundry-Cheatcodes:

  • vm.prank(addr) — Nächster Call wird von addr ausgeführt
  • vm.deal(addr, amount) — Setzt den ETH-Balance
  • vm.warp(timestamp) — Setzt block.timestamp
  • vm.roll(blockNumber) — Setzt block.number
  • vm.expectRevert(msg) — Erwartet Revert beim nächsten Call
  • vm.expectEmit() — Erwartet ein bestimmtes Event

Integrationstests

Unit Tests prüfen einzelne Funktionen. Integrationstests prüfen das Zusammenspiel mehrerer Contracts. Ein typisches Szenario: ein Governance-Token, ein Voting-Contract und ein Timelock, die zusammenarbeiten.

contract GovernanceIntegrationTest is Test {
    GovernanceToken token;
    Governor governor;
    Timelock timelock;

    address proposer = makeAddr("proposer");
    address voter1 = makeAddr("voter1");
    address voter2 = makeAddr("voter2");

    function setUp() public {
        token = new GovernanceToken();
        timelock = new Timelock(1 days, new address[](0), new address[](0));
        governor = new Governor(token, timelock);

        // Proposer und Voter mit Token ausstatten
        token.mint(proposer, 100_000e18);
        token.mint(voter1, 500_000e18);
        token.mint(voter2, 400_000e18);

        // Delegation (nötig für Voting Power)
        vm.prank(voter1);
        token.delegate(voter1);
        vm.prank(voter2);
        token.delegate(voter2);

        // 1 Block vorspulen (Delegation braucht Checkpoint)
        vm.roll(block.number + 1);
    }

    function test_FullGovernanceFlow() public {
        // 1. Proposal erstellen
        address[] memory targets = new address[](1);
        targets[0] = address(token);
        uint256[] memory values = new uint256[](1);
        bytes[] memory calldatas = new bytes[](1);
        calldatas[0] = abi.encodeCall(token.mint, (proposer, 50_000e18));

        vm.prank(proposer);
        uint256 proposalId = governor.propose(
            targets, values, calldatas, "Mint additional tokens"
        );

        // 2. Voting-Delay abwarten
        vm.roll(block.number + governor.votingDelay() + 1);

        // 3. Abstimmen
        vm.prank(voter1);
        governor.castVote(proposalId, 1); // For
        vm.prank(voter2);
        governor.castVote(proposalId, 1); // For

        // 4. Voting-Period abwarten
        vm.roll(block.number + governor.votingPeriod() + 1);

        // 5. Queue + Execute
        governor.queue(targets, values, calldatas, keccak256("Mint additional tokens"));
        vm.warp(block.timestamp + 1 days + 1);
        governor.execute(targets, values, calldatas, keccak256("Mint additional tokens"));

        // 6. Ergebnis prüfen
        assertEq(token.balanceOf(proposer), 150_000e18);
    }
}

Fuzz Testing mit Foundry

Fuzz Tests lassen Foundry zufällige Inputs generieren. Statt ein paar handverlesene Werte zu testen, prüfen Sie eine Eigenschaft über tausende Durchläufe:

function testFuzz_DepositAndWithdraw(
    uint256 amount
) public {
    // Bounds: sinnvoller Wertebereich
    amount = bound(amount, 1, 100 ether);

    vm.deal(alice, amount);
    vm.startPrank(alice);

    vault.deposit{value: amount}();
    assertEq(vault.balances(alice), amount);

    uint256 balanceBefore = alice.balance;
    vault.withdraw();

    assertEq(alice.balance, balanceBefore + amount);
    assertEq(vault.balances(alice), 0);

    vm.stopPrank();
}

// Invariante: Vault-Balance = Summe aller User-Balances
function testFuzz_VaultBalanceInvariant(
    uint256 amountAlice,
    uint256 amountBob
) public {
    amountAlice = bound(amountAlice, 0, 50 ether);
    amountBob = bound(amountBob, 0, 50 ether);

    vm.deal(alice, amountAlice);
    vm.deal(bob, amountBob);

    if (amountAlice > 0) {
        vm.prank(alice);
        vault.deposit{value: amountAlice}();
    }
    if (amountBob > 0) {
        vm.prank(bob);
        vault.deposit{value: amountBob}();
    }

    assertEq(
        address(vault).balance,
        vault.balances(alice) + vault.balances(bob)
    );
}

Standardmäßig führt Foundry 256 Runs pro Fuzz-Test durch. Für sicherheitskritische Contracts erhöhen wir auf 10.000+:

# foundry.toml
[fuzz]
runs = 10000
seed = 42        # Reproduzierbar
max_test_rejects = 65536

Stateful Fuzzing mit Echidna

Foundry-Fuzzing testet einzelne Funktionsaufrufe. Echidna kann Sequenzen von Aufrufen generieren — es findet Bugs, die nur durch eine bestimmte Abfolge von Transaktionen entstehen.

// Echidna-Testvertrag
contract VaultEchidnaTest {
    Vault vault;

    constructor() {
        vault = new Vault();
    }

    // Echidna ruft deposit() und withdraw() in zufälliger
    // Reihenfolge mit zufälligen Parametern auf.
    function deposit() public payable {
        vault.deposit{value: msg.value}();
    }

    function withdraw() public {
        try vault.withdraw() {} catch {}
    }

    // Invariante: Vault darf nie mehr auszahlen als eingezahlt
    function echidna_vault_solvent() public view returns (bool) {
        return address(vault).balance >= 0;
        // Echidna versucht, diese Eigenschaft zu brechen
    }
}
# Echidna ausführen
$ echidna test/VaultEchidna.sol --contract VaultEchidnaTest
# Standardmäßig 50.000 Transaktionssequenzen

Coverage und CI

Coverage-Reports zeigen, welche Code-Pfade nicht getestet sind:

$ forge coverage --report lcov
# Ergebnis: src/Vault.sol .......... 100.0% (branches: 94.4%)

# In der CI-Pipeline (GitHub Actions):
- name: Run tests
  run: forge test --gas-report
- name: Check coverage
  run: |
    forge coverage --report summary
    # Fail if branch coverage < 95%

Unsere Mindestanforderungen für ein Deployment:

  • 100 % Line-Coverage
  • > 95 % Branch-Coverage
  • Alle Fuzz-Tests mit mindestens 1.000 Runs bestanden
  • Stateful Fuzzing mit mindestens 50.000 Sequenzen
  • Keine Slither-Findings mit Severity High oder Medium

Fazit

Kein einzelnes Test-Level reicht aus. Unit Tests finden offensichtliche Fehler schnell. Integrationstests decken Interaktionsprobleme auf. Fuzz Tests finden Edge Cases, die kein Entwickler sich ausdenken würde. Stateful Fuzzing findet Sequenzen, die nur durch bestimmte Transaktionsabfolgen auftreten.

Der Zeitaufwand für eine vollständige Teststrategie liegt erfahrungsgemäß bei 40-60 % der gesamten Entwicklungszeit. Das klingt viel. Verglichen mit den Kosten eines Exploits auf einem live Contract ist es eine günstige Versicherung.