Solidity入门(14)-Hardhat 3 单元测试基础与技巧

文章目录

  • [1. 为什么需要单元测试](#1. 为什么需要单元测试)
    • [1.1 没有测试的问题](#1.1 没有测试的问题)
    • [1.2 单元测试的优势](#1.2 单元测试的优势)
    • [1.3 测试驱动开发(TDD)](#1.3 测试驱动开发(TDD))
  • [2. Hardhat 3测试框架概览](#2. Hardhat 3测试框架概览)
    • [2.1 核心组件](#2.1 核心组件)
    • [2.2 Mocha测试运行器](#2.2 Mocha测试运行器)
    • [2.3 Chai断言库](#2.3 Chai断言库)
    • [2.4 Hardhat插件](#2.4 Hardhat插件)
  • [3. 测试文件结构](#3. 测试文件结构)
    • [3.1 文件组织](#3.1 文件组织)
    • [3.2 测试文件基本结构](#3.2 测试文件基本结构)
    • [3.3 测试组织结构](#3.3 测试组织结构)
    • [3.4 Hardhat 3的特殊之处](#3.4 Hardhat 3的特殊之处)
  • [4. Mocha测试结构详解](#4. Mocha测试结构详解)
    • [4.1 钩子函数](#4.1 钩子函数)
    • [4.2 钩子函数示例](#4.2 钩子函数示例)
    • [4.3 describe和it](#4.3 describe和it)
    • [4.4 异步测试](#4.4 异步测试)
  • [5. Chai断言语法基础](#5. Chai断言语法基础)
    • [5.1 基础断言](#5.1 基础断言)
    • [5.2 链式断言](#5.2 链式断言)
    • [5.3 数组断言](#5.3 数组断言)
    • [5.4 对象断言](#5.4 对象断言)
    • [5.5 字符串断言](#5.5 字符串断言)
    • [5.6 BigInt处理](#5.6 BigInt处理)
  • [6. Hardhat 3特定断言](#6. Hardhat 3特定断言)
    • [6.1 事件断言](#6.1 事件断言)
    • [6.2 余额变化断言](#6.2 余额变化断言)
    • [6.3 回退断言](#6.3 回退断言)
  • [7. 合约部署和函数测试](#7. 合约部署和函数测试)
    • [7.1 部署测试](#7.1 部署测试)
    • [7.2 函数测试](#7.2 函数测试)
    • [7.3 边界测试](#7.3 边界测试)
    • [7.4 测试编写原则](#7.4 测试编写原则)
  • [8. 事件触发测试](#8. 事件触发测试)
    • [8.1 基础事件测试](#8.1 基础事件测试)
    • [8.2 多个事件测试](#8.2 多个事件测试)
    • [8.3 查询历史事件](#8.3 查询历史事件)
    • [8.4 事件测试注意事项](#8.4 事件测试注意事项)
  • [9. 错误和回退测试](#9. 错误和回退测试)
    • [9.1 require回退测试](#9.1 require回退测试)
    • [9.2 自定义错误测试](#9.2 自定义错误测试)
    • [9.3 Panic错误测试](#9.3 Panic错误测试)
    • [9.4 错误测试最佳实践](#9.4 错误测试最佳实践)
  • [10. 时间和区块操作](#10. 时间和区块操作)
    • [10.1 时间操作](#10.1 时间操作)
    • [10.2 区块操作](#10.2 区块操作)
    • [10.3 时间锁合约测试示例](#10.3 时间锁合约测试示例)
    • [10.4 注意事项](#10.4 注意事项)
  • [11. 快照和恢复](#11. 快照和恢复)
    • [11.1 loadFixture概述](#11.1 loadFixture概述)
    • [11.2 Fixture函数定义](#11.2 Fixture函数定义)
    • [11.3 使用loadFixture](#11.3 使用loadFixture)
    • [11.4 复杂Fixture示例](#11.4 复杂Fixture示例)
    • [11.5 loadFixture的优势](#11.5 loadFixture的优势)
  • [12. 测试覆盖率](#12. 测试覆盖率)
    • [12.1 覆盖率概述](#12.1 覆盖率概述)
    • [12.2 配置覆盖率](#12.2 配置覆盖率)
    • [12.3 覆盖率报告](#12.3 覆盖率报告)
    • [12.4 覆盖率目标](#12.4 覆盖率目标)
    • [12.5 提高覆盖率](#12.5 提高覆盖率)
  • [13. Gas报告](#13. Gas报告)
    • [13.1 Gas报告概述](#13.1 Gas报告概述)
    • [13.2 生成Gas报告](#13.2 生成Gas报告)
    • [13.3 Gas报告示例](#13.3 Gas报告示例)
    • [13.4 Gas优化建议](#13.4 Gas优化建议)
  • [14. 测试最佳实践](#14. 测试最佳实践)
    • [14.1 命名和组织](#14.1 命名和组织)
    • [14.2 测试独立性](#14.2 测试独立性)
    • [14.3 全面覆盖](#14.3 全面覆盖)
    • [14.4 错误处理](#14.4 错误处理)
    • [14.5 性能优化](#14.5 性能优化)
    • [14.6 代码复用](#14.6 代码复用)
    • [14.7 文档化](#14.7 文档化)
  • [15. 常见错误和解决方案](#15. 常见错误和解决方案)
    • [15.1 忘记使用await](#15.1 忘记使用await)
    • [15.2 事件名称错误](#15.2 事件名称错误)
    • [15.3 参数类型不匹配](#15.3 参数类型不匹配)
    • [15.4 错误消息不匹配](#15.4 错误消息不匹配)
    • [15.5 网络连接问题](#15.5 网络连接问题)
    • [15.6 合约未部署](#15.6 合约未部署)
  • [16. 实战演示](#16. 实战演示)
    • [16.1 完整测试示例](#16.1 完整测试示例)
    • [16.2 运行测试](#16.2 运行测试)
  • 17.官方参考文档

1. 为什么需要单元测试

1.1 没有测试的问题

在开始学习具体的测试技术之前,我们先来思考一个问题:为什么需要单元测试?

从没有测试的角度来看,我们会遇到很多问题:

  1. 手动测试耗时且容易遗漏:

    • 每次修改代码后,都需要手动测试每个功能
    • 这个过程非常耗时,而且容易遗漏边界情况
    • 随着功能增加,手动测试的工作量呈指数级增长
    • 在复杂的DeFi协议中,手动测试几乎不可能覆盖所有场景
  2. 重构缺乏信心:

    • 当我们需要重构代码时,由于缺乏测试的保护,往往缺乏信心
    • 担心会引入新的问题,导致不敢进行必要的代码优化
    • 代码质量逐渐下降,技术债务不断积累
  3. 生产环境风险:

    • 没有经过充分测试的代码,问题可能会在生产环境中暴露
    • 智能合约一旦部署就无法修改,问题可能导致实际损失
    • 历史上很多安全事件都是因为测试不充分导致的
  4. 团队协作困难:

    • 没有测试的话,团队成员无法保证代码的质量
    • 代码审查时难以验证功能是否正确
    • 新成员接手项目时,缺乏理解代码行为的文档

1.2 单元测试的优势

相比之下,单元测试能够带来很多优势:

  1. 自动化验证功能:

    • 测试可以自动化运行,大大节省时间
    • 一次编写,多次运行
    • 可以在每次代码变更后自动验证功能
  2. 快速反馈问题:

    • 当代码出现问题时,测试能够快速反馈
    • 帮助我们及时发现问题,而不是等到生产环境
    • 减少调试时间,提高开发效率
  3. 测试即文档:

    • 测试本身也是一种文档,能够清晰地描述合约的行为
    • 通过阅读测试用例,可以快速理解合约的功能
    • 比传统的文档更容易维护和更新
  4. 提升代码质量:

    • 编写测试的过程,会促使我们思考各种边界情况
    • 帮助我们发现设计上的问题
    • 提高代码的健壮性和可靠性
  5. 支持持续集成:

    • 测试可以集成到CI/CD流程中
    • 自动化的质量检查
    • 确保每次提交的代码都经过验证
  6. 安全重构:

    • 有了测试的保护,我们可以放心地进行重构
    • 测试会告诉我们重构是否破坏了现有功能
    • 支持持续改进代码质量
  7. 降低维护成本:

    • 虽然编写测试需要时间,但长期来看降低了维护成本
    • 减少了生产环境的问题
    • 提高了开发效率

1.3 测试驱动开发(TDD)

测试驱动开发(Test-Driven Development)是一种开发方法论,其核心思想是:

  1. 先写测试:在实现功能之前,先编写测试用例
  2. 运行测试:运行测试,确认测试失败(因为功能还没实现)
  3. 实现功能:编写最少的代码使测试通过
  4. 重构:在测试通过的基础上进行重构优化

TDD的优势:

  • 确保代码满足需求
  • 提高测试覆盖率
  • 促进更好的设计
  • 增强开发信心

虽然TDD不是必须的,但理解其思想有助于编写更好的测试。

2. Hardhat 3测试框架概览

2.1 核心组件

Hardhat 3的测试框架主要由三个核心组件组成:Mocha、Chai和Hardhat插件。这三个组件配合使用,构成了一个功能完善的测试框架。

组件架构:

bash 复制代码
Hardhat 3 测试框架
├── Mocha(测试运行器)
│   ├── 测试结构组织
│   ├── 钩子函数
│   └── 异步支持
├── Chai(断言库)
│   ├── 基础断言
│   ├── 链式语法
│   └── BDD/TDD风格
└── Hardhat插件
    ├── hardhat-chai-matchers(区块链断言)
    └── hardhat-network-helpers(网络工具)

2.2 Mocha测试运行器

Mocha是一个成熟的测试运行器,它是JavaScript生态系统中最流行的测试框架之一。

Mocha的核心特性:

  1. 测试结构组织:

    • 提供describe和it这样的结构来组织测试
    • 支持嵌套的describe创建子套件
    • 清晰的测试层次结构
  2. 钩子函数:

    • before:所有测试前执行一次
    • beforeEach:每个测试前执行
    • afterEach:每个测试后执行
    • after:所有测试后执行一次
  3. 异步支持:

    • 原生支持Promise
    • 支持async/await语法
    • 可以测试异步操作
  4. 丰富的报告:

    • 多种报告格式
    • 详细的错误信息
    • 测试执行时间统计

Mocha的优势:

  • 配置灵活,可以适应各种项目需求
  • 报告丰富,提供详细的测试结果
  • 社区非常成熟,有大量的文档和示例
  • 与各种工具集成良好

2.3 Chai断言库

Chai是一个强大的断言库,它提供了多种风格的断言方式。

Chai的三种风格:

  1. expect风格(推荐):
bash 复制代码
expect(value).to.equal(42);
expect(value).to.be.true;
  • 可读性强
  • 链式语法
  • 适合BDD风格
  1. should风格:
bash 复制代码
value.should.equal(42);
value.should.be.true;
  • 更接近自然语言
  • 需要扩展Object.prototype
  1. assert风格:
bash 复制代码
assert.equal(value, 42);
assert.isTrue(value);
  • 类似Node.js的assert模块
  • 适合TDD风格

Chai的核心特性:

  • 链式语法:让代码可读性非常强
  • BDD和TDD风格:支持两种开发风格
  • 插件丰富:有大量的插件扩展功能
  • 文档完善:有详细的API文档

在Hardhat 3中,我们主要使用expect风格,因为它与Hardhat的区块链特定断言配合最好。

2.4 Hardhat插件

Hardhat插件为测试提供了区块链特定的功能,这些功能是普通测试框架无法提供的。

hardhat-chai-matchers:

这个插件扩展了Chai的断言能力,提供了专门用于区块链测试的断言方法:

  1. 事件断言:

    • .to.emit():验证事件是否被触发
    • .withArgs():验证事件参数
  2. 余额变化断言:

    • .to.changeEtherBalance():验证ETH余额变化
    • .to.changeEtherBalances():验证多个账户的余额变化
  3. 回退断言:

    • .to.be.revertedWith():验证带有错误消息的回退
    • .to.be.reverted:验证无原因的回退
    • .to.be.revertedWithCustomError():验证自定义错误
    • .to.be.revertedWithPanic():验证panic错误

hardhat-network-helpers:

这个插件提供了网络操作和快照恢复功能:

  1. 时间操作:

    • time.increase():增加时间
    • time.increaseTo():跳转到特定时间
    • time.setNextBlockTimestamp():设置下一个区块的时间戳
  2. 区块操作:

    • mine():挖掘指定数量的区块
    • mineUpTo():挖掘到指定区块号
  3. 快照恢复:

    • loadFixture():加载Fixture并创建快照
    • 自动恢复状态,提升测试速度

插件安装:

在Hardhat 3中,这些插件已经包含在@nomicfoundation/hardhat-toolbox-mocha-ethers中,不需要单独安装。

3. 测试文件结构

3.1 文件组织

在Hardhat 3项目中,测试文件通常放在test目录下,命名规范是ContractName.test.ts,一个合约对应一个测试文件。

目录结构:

bash 复制代码
project/
├── contracts/
│   └── Counter.sol
├── test/
│   ├── Counter.test.ts
│   └── Token.test.ts
└── hardhat.config.ts

命名规范:

  • 测试文件以.test.ts结尾
  • 文件名与合约名对应
  • 使用PascalCase命名

3.2 测试文件基本结构

测试文件的基本结构包括导入语句、网络连接、以及测试套件的组织。

基本结构示例:

bash 复制代码
// 导入语句
import { expect } from "chai";
import { network } from "hardhat";

// 连接网络(Hardhat 3新方式)
const { ethers, networkHelpers } = await network.connect();

// 定义Fixture函数
async function deployCounterFixture() {
  const [owner, addr1, addr2] = await ethers.getSigners();
  const counter = await ethers.deployContract("Counter");
  
  return { counter, owner, addr1, addr2 };
}

// 测试套件
describe("Counter", function () {
  // 测试用例
  it("Should deploy with initial value 0", async function () {
    const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
    
    expect(await counter.x()).to.equal(0);
  });
});

关键点说明:

  1. 导入语句:

    • expect:从chai导入,用于断言
    • network:从hardhat导入,用于网络连接
  2. 网络连接(Hardhat 3新方式):

    • 使用await network.connect()获取ethers对象
    • 这是与Hardhat 2的重要区别
    • 同时获取networkHelpers用于快照恢复等功能
  3. Fixture函数:

    • 定义部署和初始化逻辑
    • 返回测试需要的所有对象
    • 使用networkHelpers.loadFixture()加载

3.3 测试组织结构

测试的组织结构是层次化的,使用describe和it来组织测试。

层次结构:

bash 复制代码
describe("Counter", function () {
  // 第一层:测试套件
  
  describe("Deployment", function () {
    // 第二层:子套件(按功能分组)
    
    it("Should deploy with initial value 0", async function () {
      // 第三层:具体测试用例
    });
    
    it("Should have valid address", async function () {
      // 另一个测试用例
    });
  });
  
  describe("Increment", function () {
    // 另一个子套件
    
    it("Should increment counter", async function () {
      // 测试用例
    });
  });
});

组织原则:

  • 按功能分组:使用describe按功能组织测试
  • 描述性名称:使用清晰的描述性名称
  • 保持独立:每个测试应该能够独立运行
  • 避免依赖:测试之间不应该有依赖关系

3.4 Hardhat 3的特殊之处

在Hardhat 3中,有几个重要的变化:

  1. 网络连接方式:
bash 复制代码
// Hardhat 2的方式(已废弃)
import { ethers } from "hardhat";

// Hardhat 3的方式(新)
const { ethers, networkHelpers } = await network.connect();
  1. 顶级await支持:

由于使用了ESM模块系统,可以在文件顶层使用await:

bash 复制代码
// 可以在顶层使用await
const { ethers } = await network.connect();
  1. Fixture加载方式:
bash 复制代码
// 使用networkHelpers.loadFixture
const { counter } = await networkHelpers.loadFixture(deployCounterFixture);

这些变化使得Hardhat 3的测试代码更加现代化和易用。

4. Mocha测试结构详解

4.1 钩子函数

Mocha提供了几个重要的钩子函数,用于在测试的不同阶段执行代码。

钩子函数类型:

  1. before:

    • 在所有测试前执行一次
    • 适合初始化全局资源
    • 例如:设置测试环境、初始化数据库连接
  2. beforeEach:

    • 在每个测试前执行
    • 适合准备测试数据
    • 例如:部署合约、获取签名者账户
  3. afterEach:

    • 在每个测试后执行
    • 用于清理测试状态
    • 例如:重置状态、清理临时数据
  4. after:

    • 在所有测试后执行一次
    • 用于释放全局资源
    • 例如:关闭数据库连接、清理临时文件

执行顺序:

bash 复制代码
before (执行一次)
  ├── beforeEach
  ├── 测试用例1
  ├── afterEach
  ├── beforeEach
  ├── 测试用例2
  ├── afterEach
  └── ...
after (执行一次)

4.2 钩子函数示例

beforeEach示例:

bash 复制代码
describe("Counter", function () {
  let counter: any;
  let owner: any;
  
  beforeEach(async function () {
    // 每个测试前都会执行
    [owner] = await ethers.getSigners();
    counter = await ethers.deployContract("Counter");
  });
  
  it("Should increment", async function () {
    await counter.inc();
    expect(await counter.x()).to.equal(1);
  });
  
  it("Should set number", async function () {
    await counter.setNumber(42);
    expect(await counter.x()).to.equal(42);
  });
});

注意:虽然可以使用beforeEach,但在Hardhat 3中,更推荐使用loadFixture,因为它提供了快照恢复功能,性能更好。

4.3 describe和it

describe:

describe用于定义测试套件,可以嵌套使用:

bash 复制代码
describe("Counter", function () {
  describe("Deployment", function () {
    // 部署相关测试
  });
  
  describe("Increment", function () {
    // 增量相关测试
  });
});

it:

it用于定义具体的测试用例:

bash 复制代码
it("Should increment counter", async function () {
  // 测试逻辑
});

测试用例命名:

  • 使用描述性的名称
  • 描述测试场景和预期结果
  • 例如:"Should revert when amount is zero"

4.4 异步测试

Mocha原生支持异步测试,可以使用Promise或async/await:

Promise方式:

bash 复制代码
it("Should return a value", function () {
  return someAsyncFunction().then(result => {
    expect(result).to.equal(42);
  });
});

async/await方式(推荐):

bash 复制代码
it("Should return a value", async function () {
  const result = await someAsyncFunction();
  expect(result).to.equal(42);
});

在Hardhat 3中,我们主要使用async/await方式,因为它更清晰易读。

5. Chai断言语法基础

5.1 基础断言

Chai提供了丰富的断言方法,我们先来看看基础的断言。

相等性断言:

bash 复制代码
expect(value).to.equal(42);        // 等于
expect(value).to.not.equal(42);    // 不等于
expect(value).to.be.deep.equal({a: 1}); // 深度相等(对象比较)

数值比较:

bash 复制代码
expect(value).to.be.above(10);      // 大于
expect(value).to.be.below(100);     // 小于
expect(value).to.be.at.least(10);   // 大于等于
expect(value).to.be.at.most(100);   // 小于等于

布尔值断言:

bash 复制代码
expect(value).to.be.true;
expect(value).to.be.false;

空值断言:

bash 复制代码
expect(value).to.be.null;
expect(value).to.be.undefined;
expect(value).to.exist;  // 不为null或undefined

类型检查:

bash 复制代码
expect(value).to.be.a("string");
expect(value).to.be.an("array");
expect(value).to.be.an.instanceof(Contract);

5.2 链式断言

Chai的强大之处在于支持链式断言,我们可以将多个断言连接在一起:

bash 复制代码
expect(value)
  .to.be.a("bigint")
  .and.to.be.above(0)
  .and.to.be.below(1000000);

这种链式语法让代码更加清晰易读。

5.3 数组断言

数组类型和长度:

bash 复制代码
expect(array).to.be.an("array");
expect(array).to.have.length(3);

包含元素:

bash 复制代码
expect(array).to.include(2);
expect(array).to.contain(2);

5.4 对象断言

属性检查:

bash 复制代码
expect(obj).to.have.property("name");
expect(obj).to.have.property("name", "value");

属性类型和值:

bash 复制代码
expect(obj.name).to.be.a("string"); expect(obj.value).to.equal(42);

5.5 字符串断言

类型和内容:

bash 复制代码
expect(str).to.be.a("string");
expect(str).to.include("substring");
expect(str).to.have.length(10);
expect(str).to.match(/^0x[a-fA-F0-9]{40}$/); // 正则匹配

地址验证示例:

bash 复制代码
const address = await counter.getAddress();
expect(address).to.be.a("string");
expect(address).to.have.length(42);
expect(address).to.match(/^0x[a-fA-F0-9]{40}$/);

5.6 BigInt处理

在Solidity中,很多值是uint256类型,在JavaScript中对应bigint类型。需要注意类型匹配:

// 使用n后缀创建bigint

bash 复制代码
expect(await counter.x()).to.equal(0n);
expect(await counter.x()).to.equal(42n);

// 或者使用BigInt构造函数

bash 复制代码
expect(await counter.x()).to.equal(BigInt(0));

常见错误:

bash 复制代码
// 错误:类型不匹配
expect(await counter.x()).to.equal(0);  // 0是number类型

// 正确:使用bigint
expect(await counter.x()).to.equal(0n); // 0n是bigint类型

6. Hardhat 3特定断言

6.1 事件断言

事件是智能合约与外部世界通信的重要机制,因此测试事件触发也是非常重要的。

基础事件断言:

bash 复制代码
await expect(counter.inc())
  .to.emit(counter, "Increment");

验证事件参数:

bash 复制代码
await expect(counter.inc())
  .to.emit(counter, "Increment")
  .withArgs(1n);

多个参数:

bash 复制代码
await expect(counter.transfer(to, amount))
  .to.emit(token, "Transfer")
  .withArgs(from, to, amount);

注意事项:

  • 必须使用await:事件断言是异步的,必须使用await
  • 事件名称区分大小写:必须与合约中定义的一致
  • 参数类型要匹配:特别是注意bigint类型
  • 参数顺序要匹配:参数的顺序必须与事件定义一致

获取事件过滤器(推荐):

bash 复制代码
// 使用合约实例的filters方法
const incrementFilter = counter.filters.Increment();
await expect(counter.inc())
  .to.emit(counter, incrementFilter)
  .withArgs(1n);

这种方式可以在编译时检查事件是否存在,更加安全。

6.2 余额变化断言

余额变化断言用于验证转账操作后,账户余额的变化是否符合预期。

单个账户余额变化:

bash 复制代码
await expect(
  owner.sendTransaction({ to: addr1.address, value: ethers.parseEther("1") })
).to.changeEtherBalance(addr1, ethers.parseEther("1"));

多个账户余额变化:

bash 复制代码
await expect(
  owner.sendTransaction({ to: addr1.address, value: ethers.parseEther("1") })
).to.changeEtherBalances(
  [owner, addr1],
  [ethers.parseEther("-1"), ethers.parseEther("1")]
);

考虑Gas费用:

// 自动考虑Gas费用

bash 复制代码
await expect(
  owner.sendTransaction({ to: addr1.address, value: ethers.parseEther("1") })
).to.changeEtherBalance(owner, ethers.parseEther("-1"), { includeFee: true });

注意事项:

  • 余额变化包括Gas费用
  • 使用includeFee选项可以自动考虑Gas费用
  • 对于多个账户,数组长度要匹配

6.3 回退断言

在智能合约开发中,错误处理是非常重要的一部分。我们需要测试各种回退情况。

require回退(带错误消息):

bash 复制代码
await expect(counter.incBy(0))
  .to.be.revertedWith("incBy: increment should be positive");

无原因回退:

bash 复制代码
await expect(contract.fail())
  .to.be.reverted;

自定义错误:

bash 复制代码
// 合约中定义自定义错误
// error InsufficientBalance(uint256 required, uint256 available);

await expect(contract.withdraw(amount))
  .to.be.revertedWithCustomError(contract, "InsufficientBalance")
  .withArgs(required, available);

Panic错误:

bash 复制代码
// Panic代码:0x11 = 算术溢出
await expect(contract.overflow())
  .to.be.revertedWithPanic(0x11);

常见Panic代码:

  • 0x00:通用编译器插入的panic
  • 0x01:断言失败
  • 0x11:算术溢出
  • 0x12:除零或模零
  • 0x21:转换为不存在的枚举值
  • 0x22:存储字节数组编码错误
  • 0x31:在空数组上调用pop()
  • 0x32:数组访问越界
  • 0x41:分配太多内存
  • 0x51:调用零初始化的变量

注意事项:

  • 错误消息要精确匹配:revertedWith要求消息完全匹配,包括空格和大小写
  • 测试所有回退路径:确保所有错误情况都被测试
  • 验证安全机制:确保安全机制生效
  • 使用描述性测试名称:让测试意图清晰

7. 合约部署和函数测试

7.1 部署测试

对于部署测试,我们需要验证合约是否正确部署,以及初始状态是否正确。

验证合约地址:

bash 复制代码
it("Should have valid address", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  const address = await counter.getAddress();
  
  expect(address).to.be.a("string");
  expect(address).to.have.length(42);
  expect(address).to.match(/^0x[a-fA-F0-9]{40}$/);
});

验证初始状态:

bash 复制代码
it("Should deploy with initial value 0", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  expect(await counter.x()).to.equal(0n);
});

验证构造函数参数:

bash 复制代码
it("Should deploy with correct constructor parameters", async function () {
  const initialValue = 100n;
  const counter = await ethers.deployContract("Counter", [initialValue]);
  
  expect(await counter.x()).to.equal(initialValue);
});

7.2 函数测试

对于函数测试,我们应该测试正常流程、边界情况和异常情况。

正常流程测试:

bash 复制代码
it("Should increment counter", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  await counter.inc();
  expect(await counter.x()).to.equal(1n);
  
  await counter.inc();
  expect(await counter.x()).to.equal(2n);
});

多次调用测试:

bash 复制代码
it("Should increment by specific amount", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  await counter.incBy(5);
  expect(await counter.x()).to.equal(5n);
  
  await counter.incBy(10);
  expect(await counter.x()).to.equal(15n);
});

状态变化验证:

bash 复制代码
it("Should update state correctly", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  const initialValue = await counter.x();
  await counter.inc();
  const newValue = await counter.x();
  
  expect(newValue).to.equal(initialValue + 1n);
});

7.3 边界测试

边界情况往往是最容易出问题的地方,需要特别关注。

零值测试:

bash 复制代码
it("Should handle zero value", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  await counter.setNumber(0);
  expect(await counter.x()).to.equal(0n);
});

最大值测试:

bash 复制代码
it("Should handle maximum value", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  const maxValue = 2n ** 256n - 1n;
  await counter.setNumber(maxValue);
  expect(await counter.x()).to.equal(maxValue);
});

溢出测试:

bash 复制代码
it("Should revert on overflow", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  const maxValue = 2n ** 256n - 1n;
  await counter.setNumber(maxValue);
  
  // 在Solidity 0.8+中,溢出会自动revert
  await expect(counter.inc())
    .to.be.revertedWithPanic(0x11); // 算术溢出
});

7.4 测试编写原则

在编写测试时,应该遵循以下原则:

  1. 每个函数至少一个测试:确保所有函数都被测试
  2. 测试正常和异常情况:覆盖所有代码路径
  3. 验证状态变化:确保状态正确更新
  4. 使用描述性名称:让测试意图清晰
  5. 保持测试独立:每个测试应该能够独立运行

8. 事件触发测试

8.1 基础事件测试

事件是智能合约与外部世界通信的重要机制,测试事件触发是验证合约行为的重要方式。

单个事件测试:

bash 复制代码
it("Should emit Increment event", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  await expect(counter.inc())
    .to.emit(counter, "Increment")
    .withArgs(1n);
});

事件参数验证:

bash 复制代码
it("Should emit Increment event with correct parameters", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  const amount = 5n;
  await expect(counter.incBy(amount))
    .to.emit(counter, "Increment")
    .withArgs(amount);
});

8.2 多个事件测试

如果一个交易触发了多个事件,我们需要分别验证:

bash 复制代码
it("Should emit multiple events", async function () {
  const { token, owner, addr1 } = await networkHelpers.loadFixture(deployTokenFixture);
  
  const amount = ethers.parseEther("100");
  
  const tx = await token.transfer(addr1.address, amount);
  
  await expect(tx)
    .to.emit(token, "Transfer")
    .withArgs(owner.address, addr1.address, amount);
  
  // 如果还有其他事件,继续验证
});

8.3 查询历史事件

我们还可以查询历史事件,这对于测试复杂的事件日志场景很有用:

bash 复制代码
it("Should query historical events", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  // 记录部署时的区块号
  const deployBlock = await ethers.provider.getBlockNumber();
  
  // 执行一些操作
  await counter.inc();
  await counter.incBy(5);
  
  // 查询事件
  const filter = counter.filters.Increment();
  const events = await counter.queryFilter(filter, deployBlock);
  
  expect(events).to.have.length(2);
  expect(events[0].args[0]).to.equal(1n);
  expect(events[1].args[0]).to.equal(5n);
});

8.4 事件测试注意事项

注意事项:

  1. 事件必须在交易中触发:不能是纯view函数
  2. 使用await等待断言:事件断言是异步的
  3. 事件名称区分大小写:必须与合约中定义的一致
  4. 参数类型要匹配:特别是注意bigint类型
  5. 查询历史事件时需要记录区块号:用于指定查询范围

9. 错误和回退测试

9.1 require回退测试

require回退是最常见的错误处理方式,我们需要测试各种require条件。

带错误消息的require:

bash 复制代码
it("Should revert when increment is zero", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  await expect(counter.incBy(0))
    .to.be.revertedWith("incBy: increment should be positive");
});

无参数require:

bash 复制代码
it("Should revert without reason", async function () {
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  await expect(contract.fail())
    .to.be.reverted;
});

9.2 自定义错误测试

自定义错误是Solidity 0.8.4引入的特性,比字符串错误消息更省Gas。

合约中的自定义错误:

bash 复制代码
error InsufficientBalance(uint256 required, uint256 available);

function withdraw(uint256 amount) public {
    if (balance < amount) {
        revert InsufficientBalance(amount, balance);
    }
    // ...
}

测试自定义错误:

bash 复制代码
it("Should revert with custom error", async function () {
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  const amount = ethers.parseEther("1000");
  
  await expect(contract.withdraw(amount))
    .to.be.revertedWithCustomError(contract, "InsufficientBalance")
    .withArgs(amount, balance);
});

9.3 Panic错误测试

Panic错误是Solidity在某些严重错误时触发的,比如算术溢出、数组越界等。

算术溢出测试:

bash 复制代码
it("Should revert with panic on overflow", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  const maxValue = 2n ** 256n - 1n;
  await counter.setNumber(maxValue);
  
  // 在Solidity 0.8+中,溢出会触发panic
  await expect(counter.inc())
    .to.be.revertedWithPanic(0x11); // 算术溢出
});

数组越界测试:

bash 复制代码
it("Should revert with panic on array out of bounds", async function () {
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  await expect(contract.getArrayElement(100))
    .to.be.revertedWithPanic(0x32); // 数组访问越界
});

9.4 错误测试最佳实践

测试原则:

  1. 错误消息要精确匹配:revertedWith要求消息完全匹配
  2. 测试所有回退路径:确保所有错误情况都被测试
  3. 验证安全机制:确保安全机制生效
  4. 使用描述性测试名称:让测试意图清晰

测试覆盖:

  • 正常流程
  • 边界情况
  • 异常情况
  • 所有require条件
  • 所有自定义错误
  • 所有可能的panic情况

10. 时间和区块操作

10.1 时间操作

有些智能合约依赖于时间或区块号,比如时间锁合约、定时任务等。Hardhat提供了强大的时间旅行和区块操作功能。

增加时间:

bash 复制代码
import { time } from "@nomicfoundation/hardhat-network-helpers";

it("Should allow withdrawal after lock period", async function () {
  const { vault } = await networkHelpers.loadFixture(deployVaultFixture);
  
  const lockPeriod = 7 * 24 * 60 * 60; // 7天(秒)
  
  // 增加7天
  await time.increase(lockPeriod);
  
  // 现在可以提取了
  await expect(vault.withdraw())
    .to.not.be.reverted;
});

跳转到特定时间:

bash 复制代码
it("Should allow action at specific time", async function () {
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  const targetTime = Math.floor(Date.now() / 1000) + 86400; // 24小时后
  
  await time.increaseTo(targetTime);
  
  await expect(contract.execute())
    .to.not.be.reverted;
});

设置下一个区块的时间戳:

bash 复制代码
it("Should set next block timestamp", async function () {
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  const targetTime = Math.floor(Date.now() / 1000) + 3600;
  
  await time.setNextBlockTimestamp(targetTime);
  await time.advanceBlock();
  
  // 现在区块时间戳是targetTime
});

10.2 区块操作

挖掘区块:

bash 复制代码
import { mine } from "@nomicfoundation/hardhat-network-helpers";

it("Should mine blocks", async function () {
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  const initialBlock = await ethers.provider.getBlockNumber();
  
  // 挖掘5个区块
  await mine(5);
  
  const finalBlock = await ethers.provider.getBlockNumber();
  expect(finalBlock).to.equal(initialBlock + 5);
});

挖掘到特定区块号:

bash 复制代码
import { mineUpTo } from "@nomicfoundation/hardhat-network-helpers";

it("Should mine up to specific block", async function () {
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  const targetBlock = 100;
  
  await mineUpTo(targetBlock);
  
  const currentBlock = await ethers.provider.getBlockNumber();
  expect(currentBlock).to.be.at.least(targetBlock);
});

10.3 时间锁合约测试示例

时间锁合约:

bash 复制代码
contract Timelock {
    uint256 public constant LOCK_PERIOD = 7 days;
    uint256 public lockedUntil;
    mapping(address => uint256) public balances;
    
    function lock() external {
        lockedUntil = block.timestamp + LOCK_PERIOD;
    }
    
    function withdraw() external {
        require(block.timestamp >= lockedUntil, "Still locked");
        // 提取逻辑
    }
}

测试时间锁:

bash 复制代码
it("Should prevent withdrawal before lock period", async function () {
  const { timelock } = await networkHelpers.loadFixture(deployTimelockFixture);
  
  await timelock.lock();
  
  // 尝试立即提取(应该失败)
  await expect(timelock.withdraw())
    .to.be.revertedWith("Still locked");
});

it("Should allow withdrawal after lock period", async function () {
  const { timelock } = await networkHelpers.loadFixture(deployTimelockFixture);
  
  await timelock.lock();
  
  // 增加7天
  await time.increase(7 * 24 * 60 * 60);
  
  // 现在可以提取了
  await expect(timelock.withdraw())
    .to.not.be.reverted;
});

10.4 注意事项

注意事项:

  1. 这些操作只在Hardhat网络有效:不会影响真实的区块链
  2. 时间操作是异步的:需要使用await
  3. 区块操作会影响区块号:可能影响依赖区块号的逻辑
  4. 时间戳必须是递增的:不能设置比当前时间更早的时间戳

11. 快照和恢复

11.1 loadFixture概述

在编写测试时,我们经常需要在每个测试前部署合约。如果使用beforeEach,每次测试都会重新部署,这会消耗大量时间。Hardhat提供了loadFixture功能来解决这个问题。

loadFixture的工作原理:

  • 第一次调用时,执行Fixture函数并创建快照
  • 后续调用时,直接恢复到快照状态,而不是重新执行
  • 这可以将测试速度提升5到10倍

11.2 Fixture函数定义

Fixture函数应该是一个纯函数,返回所有测试需要的对象:

bash 复制代码
async function deployCounterFixture() {
  const [owner, addr1, addr2] = await ethers.getSigners();
  const counter = await ethers.deployContract("Counter");
  
  // 可选:执行一些初始化操作
  await counter.incBy(10);
  
  return { counter, owner, addr1, addr2 };
}

Fixture函数原则:

  • 应该是纯函数:相同的输入应该产生相同的输出
  • 只进行初始化:不应该在Fixture中执行测试逻辑
  • 返回所有需要的对象:包括合约、账户等
  • 可以处理复杂逻辑:包括多合约部署和预设状态

11.3 使用loadFixture

基础使用:

bash 复制代码
it("Should use fixture", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  
  // 使用counter进行测试
  expect(await counter.x()).to.equal(10n);
});

多个测试使用同一个Fixture:

bash 复制代码
describe("Counter", function () {
  it("Test 1", async function () {
    const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
    // 测试1
  });
  
  it("Test 2", async function () {
    const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
    // 测试2 - 状态是干净的,不受测试1影响
  });
});

11.4 复杂Fixture示例

多合约系统Fixture:

bash 复制代码
async function deployVaultSystemFixture() {
  const [owner, user1, user2] = await ethers.getSigners();
  
  // 部署Token合约
  const token = await ethers.deployContract("Token");
  
  // 部署Vault合约,传入Token地址
  const vault = await ethers.deployContract("Vault", [await token.getAddress()]);
  
  // 给用户分配一些Token
  await token.transfer(user1.address, ethers.parseEther("1000"));
  await token.transfer(user2.address, ethers.parseEther("2000"));
  
  return { token, vault, owner, user1, user2 };
}

11.5 loadFixture的优势

优势总结:

  • 每个测试独立运行:不受其他测试影响
  • 保证测试可重复性:每次运行都是相同的初始状态
  • 避免重复部署:大幅提升测试速度
  • 可以处理复杂初始化:包括多合约部署和预设状态

性能对比:

  • 使用beforeEach:每个测试都重新部署,100个测试可能需要几分钟
  • 使用loadFixture:第一次部署,后续恢复快照,100个测试可能只需要几十秒

12. 测试覆盖率

12.1 覆盖率概述

测试覆盖率是衡量测试质量的重要指标。它告诉我们测试覆盖了多少代码,帮助我们识别未测试的代码路径。

覆盖率类型:

  • 语句覆盖率:执行了多少条语句
  • 分支覆盖率:执行了多少个分支
  • 函数覆盖率:调用了多少个函数
  • 行覆盖率:执行了多少行代码

12.2 配置覆盖率

在Hardhat 3中,覆盖率功能已经集成在toolbox中,不需要额外配置。

运行覆盖率测试:

bash 复制代码
# 生成覆盖率报告
npx hardhat test --coverage

# 生成HTML报告
npx hardhat test --coverage --report html

# 只运行Mocha测试并生成覆盖率
npx hardhat test mocha --coverage

# 只运行Solidity测试并生成覆盖率
npx hardhat test solidity --coverage

12.3 覆盖率报告

控制台报告:

运行覆盖率测试后,会在控制台显示覆盖率统计:

bash 复制代码
Coverage Report
===============
Statements   : 95.45% ( 21/22 )
Branches     : 90.00% ( 18/20 )
Functions    : 100.00% ( 10/10 )
Lines        : 95.45% ( 21/22 )

HTML报告:

使用--report html选项可以生成HTML报告:

bash 复制代码
npx hardhat test --coverage --report html

这会在coverage目录下生成HTML报告,可以在浏览器中打开查看详细的覆盖率信息。

12.4 覆盖率目标

推荐覆盖率:

  • 关键合约:应该达到100%的覆盖率
  • 一般合约:应该超过80%
  • 工具合约:可以稍微低一些,但最好也超过70%

覆盖率不是唯一指标:

  • 高覆盖率不代表测试质量高
  • 需要测试有意义的场景
  • 关注边界情况和异常情况

12.5 提高覆盖率

识别未覆盖的代码:

  • 查看覆盖率报告
  • 识别未覆盖的代码路径
  • 编写测试覆盖这些路径

常见未覆盖场景:

  • 错误处理路径
  • 边界条件
  • 特殊状态转换
  • 权限检查

13. Gas报告

13.1 Gas报告概述

除了功能测试,我们还需要关注Gas消耗。Gas消耗直接影响合约部署和运行的成本,特别是在主网上。

Gas报告的作用:

  • 识别高Gas函数
  • 对比不同实现方式
  • 优化Gas消耗
  • 估算部署和运行成本

13.2 生成Gas报告

使用--gas-stats选项:

bash 复制代码
npx hardhat test --gas-stats

这会显示每个函数的Gas消耗统计:

  • 最小值
  • 平均值
  • 中位数
  • 最大值
  • 调用次数
  • 部署成本和大小

使用环境变量:

bash 复制代码
REPORT_GAS=true npx hardhat test

13.3 Gas报告示例

报告输出:

bash 复制代码
Gas Report
==========
Counter.inc()
  Min: 26,234
  Avg: 26,234
  Median: 26,234
  Max: 26,234
  # calls: 5


Counter.incBy(uint256)
  Min: 26,456
  Avg: 26,456
  Median: 26,456
  Max: 26,456
  # calls: 3

13.4 Gas优化建议

优化技巧:

  • 减少存储操作:存储操作是最耗Gas的
  • 使用事件代替存储:对于不需要链上查询的数据
  • 优化数据结构:使用packed storage
  • 减少外部调用:批量处理操作
  • 使用库函数:复用代码,减少部署大小

对比测试:

bash 复制代码
it("Should compare gas costs", async function () {
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  // 测试方法1
  const tx1 = await contract.method1();
  const receipt1 = await tx1.wait();
  const gas1 = receipt1.gasUsed;
  
  // 测试方法2
  const tx2 = await contract.method2();
  const receipt2 = await tx2.wait();
  const gas2 = receipt2.gasUsed;
  
  console.log(`Method 1: ${gas1}, Method 2: ${gas2}`);
});

14. 测试最佳实践

14.1 命名和组织

描述性命名:

测试名称应该清晰描述测试场景和预期结果:

bash 复制代码
// 好的命名
it("Should revert when amount is zero", async function () {});
it("Should emit Transfer event when transferring tokens", async function () {});

// 不好的命名
it("Test 1", async function () {});
it("Should work", async function () {});

合理组织:

按功能分组测试,使用嵌套describe:

bash 复制代码
describe("Counter", function () {
  describe("Deployment", function () {
    // 部署相关测试
  });
  
  describe("Increment", function () {
    // 增量相关测试
  });
  
  describe("Error Handling", function () {
    // 错误处理相关测试
  });
});

14.2 测试独立性

保持独立:

每个测试应该能够独立运行,不依赖其他测试:

bash 复制代码
// 好的:每个测试独立
it("Test 1", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  // 测试1
});

it("Test 2", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  // 测试2 - 不依赖测试1
});

// 不好的:测试之间有依赖
let counter;
it("Test 1", async function () {
  counter = await ethers.deployContract("Counter");
});

it("Test 2", async function () {
  // 依赖测试1的counter
  await counter.inc();
});

使用loadFixture:

使用loadFixture可以确保每个测试都有干净的初始状态:

bash 复制代码
it("Should not be affected by previous test", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  // 即使前面的测试修改了状态,这里也是干净的
  expect(await counter.x()).to.equal(0n);
});

14.3 全面覆盖

测试所有路径:

  • 正常流程
  • 异常情况
  • 边界条件
  • 所有函数
  • 所有错误处理

边界测试:

bash 复制代码
it("Should handle zero value", async function () {});
it("Should handle maximum value", async function () {});
it("Should revert on overflow", async function () {});

14.4 错误处理

验证错误消息:

bash 复制代码
await expect(contract.fail())
  .to.be.revertedWith("Expected error message");

测试所有回退路径:

bash 复制代码
it("Should revert when condition 1 fails", async function () {});
it("Should revert when condition 2 fails", async function () {});
it("Should revert when condition 3 fails", async function () {});

使用自定义错误:

自定义错误比字符串错误消息更省Gas:

bash 复制代码
await expect(contract.fail())
  .to.be.revertedWithCustomError(contract, "CustomError");

14.5 性能优化

使用快照恢复:

使用loadFixture而不是beforeEach:

bash 复制代码
// 好的:使用loadFixture
const { counter } = await networkHelpers.loadFixture(deployCounterFixture);

// 不好的:每次都重新部署
beforeEach(async function () {
  counter = await ethers.deployContract("Counter");
});

并行运行测试:

bash 复制代码
npx hardhat test --parallel

14.6 代码复用

提取公共逻辑:

bash 复制代码
// 提取到Fixture
async function deployCounterFixture() {
  // 公共部署逻辑
}

// 提取辅助函数
async function setupCounter(initialValue: bigint) {
  const counter = await ethers.deployContract("Counter");
  await counter.setNumber(initialValue);
  return counter;
}

14.7 文档化

添加注释:

bash 复制代码
it("Should handle complex scenario", async function () {
  // 这个测试验证了在特定条件下,合约的行为
  // 条件:余额 > 1000,时间 > lockPeriod
  const { contract } = await networkHelpers.loadFixture(deployFixture);
  
  // 设置条件
  await contract.deposit(ethers.parseEther("2000"));
  await time.increase(lockPeriod);
  
  // 验证行为
  await expect(contract.withdraw())
    .to.not.be.reverted;
});

使用描述性变量名:

bash 复制代码
// 好的
const initialBalance = await token.balanceOf(owner.address);
const transferAmount = ethers.parseEther("100");

// 不好的
const b1 = await token.balanceOf(owner.address);
const amt = ethers.parseEther("100");

15. 常见错误和解决方案

15.1 忘记使用await

问题:忘记使用await等待异步操作

bash 复制代码
// 错误
expect(counter.inc()).to.emit(counter, "Increment");

// 正确
await expect(counter.inc()).to.emit(counter, "Increment");

解决方案:确保所有异步操作都使用await

15.2 事件名称错误

问题:事件名称大小写不匹配

bash 复制代码
// 错误:事件名称大小写不匹配
await expect(counter.inc())
  .to.emit(counter, "increment"); // 应该是"Increment"

// 正确
await expect(counter.inc())
  .to.emit(counter, "Increment");

解决方案:使用合约实例的filters方法

bash 复制代码
const incrementFilter = counter.filters.Increment();
await expect(counter.inc())
  .to.emit(counter, incrementFilter);

15.3 参数类型不匹配

问题:JavaScript的number类型与Solidity的uint256不匹配

bash 复制代码
// 错误:类型不匹配
expect(await counter.x()).to.equal(0); // 0是number类型

// 正确:使用bigint
expect(await counter.x()).to.equal(0n); // 0n是bigint类型

解决方案:

bash 复制代码
使用n后缀创建bigint:0n, 42n
使用BigInt()构造函数:BigInt(0)
使用ethers工具函数:ethers.parseEther("1")

15.4 错误消息不匹配

bash 复制代码
问题:revertWith要求消息完全匹配

// 错误:消息不完全匹配
await expect(contract.fail())
  .to.be.revertedWith("Error"); // 实际消息是"Error: invalid input"

// 正确:完全匹配
await expect(contract.fail())
  .to.be.revertedWith("Error: invalid input");

解决方案:

确保错误消息完全匹配,包括空格和大小写

如果消息可能变化,考虑只使用.to.be.reverted

15.5 网络连接问题

问题:无法连接到网络

解决方案:

检查hardhat.config.ts中的网络配置

确保使用正确的网络类型(edr-simulated或http)

检查环境变量是否正确设置

确认RPC URL有效

15.6 合约未部署

问题:在测试中使用合约前没有部署

bash 复制代码
// 错误:合约未部署
it("Should work", async function () {
  await counter.inc(); // counter未定义
});

// 正确:先部署合约
it("Should work", async function () {
  const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
  await counter.inc();
});

解决方案:

确保在测试前部署合约,或使用Fixture管理部署

16. 实战演示

16.1 完整测试示例

让我们看一个完整的测试示例,展示所有学到的技巧:

Counter合约:

bash 复制代码
// contracts/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Counter {
    uint256 public x;
    
    event Increment(uint256 by);
    
    constructor() {
        x = 0;
    }
    
    function inc() public {
        x++;
        emit Increment(1);
    }
    
    function incBy(uint256 by) public {
        require(by > 0, "incBy: increment should be positive");
        x += by;
        emit Increment(by);
    }
    
    function setNumber(uint256 _x) public {
        x = _x;
    }
}

完整测试文件:

bash 复制代码
// test/Counter.test.ts
import { expect } from "chai";
import { network } from "hardhat";

// 连接网络
const { ethers, networkHelpers } = await network.connect();

// 定义Fixture函数
async function deployCounterFixture() {
  const [owner, addr1, addr2] = await ethers.getSigners();
  const counter = await ethers.deployContract("Counter");
  
  return { counter, owner, addr1, addr2 };
}

// 测试套件
describe("Counter", function () {
  // 子套件:部署测试
  describe("Deployment", function () {
    // 测试用例:初始值测试
    it("Should deploy with initial value 0", async function () {
      const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
      
      expect(await counter.x()).to.equal(0n);
    });
    
    // 测试用例:合约地址验证
    it("Should have valid address", async function () {
      const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
      const address = await counter.getAddress();
      
      expect(address).to.be.a("string");
      expect(address).to.have.length(42);
      expect(address).to.match(/^0x[a-fA-F0-9]{40}$/);
    });
  });

  // 子套件:增量功能测试
  describe("Increment", function () {
    // 测试用例:基本增量
    it("Should increment counter", async function () {
      const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
      
      await counter.inc();
      expect(await counter.x()).to.equal(1n);
      
      await counter.inc();
      expect(await counter.x()).to.equal(2n);
    });

    // 测试用例:指定增量
    it("Should increment by specific amount", async function () {
      const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
      
      await counter.incBy(5n);
      expect(await counter.x()).to.equal(5n);
      
      await counter.incBy(10n);
      expect(await counter.x()).to.equal(15n);
    });

    // 测试用例:事件触发
    it("Should emit Increment event", async function () {
      const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
      
      await expect(counter.inc())
        .to.emit(counter, "Increment")
        .withArgs(1n);
      
      await expect(counter.incBy(5n))
        .to.emit(counter, "Increment")
        .withArgs(5n);
    });

    // 测试用例:错误处理
    it("Should revert when increment is zero", async function () {
      const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
      
      await expect(counter.incBy(0))
        .to.be.revertedWith("incBy: increment should be positive");
    });
  });

  // 子套件:状态隔离测试
  describe("State Isolation", function () {
    // 测试用例:测试隔离验证
    it("Should not be affected by previous test", async function () {
      // 即使前面的测试修改了状态,这里也是干净的
      const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
      
      // 从0开始,不是从之前测试的值开始
      expect(await counter.x()).to.equal(0n);
    });
  });
});

16.2 运行测试

运行所有测试:

bash 复制代码
npx hardhat test

运行特定测试文件:

bash 复制代码
npx hardhat test test/Counter.test.ts

运行匹配的测试:

bash 复制代码
npx hardhat test --grep "Deployment"

生成Gas报告:

bash 复制代码
npx hardhat test --gas-stats

生成覆盖率报告:

bash 复制代码
npx hardhat test --coverage

17.官方参考文档

Hardhat测试文档:https://hardhat.org/docs/testing

Chai断言库文档:https://www.chaijs.com/

Mocha测试框架文档:https://mochajs.org/

相关推荐
音浪豆豆_Rachel7 小时前
Flutter跨平台通信的实战演练:复杂数据结构与单元测试在鸿蒙生态中的完美实现
数据结构·flutter·单元测试·harmonyos
古城小栈8 小时前
医疗健康:区块链 + AI 疾病预测模型落地实践
人工智能·区块链
物流可信数据空间8 小时前
可信数据空间与区块链技术的结合点有哪些?
分布式·架构·区块链
白露与泡影8 小时前
Java单元测试、集成测试,区别
java·单元测试·集成测试
汽车仪器仪表相关领域9 小时前
ZRT-V 机器人减速器寿命测试系统:精准破解 “寿命焦虑” 的核心测试方案
人工智能·功能测试·机器学习·单元测试·机器人·可用性测试·安全性测试
古城小栈9 小时前
Go + 区块链:模块化链节点开发实践
开发语言·golang·区块链
YangYang9YangYan1 天前
2026高职会计电算化专业高价值技能证书
大数据·学习·区块链
Moonbeam Community1 天前
Polkadot 2025:从协议工程到可用的去中心化云平台
大数据·web3·去中心化·区块链·polkadot
OpenBuild.xyz1 天前
x402 V2:架构重构 + 多链兼容,定义智能代理支付新标准
web3·区块链