智慧合約測試方法論完整指南
系統介紹單元測試、整合測試、模糊測試與形式化驗證的智慧合約測試策略。
智慧合約測試方法論完整指南
概述
智慧合約測試是確保區塊鏈應用安全性的關鍵環節。與傳統軟體不同,智慧合約一旦部署就無法修改,任何漏洞都可能導致不可挽回的資金損失。本文系統性地介紹智慧合約測試的方法論,涵蓋單元測試、整合測試、模糊測試、形式化驗證等各種技術,以及測試框架、工具和最佳實踐。適用於有一定 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..."
});
}
總結
智慧合約測試是確保安全性的關鍵:
單元測試:
- 測試每個函數的基本功能
- 覆蓋正常和錯誤情況
- 快速執行,及時反饋
整合測試:
- 測試合約間的交互
- 模擬真實使用場景
- 發現集成問題
模糊測試:
- 測試邊界條件
- 發現未知漏洞
- 提高安全性
形式化驗證:
- 數學證明合約屬性
- 發現深層次問題
- 最高安全標準
記住:沒有測試是足夠的。最好的策略是結合多種測試方法,持續改進測試覆蓋率,並定期進行專業審計。
相關文章
- OpenZeppelin 智慧合約庫使用完整指南 — 詳細介紹 OpenZeppelin Contracts 的 ERC 代幣標準、存取控制與安全工具。
- 智慧合約形式化驗證完整指南 — 系統介紹形式化驗證的數學方法與漏洞分類體系,包括 Certora、Runtime Verification 等工具。
- Tornado Cash 事件分析與隱私協議教訓 — 深入分析 2022 年 OFAC 制裁事件、技術機制與對加密隱私領域的深遠影響。
- 混幣協議風險評估與安全使用指南 — 系統分析混幣協議的智慧合約、法律合規與資產安全風險。
- 搶先交易與三明治攻擊防範完整指南 — 深入分析 MEV 搶先交易與三明治攻擊的技術機制及用戶、開發者防範策略。
延伸閱讀與來源
- Ethereum.org Developers 官方開發者入口與技術文件
- EIPs 以太坊改進提案
這篇文章對您有幫助嗎?
請告訴我們如何改進:
0 人覺得有帮助
評論
發表評論
注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。
目前尚無評論,成為第一個發表評論的人吧!