智慧合約測試方法論完整指南

系統介紹單元測試、整合測試、模糊測試與形式化驗證的智慧合約測試策略。

智慧合約測試方法論完整指南

概述

智慧合約測試是確保區塊鏈應用安全性的關鍵環節。與傳統軟體不同,智慧合約一旦部署就無法修改,任何漏洞都可能導致不可挽回的資金損失。本文系統性地介紹智慧合約測試的方法論,涵蓋單元測試、整合測試、模糊測試、形式化驗證等各種技術,以及測試框架、工具和最佳實踐。適用於有一定 Solidity 基礎的開發者。

測試金字塔

智慧合約測試金字塔

                    /\
                   /  \
                  / Fuzz \
                 /--------\
                /  Formal  \
               /   Verify   \
              /--------------\
             / Integration    \
            /------------------\
           /      Unit          \
          /----------------------\
層級數量執行速度發現問題
單元測試最多最快簡單邏輯錯誤
整合測試中等中等合約交互問題
模糊測試較少較慢邊界條件
形式化驗證最少最慢數學證明

測試環境設置

Hardhat 環境

// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("hardhat-contract-sizer");

module.exports = {
    solidity: {
        version: "0.8.20",
        settings: {
            optimizer: {
                enabled: true,
                runs: 200
            }
        }
    },
    networks: {
        hardhat: {
            chainId: 31337
        },
        localhost: {
            url: "http://127.0.0.1:8545"
        }
    },
    gasReporter: {
        enabled: true,
        currency: "USD"
    },
    contractSizer: {
        alphaSort: true,
        disambiguatePaths: false,
        runOnCompile: true,
        strict: true
    }
};

配置測試腳本

// scripts/test-setup.js
const { ethers } = require("hardhat");

async function setupTestEnvironment() {
    // 獲取測試帳戶
    const [owner, user1, user2, attacker] = await ethers.getSigners();

    // 部署測試代幣
    const Token = await ethers.getContractFactory("TestToken");
    const token = await Token.deploy(ethers.utils.parseEther("1000000"));
    await token.deployed();

    return {
        owner,
        user1,
        user2,
        attacker,
        token
    };
}

module.exports = { setupTestEnvironment };

單元測試

基本結構

// test/MyContract.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("MyContract", function () {
    let myContract;
    let owner;
    let user1;
    let user2;

    beforeEach(async function () {
        [owner, user1, user2] = await ethers.getSigners();

        const MyContract = await ethers.getContractFactory("MyContract");
        myContract = await MyContract.deploy();
        await myContract.deployed();
    });

    describe("Deployment", function () {
        it("should set the right owner", async function () {
            expect(await myContract.owner()).to.equal(owner.address);
        });

        it("should assign total supply to owner", async function () {
            const [ownerBalance] = await ethers.getSigners();
            const balance = await myContract.balanceOf(owner.address);
            expect(balance).to.equal(ethers.utils.parseEther("1000000"));
        });
    });

    describe("Transfers", function () {
        it("should transfer tokens between accounts", async function () {
            await myContract.transfer(user1.address, ethers.utils.parseEther("50"));

            expect(await myContract.balanceOf(user1.address)).to.equal(
                ethers.utils.parseEther("50")
            );
        });

        it("should fail if sender doesn't have enough tokens", async function () {
            const initialBalance = await myContract.balanceOf(owner.address);
            await expect(
                myContract.connect(user1).transfer(
                    owner.address,
                    ethers.utils.parseEther("1")
                )
            ).to.be.revertedWith("Insufficient balance");
        });
    });
});

詳細測試案例

// 完整測試示例
describe("Token Contract", function () {
    // 測試 ERC20 基本功能
    describe("Basic Transfers", function () {
        it("should transfer correctly", async function () {
            const amount = ethers.utils.parseEther("100");

            // 記錄餘額
            const initialOwnerBalance = await token.balanceOf(owner.address);
            const initialRecipientBalance = await token.balanceOf(user1.address);

            // 執行轉帳
            await token.transfer(user1.address, amount);

            // 驗證結果
            expect(await token.balanceOf(owner.address)).to.equal(
                initialOwnerBalance.sub(amount)
            );
            expect(await token.balanceOf(user1.address)).to.equal(
                initialRecipientBalance.add(amount)
            );
        });

        it("should emit Transfer event", async function () {
            const amount = ethers.utils.parseEther("100");

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

    // 測試邊界條件
    describe("Edge Cases", function () {
        it("should handle zero transfer", async function () {
            await token.transfer(user1.address, 0);
            expect(await token.balanceOf(user1.address)).to.equal(0);
        });

        it("should handle transfer to zero address", async function () {
            await expect(
                token.transfer(
                    ethers.constants.AddressZero,
                    ethers.utils.parseEther("100")
                )
            ).to.be.revertedWith("Transfer to zero address");
        });

        it("should handle overflow", async function () {
            // 嘗試轉帳超過餘額
            const balance = await token.balanceOf(owner.address);
            await expect(
                token.transfer(user1.address, balance.add(1))
            ).to.be.reverted;
        });
    });

    // 測試權限控制
    describe("Access Control", function () {
        it("should allow owner to mint", async function () {
            const initialSupply = await token.totalSupply();
            await token.mint(user1.address, ethers.utils.parseEther("1000"));

            expect(await token.totalSupply()).to.equal(
                initialSupply.add(ethers.utils.parseEther("1000"))
            );
        });

        it("should prevent non-owner from minting", async function () {
            await expect(
                token.connect(user1).mint(
                    user2.address,
                    ethers.utils.parseEther("1000")
                )
            ).to.be.revertedWith("AccessControl");
        });

        it("should allow minter to mint", async function () {
            await token.grantRole(
                await token.MINTER_ROLE(),
                user1.address
            );

            await token.connect(user1).mint(
                user2.address,
                ethers.utils.parseEther("1000")
            );

            expect(await token.balanceOf(user2.address)).to.equal(
                ethers.utils.parseEther("1000")
            );
        });
    });
});

整合測試

多合約交互測試

// test/AMM.test.js
describe("AMM Integration", function () {
    let factory;
    let router;
    let tokenA;
    let tokenB;
    let pair;
    let owner;
    let user1;

    beforeEach(async function () {
        [owner, user1] = await ethers.getSigners();

        // 部署工廠
        const Factory = await ethers.getContractFactory("UniswapV2Factory");
        factory = await Factory.deploy(owner.address);
        await factory.deployed();

        // 部署代幣
        const Token = await ethers.getContractFactory("Token");
        tokenA = await Token.deploy("TokenA", "TKA");
        tokenB = await Token.deploy("TokenB", "TKB");

        // 部署路由
        const Router = await ethers.getContractFactory("UniswapV2Router02");
        router = await Router.deploy(factory.address, owner.address);
        await router.deployed();

        // 添加流動性
        await tokenA.approve(router.address, ethers.utils.parseEther("1000"));
        await tokenB.approve(router.address, ethers.utils.parseEther("1000"));
        await router.addLiquidity(
            tokenA.address,
            tokenB.address,
            ethers.utils.parseEther("100"),
            ethers.utils.parseEther("100"),
            0,
            0,
            owner.address,
            Math.floor(Date.now() / 1000) + 3600
        );

        // 獲取交易對地址
        const pairAddress = await factory.getPair(tokenA.address, tokenB.address);
        pair = await ethers.getContractAt("UniswapV2Pair", pairAddress);
    });

    it("should add liquidity correctly", async function () {
        const balance = await pair.balanceOf(owner.address);
        expect(balance).to.be.gt(0);
    });

    it("should swap tokens correctly", async function () {
        const path = [tokenA.address, tokenB.address];
        const amountIn = ethers.utils.parseEther("1");

        await tokenA.approve(router.address, amountIn);

        const amounts = await router.getAmountsOut(amountIn, path);
        const expectedOutput = amounts[1];

        await router.swapExactTokensForTokens(
            amountIn,
            0,
            path,
            user1.address,
            Math.floor(Date.now() / 1000) + 3600
        );

        expect(await tokenB.balanceOf(user1.address)).to.equal(expectedOutput);
    });

    it("should handle price impact", async function () {
        // 大額交易測試價格影響
        const amountIn = ethers.utils.parseEther("50");
        const path = [tokenA.address, tokenB.address];

        await tokenA.approve(router.address, amountIn);

        const amounts = await router.getAmountsOut(amountIn, path);
        const minOutput = amounts[1].mul(95).div(100); // 5% 滑點容忍

        await router.swapExactTokensForTokens(
            amountIn,
            minOutput,
            path,
            user1.address,
            Math.floor(Date.now() / 1000) + 3600
        );
    });
});

模擬攻擊場景

// test/attacks.test.js
describe("Attack Scenarios", function () {
    // 測試重入攻擊
    describe("Reentrancy Attack", function () {
        it("should prevent reentrancy in withdraw", async function () {
            const [owner, attacker] = await ethers.getSigners();

            // 部署有漏洞的合約
            const VulnerableVault = await ethers.getContractFactory("VulnerableVault");
            const vulnerable = await VulnerableVault.deploy();
            await vulnerable.deployed();

            // 部署攻擊合約
            const Attack = await ethers.getContractFactory("ReentrancyAttack");
            const attack = await Attack.deploy(vulnerable.address);
            await attack.deployed();

            // 存款
            await vulnerable.deposit({ value: ethers.utils.parseEther("10") });

            // 攻擊
            await attack.attack({ value: ethers.utils.parseEther("1") });

            // 驗證 - 有漏洞的合約應該被盜空
            expect(await ethers.provider.getBalance(vulnerable.address)).to.equal(0);
        });

        it("should be protected by ReentrancyGuard", async function () {
            const SecureVault = await ethers.getContractFactory("SecureVault");
            const secure = await SecureVault.deploy();
            await secure.deployed();

            await secure.deposit({ value: ethers.utils.parseEther("10") });

            const Attack = await ethers.getContractFactory("ReentrancyAttack");
            const attack = await Attack.deploy(secure.address);
            await attack.deployed();

            await expect(
                attack.attack({ value: ethers.utils.parseEther("1") })
            ).to.be.reverted;
        });
    });

    // 測試閃電貸攻擊
    describe("Flash Loan Attack", function () {
        it("should detect price manipulation", async function () {
            // 部署目標合約
            const Target = await ethers.getContractFactory("PriceOracleTarget");
            const target = await Target.deploy();
            await target.deployed();

            // 獲取初始價格
            const initialPrice = await target.getPrice();

            // 模擬閃電貸攻擊
            const Attacker = await ethers.getContractFactory("FlashLoanAttacker");
            const attacker = await Attacker.deploy(target.address);
            await attacker.deployed();

            await attacker.executeAttack({ value: ethers.utils.parseEther("100") });

            // 檢查價格是否被操控
            const newPrice = await target.getPrice();
            const priceChange = newPrice.mul(10000).div(initialPrice);

            // 價格變化不應超過 50%
            expect(priceChange).to.be.lt(15000);
        });
    });
});

模糊測試(Fuzz Testing)

使用 Foundry

// test/Counter.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
        counter.setNumber(0);
    }

    function testIncrement() public {
        counter.increment();
        assertEq(counter.number(), 1);
    }

    function testSetNumber(uint256 x) public {
        counter.setNumber(x);
        assertEq(counter.number(), x);
    }

    // 模糊測試 - 測試各種輸入
    function testFuzzSetNumber(uint256 x) public {
        counter.setNumber(x);
        assertEq(counter.number(), x);
    }

    function testFuzzIncrement(uint256 x) public {
        counter.setNumber(x);
        counter.increment();
        assertEq(counter.number(), x + 1);
    }

    // 邊界測試
    function testFuzzSetNumberEdgeCases(uint8 x) public {
        // 測試 0, 1, 127, 128, 255
        counter.setNumber(x);
        assertEq(counter.number(), x);
    }

    // 攻擊者視角
    function testForkState() public {
        // 在測試中使用真實網路狀態
    }
}

使用 Hardhat + Ethers

// test/fuzz.test.js
const { ethers } = require("hardhat");

describe("Fuzz Testing", function () {
    it("should handle various transfer amounts", async function () {
        const [owner, user1] = await ethers.getSigners();
        const Token = await ethers.getContractFactory("Token");
        const token = await Token.deploy(ethers.utils.parseEther("1000000"));
        await token.deployed();

        // 測試大量隨機值
        const testCases = [0, 1, 100, 1000, 10000, "MAX_UINT256"];

        for (const amount of testCases) {
            try {
                const value = amount === "MAX_UINT256"
                    ? ethers.constants.MaxUint256
                    : ethers.utils.parseEther(amount.toString());

                await token.transfer(user1.address, value);
                console.log(`Transfer ${amount} succeeded`);
            } catch (error) {
                console.log(`Transfer ${amount} failed: ${error.message}`);
            }
        }
    });
});

屬性測試

// 使用 echidna 進行屬性測試
// contracts/EchidnaTest.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract TokenProperties is Test {
    Token public token;
    address[] public users;

    function setUp() public {
        token = new Token();
        token.mint(address(this), 1000 ether);

        // 創建測試用戶
        for (uint i = 0; i < 10; i++) {
            users.push(makeAddr(Strings.toString(i)));
        }
    }

    // 屬性:總供應量恆定
    function echidna_totalSupplyConstant() public view {
        uint256 total = token.totalSupply();
        assertEq(total, 1000 ether);
    }

    // 屬性:餘額不會為負
    function echidna_balanceNonNegative(address user) public view {
        uint256 balance = token.balanceOf(user);
        assert(balance >= 0);
    }

    // 屬性:轉帳後雙方餘額正確
    function echidna_transferConservation(
        address from,
        address to,
        uint256 amount
    ) public {
        vm.assume(from != to);
        vm.assume(amount <= token.balanceOf(from));

        uint256 beforeFrom = token.balanceOf(from);
        uint256 beforeTo = token.balanceOf(to);

        vm.prank(from);
        token.transfer(to, amount);

        assertEq(token.balanceOf(from), beforeFrom - amount);
        assertEq(token.balanceOf(to), beforeTo + amount);
    }
}

形式化驗證

Certora 驗證

// contracts/Token.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {
    uint256 public constant MAX_SUPPLY = 1000000 * 10**18;

    constructor() ERC20("Token", "TKN") {
        _mint(msg.sender, 100000 * 10**18);
    }

    function mint(address to, uint256 amount) external {
        require(totalSupply() + amount <= MAX_SUPPLY, "Max supply");
        _mint(to, amount);
    }
}
// certora/specs/Token.spec
rule totalSupplyNeverExceedsMax() {
    uint256 maxSupply = 1000000 * 10^18;
    uint256 currentSupply = token.totalSupply();
    assert currentSupply <= maxSupply, "Total supply exceeds maximum";
}

rule transferPreservesBalance(address from, address to, uint256 amount) {
    uint256 balanceFromBefore = token.balanceOf(from);
    uint256 balanceToBefore = token.balanceOf(to);

    env e;
    require e.msg.sender == from;
    require amount <= balanceFromBefore;

    call e, token.transfer(to, amount);

    uint256 balanceFromAfter = token.balanceOf(from);
    uint256 balanceToAfter = token.balanceOf(to);

    assert balanceFromAfter == balanceFromBefore - amount, "From balance incorrect";
    assert balanceToAfter == balanceToBefore + amount, "To balance incorrect";
}

rule noDoubleSpending(address from, address to, uint256 amount) {
    env e;
    require e.msg.sender == from;

    uint256 balance = token.balanceOf(from);
    require amount <= balance;

    call e, token.transfer(to, amount);

    assert token.balanceOf(from) == balance - amount, "Double spending possible";
}

SMT 求解器測試

// 使用 hardhat proving
describe("Formal Verification", function () {
    it("should prove contract properties", async function () {
        // 使用 Halmos 或其他形式化工具
    });
});

測試覆蓋率

運行覆蓋率報告

# Hardhat 覆蓋率
npx hardhat coverage

# Foundry 覆蓋率
forge coverage

提高覆蓋率策略

// 確保測試所有函數
describe("Full Coverage", function () {
    // 正常情況
    it("should work in normal case", async function () { });

    // 邊界情況
    it("should handle edge cases", async function () { });

    // 錯誤情況
    it("should revert on errors", async function () { });

    // 事件
    it("should emit correct events", async function () { });

    // 權限
    it("should enforce access control", async function () { });
});

Gas 優化測試

// test/gas.test.js
describe("Gas Optimization", function () {
    it("should measure gas for deployment", async function () {
        const Factory = await ethers.getContractFactory("MyContract");
        const tx = Factory.getDeployTransaction();
        const receipt = await ethers.provider.sendTransaction(
            tx
        );

        console.log("Deployment gas:", receipt.gasUsed.toString());
        expect(receipt.gasUsed).to.be.lt(1000000); // 應低於 1M gas
    });

    it("should measure gas for operations", async function () {
        const MyContract = await ethers.getContractFactory("MyContract");
        const contract = await MyContract.deploy();

        // 測量函數調用 gas
        const tx = await contract.myFunction();
        const receipt = await tx.wait();

        console.log("Function gas:", receipt.gasUsed.toString());
    });
});

測試最佳實踐

測試組織結構

test/
├── unit/
│   ├── Token.test.js
│   └── Vault.test.js
├── integration/
│   ├── AMM.test.js
│   └── Lending.test.js
├── fuzz/
│   └── TokenFuzz.test.js
└── attacks/
    └── Reentrancy.test.js

AAA 模式

// Arrange - 準備測試數據
// Act - 執行操作
// Assert - 驗證結果

it("should transfer correctly", async function () {
    // Arrange
    const amount = ethers.utils.parseEther("100");
    const initialBalance = await token.balanceOf(user1.address);

    // Act
    await token.transfer(user1.address, amount);

    // Assert
    expect(await token.balanceOf(user1.address)).to.equal(
        initialBalance.add(amount)
    );
});

測試命名規範

describe("ContractName", function () {
    describe("functionName", function () {
        it("should [expected behavior] when [condition]", async function () { });
        it("should revert when [error condition]", async function () { });
        it("should emit [event] when [trigger]", async function () { });
    });
});

// 示例
describe("Vault", function () {
    describe("deposit", function () {
        it("should increase balance when deposit is valid", async function () { });
        it("should revert when amount is zero", async function () { });
        it("should emit Deposit event when successful", async function () { });
    });
});

常見錯誤與避免方法

1. 忘記設置測試環境

// 錯誤
describe("Token", function () {
    it("should work", async function () {
        const Token = await ethers.getContractFactory("Token");
        const token = await Token.deploy();
        // ...
    });
});

// 正確 - 使用 beforeEach
describe("Token", function () {
    let token;

    beforeEach(async function () {
        const Token = await ethers.getContractFactory("Token");
        token = await Token.deploy();
    });

    it("should work", async function () {
        // token 可用
    });
});

2. 忽略異步操作

// 錯誤
it("should transfer", async function () {
    await token.transfer(user1.address, 100);
    expect(await token.balanceOf(user1.address)).to.equal(100); // 忘記 await
});

// 正確
it("should transfer", async function () {
    await token.transfer(user1.address, 100);
    expect(await token.balanceOf(user1.address)).to.equal(100);
});

3. 硬編碼地址

// 錯誤
const owner = "0x1234567890123456789012345678901234567890";

// 正確 - 動態獲取
const [owner] = await ethers.getSigners();

4. 忽略時間相關測試

// 正確處理時間
it("should unlock after time", async function () {
    await token.lock(100);

    // 快進時間
    await ethers.provider.send("evm_increaseTime", [100]);
    await ethers.provider.send("evm_mine", []);

    await token.unlock();
});

自動化測試工具

CI/CD 集成

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
      - run: npm install
      - run: npx hardhat test
      - run: npx hardhat coverage

持續監控

// 部署後監控
const { tenderly } = require("@tenderly/hardhat-tenderly");

async function verifyOnTenderly() {
    await tenderly.verify({
        name: "MyContract",
        address: "0x..."
    });
}

總結

智慧合約測試是確保安全性的關鍵:

單元測試

整合測試

模糊測試

形式化驗證

記住:沒有測試是足夠的。最好的策略是結合多種測試方法,持續改進測試覆蓋率,並定期進行專業審計。

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!