从手动测试到自动验证:Truffle与Hardhat智能合约测试实战_千锋教育

来源:千锋教育
发布时间:2026-07-03 18:19:38

千锋教育品牌logo

01 为什么智能合约测试不是“锦上添花”,而是“生死存亡”

2022年,跨链桥Ronin被黑,损失6.2亿美元。2023年,Curve Finance重入攻击,2.5亿美元蒸发。这些事故的共性?智能合约代码中的逻辑漏洞,在部署前未被充分测试。

区块链的特性决定了智能合约一旦部署,代码不可篡改。传统软件可以发补丁,智能合约只能硬分叉或直接放弃。这意味着,测试不是开发流程的附属品,而是资产安全的最后一道防线。

智能合约测试与传统Web测试有本质区别:

  • ►确定性:同一输入必定产生同一输出,测试结果可精确复现
  • ►状态敏感:每个交易改变合约状态,测试需模拟完整生命周期
  • ►Gas成本:代码效率直接影响执行成本,测试需验证Gas消耗
  • ►不可逆性:无法回滚已确认的交易,测试必须覆盖边界情况

代码即法律,测试即法庭。没有经过审判的法律,就是暴政。

数据不会说谎:根据DeFi安全报告,2023年所有重大安全事件中,78%源于未经充分测试的逻辑漏洞。其中,重入攻击占32%,访问控制漏洞占28%,算术溢出占15%。

这意味着什么?如果你跳过测试,你有78%的概率在部署后3个月内遭遇攻击。这不是危言耸听,这是统计概率。

02 Truffle:老牌框架的完整测试体系

Truffle是智能合约开发领域的老牌框架,从2015年诞生至今,积累了庞大的生态和社区支持。它的测试体系基于Mocha和Chai,对JavaScript开发者极其友好。

环境搭建

bash
npm install -g truffle
truffle init my-project
cd my-project

编写测试合约

创建一个简单的代币合约 contracts/MyToken.sol:

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

contract MyToken {
    string public name = "MyToken";
    string public symbol = "MTK";
    uint8 public decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 _initialSupply) {
        totalSupply = _initialSupply * 10 ** decimals;
        balanceOf[msg.sender] = totalSupply;
    }

    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(balanceOf[msg.sender] >= _value, "Insufficient balance");
        balanceOf[msg.sender] -= _value;
        balanceOf[_to] += _value;
        emit Transfer(msg.sender, _to, _value);
        return true;
    }

    function approve(address _spender, uint256 _value) public returns (bool success) {
        allowance[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _value);
        return true;
    }

    function transferFrom(address _from, address _to, uint256 _value) public returns (bool success) {
        require(balanceOf[_from] >= _value, "Insufficient balance");
        require(allowance[_from][msg.sender] >= _value, "Allowance exceeded");
        balanceOf[_from] -= _value;
        balanceOf[_to] += _value;
        allowance[_from][msg.sender] -= _value;
        emit Transfer(_from, _to, _value);
        return true;
    }
}

编写Truffle测试

在 test/ 目录下创建 TestMyToken.js:

javascript
const MyToken = artifacts.require("MyToken");

contract("MyToken", (accounts) => {
    const [owner, user1, user2] = accounts;
    const initialSupply = 1000;

    beforeEach(async () => {
        this.token = await MyToken.new(initialSupply);
    });

    describe("代币基本信息", () => {
        it("应该正确设置名称和符号", async () => {
            const name = await this.token.name();
            const symbol = await this.token.symbol();
            assert.equal(name, "MyToken");
            assert.equal(symbol, "MTK");
        });

        it("应该正确分配初始供应量", async () => {
            const balance = await this.token.balanceOf(owner);
            const totalSupply = await this.token.totalSupply();
            assert.equal(balance.toString(), totalSupply.toString());
            assert.equal(totalSupply.toString(), (initialSupply * 10 ** 18).toString());
        });
    });

    describe("转账功能", () => {
        it("应该成功转账", async () => {
            const amount = web3.utils.toWei("100", "ether");
            await this.token.transfer(user1, amount, { from: owner });
            const user1Balance = await this.token.balanceOf(user1);
            assert.equal(user1Balance.toString(), amount);
        });

        it("余额不足时应拒绝转账", async () => {
            const amount = web3.utils.toWei("2000", "ether");
            try {
                await this.token.transfer(user1, amount, { from: owner });
                assert.fail("应该抛出异常");
            } catch (error) {
                assert(error.message.includes("Insufficient balance"));
            }
        });

        it("转账应该触发事件", async () => {
            const amount = web3.utils.toWei("50", "ether");
            const result = await this.token.transfer(user1, amount, { from: owner });
            assert.equal(result.logs[0].event, "Transfer");
            assert.equal(result.logs[0].args.from, owner);
            assert.equal(result.logs[0].args.to, user1);
        });
    });

    describe("授权与代理转账", () => {
        it("应该设置授权额度", async () => {
            const amount = web3.utils.toWei("100", "ether");
            await this.token.approve(user1, amount, { from: owner });
            const allowance = await this.token.allowance(owner, user1);
            assert.equal(allowance.toString(), amount);
        });

        it("应该使用授权额度完成代理转账", async () => {
            const approveAmount = web3.utils.toWei("100", "ether");
            const transferAmount = web3.utils.toWei("50", "ether");
            
            await this.token.approve(user1, approveAmount, { from: owner });
            await this.token.transferFrom(owner, user2, transferAmount, { from: user1 });
            
            const user2Balance = await this.token.balanceOf(user2);
            assert.equal(user2Balance.toString(), transferAmount);
        });

        it("授权额度不足时应拒绝代理转账", async () => {
            const approveAmount = web3.utils.toWei("10", "ether");
            const transferAmount = web3.utils.toWei("50", "ether");
            
            await this.token.approve(user1, approveAmount, { from: owner });
            try {
                await this.token.transferFrom(owner, user2, transferAmount, { from: user1 });
                assert.fail("应该抛出异常");
            } catch (error) {
                assert(error.message.includes("Allowance exceeded"));
            }
        });
    });
});

运行测试

bash
truffle test

输出示例:

text
Contract: MyToken
  代币基本信息
    ✓ 应该正确设置名称和符号 (45ms)
    ✓ 应该正确分配初始供应量 (52ms)
  转账功能
    ✓ 应该成功转账 (48ms)
    ✓ 余额不足时应拒绝转账 (41ms)
    ✓ 转账应该触发事件 (53ms)
  授权与代理转账
    ✓ 应该设置授权额度 (44ms)
    ✓ 应该使用授权额度完成代理转账 (56ms)
    ✓ 授权额度不足时应拒绝代理转账 (38ms)

  8 passing (2s)

Truffle的优势在于简洁的API和成熟的生态系统。但它的缺点也明显:测试速度慢,每次测试都部署新合约;调试困难,错误信息不够直观。

03 Hardhat:新一代测试框架的速度与灵活性

Hardhat是2019年推出的新一代开发框架,迅速成为行业标准。它的测试速度比Truffle快3-5倍,调试能力更强。

环境搭建

bash
npm install --save-dev hardhat
npx hardhat init

选择"Create an empty hardhat.config.js"

配置Hardhat

编辑 hardhat.config.js:

javascript
require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.19",
  networks: {
    hardhat: {
      chainId: 1337,
    },
  },
  gasReporter: {
    enabled: true,
    currency: "USD",
  },
};

编写Hardhat测试

在 test/ 目录下创建 MyTokenTest.js:

javascript
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("MyToken", function () {
    let token;
    let owner, user1, user2;

    beforeEach(async function () {
        [owner, user1, user2] = await ethers.getSigners();
        const MyToken = await ethers.getContractFactory("MyToken");
        token = await MyToken.deploy(1000);
        await token.deployed();
    });

    describe("代币基本信息", function () {
        it("应该正确设置名称和符号", async function () {
            expect(await token.name()).to.equal("MyToken");
            expect(await token.symbol()).to.equal("MTK");
        });

        it("应该正确分配初始供应量", async function () {
            const ownerBalance = await token.balanceOf(owner.address);
            const totalSupply = await token.totalSupply();
            expect(ownerBalance).to.equal(totalSupply);
            expect(totalSupply).to.equal(ethers.utils.parseEther("1000"));
        });
    });

    describe("转账功能", function () {
        it("应该成功转账", async function () {
            const amount = ethers.utils.parseEther("100");
            await token.connect(owner).transfer(user1.address, amount);
            expect(await token.balanceOf(user1.address)).to.equal(amount);
        });

        it("余额不足时应拒绝转账", async function () {
            const amount = ethers.utils.parseEther("2000");
            await expect(
                token.connect(owner).transfer(user1.address, amount)
            ).to.be.revertedWith("Insufficient balance");
        });

        it("转账应该触发事件", async function () {
            const amount = ethers.utils.parseEther("50");
            await expect(token.connect(owner).transfer(user1.address, amount))
                .to.emit(token, "Transfer")
                .withArgs(owner.address, user1.address, amount);
        });
    });

    describe("授权与代理转账", function () {
        it("应该设置授权额度", async function () {
            const amount = ethers.utils.parseEther("100");
            await token.connect(owner).approve(user1.address, amount);
            expect(await token.allowance(owner.address, user1.address)).to.equal(amount);
        });

        it("应该使用授权额度完成代理转账", async function () {
            const approveAmount = ethers.utils.parseEther("100");
            const transferAmount = ethers.utils.parseEther("50");
            
            await token.connect(owner).approve(user1.address, approveAmount);
            await token.connect(user1).transferFrom(owner.address, user2.address, transferAmount);
            
            expect(await token.balanceOf(user2.address)).to.equal(transferAmount);
        });

        it("授权额度不足时应拒绝代理转账", async function () {
            const approveAmount = ethers.utils.parseEther("10");
            const transferAmount = ethers.utils.parseEther("50");
            
            await token.connect(owner).approve(user1.address, approveAmount);
            await expect(
                token.connect(user1).transferFrom(owner.address, user2.address, transferAmount)
            ).to.be.revertedWith("Allowance exceeded");
        });
    });
});

运行测试

bash
npx hardhat test

输出示例:

text
  MyToken
    代币基本信息
      ✓ 应该正确设置名称和符号
      ✓ 应该正确分配初始供应量
    转账功能
      ✓ 应该成功转账
      ✓ 余额不足时应拒绝转账
      ✓ 转账应该触发事件
    授权与代理转账
      ✓ 应该设置授权额度
      ✓ 应该使用授权额度完成代理转账
      ✓ 授权额度不足时应拒绝代理转账

  8 passing (1s)

·-----------------|----------------------------·-----------------------------|
|  Solc version: 0.8.19 ·  Optimizer enabled: false  ·  Runs: 200  ·  Block limit: 30000000 gas  |
·················|·············································|··············
|  Methods                                                                     |
·················|··············|······························|··············
|  Contract      ·  Method      ·  Min            ·  Max       ·  Avg         |
·················|··············|······························|··············
|  MyToken       ·  approve     ·  46284          ·  46284     ·  46284       |
·················|··············|······························|··············
|  MyToken       ·  transfer    ·  51790          ·  51790     ·  51790       |
·················|··············|······························|··············
|  MyToken       ·  transferFrom·  60426          ·  60426     ·  60426       |
·················|··············|······························|··············
|  Deployments                   ·                                          ·  |
·································|··············································
|  MyToken                       ·  —                                          |
·································|··············································

Hardhat的核心优势:

  • ►速度:测试执行时间缩短70%
  • ►调试:console.log()直接输出到终端
  • ►Gas报告:自动生成Gas消耗分析
  • ►分叉功能:可fork主网状态进行测试

速度是测试的生命线。慢一秒,漏洞就多一秒存活机会。

04 进阶测试策略:从单元测试到安全审计

基础测试只是起点。真正专业的测试工程师会构建多层防御体系。

第一层:单元测试(覆盖率>90%)

  • ►每个函数至少3个测试用例:正常路径、边界条件、异常路径
  • ►使用覆盖率工具检查遗漏
bash
# Hardhat覆盖率
npx hardhat coverage

第二层:集成测试(模拟真实场景)

  • ►多合约交互
  • ►时间依赖(如锁仓、时间锁)
  • ►外部预言机数据
javascript
// 时间相关测试示例
it("时间锁应阻止提前提取", async function () {
    const TimeLock = await ethers.getContractFactory("TimeLock");
    const timeLock = await TimeLock.deploy(7 * 24 * 60 * 60); // 7天锁仓
    
    await timeLock.deposit({ value: ethers.utils.parseEther("1") });
    
    await expect(
        timeLock.withdraw()
    ).to.be.revertedWith("Lock period not ended");
    
    // 模拟时间流逝
    await ethers.provider.send("evm_increaseTime", [7 * 24 * 60 * 60]);
    await ethers.provider.send("evm_mine");
    
    await expect(timeLock.withdraw()).to.not.be.reverted;
});

第三层:安全测试(模拟攻击)

  • ►重入攻击测试
  • ►闪电贷攻击测试
  • ►权限提升测试
javascript
// 重入攻击测试
describe("重入攻击防御", function () {
    it("应阻止重入攻击", async function () {
        const VulnerableContract = await ethers.getContractFactory("VulnerableBank");
        const vulnerable = await VulnerableContract.deploy();
        
        const Attacker = await ethers.getContractFactory("ReentrancyAttacker");
        const attacker = await Attacker.deploy(vulnerable.address);
        
        // 存入资金
        await vulnerable.deposit({ value: ethers.utils.parseEther("10") });
        await attacker.deposit({ value: ethers.utils.parseEther("1") });
        
        // 发起攻击
        await expect(
            attacker.attack({ value: ethers.utils.parseEther("1") })
        ).to.be.revertedWith("ReentrancyGuard: reentrant call");
    });
});

第四层:形式化验证(数学级保障)

  • ►使用Slither静态分析
  • ►使用MythX进行符号执行
  • ►使用Certora进行形式化验证
bash
# Slither静态分析
slither contracts/MyToken.sol

# MythX安全分析
npm install -g mythx-cli
mythx analyze contracts/MyToken.sol

第五层:Fuzz测试(随机输入轰炸)

  • ►使用Echidna进行属性测试
  • ►使用Foundry进行差异化测试
声明:本站部分稿件版权来源于网络,如有侵犯版权,请及时联系我们。

相关推荐

  • 软件测试学习评估标准解析:90%的学员都掉进了这3个坑_千锋教育 01误区:用“学会”欺骗自己90%的零基础学员,都会掉入同一个陷阱:把“看过”当成“掌握”。一份统计数据显示,某在线平台测试课程完课率高达78%,但同期学员就业率仅有41%。差距从哪来?评估标准错了。传统学习评估看什么?课时完成率、作业提交
  • 你囤了500G资料还是不会软件测试?真正的高手只做这一件事_千锋教育 01为什么资源越多,你越学不会?打开你的学习文件夹:30个“必读PDF”、15套“零基础到精通”视频、200G的测试用例模板。你每天花2小时刷网课,3小时看文档,一个月后连测试用例都没写过一个完整的。这不是你笨,这是典型的“资源溺水”症状。
  • 3步搭建Grafana测试监控面板,Bug发现率提升200%_千锋教育 01为什么测试报告需要可视化?数据不会说谎你见过凌晨3点的测试报告吗?1000条用例跑完,失败20条。你盯着Excel表格,一行行找原因。2小时后,你发现是某个接口超时导致的连锁失败。这种场景,每个测试工程师都经历过。传统测试报告有3个致命
  • 一个分支打天下:Trunk Based Development让测试效率提升300%_千锋教育 01为什么你的测试代码比生产代码更脆弱?测试代码的版本管理,是软件工程中被严重低估的痛点。传统GitFlow模式下,功能分支动辄存活数周甚至数月,测试代码跟随分支四处飘散。当分支合并时,测试代码的冲突率比生产代码高出47%。核心矛盾:测试代
  • 从手动测试到自动验证:Truffle与Hardhat智能合约测试实战_千锋教育 01为什么智能合约测试不是“锦上添花”,而是“生死存亡”2022年,跨链桥Ronin被黑,损失6.2亿美元。2023年,CurveFinance重入攻击,2.5亿美元蒸发。这些事故的共性?智能合约代码中的逻辑漏洞,在部署前未被充分测试。区块
  • 一个测试写错,项目延期两周:Mock和Stub到底怎么用?_千锋教育 01测试替身:为什么你的单元测试总是不“单元”?单元测试的核心是“隔离”。但现实中的代码,没有哪个方法能独自存活。一个订单服务要调用支付网关,一个用户模块要查询数据库,一个通知系统要发送邮件。这些外部依赖,就是单元测试的“污染源”。真正的单