当前在各种区块链中,生态最全的要属兼容EVM的区块链,在该类区块链上的智能合约绝大部分使用Solidity编写。因此,对Solidity编写的智能合约进行单元测试便成为一项经常性的工作。本文简要的介绍一下怎样使用hardhat进行Solidity智能合约单元测试。
一、什么是Hardhat我们来看其官方文档的描述:
Hardhat is a development environment to compile, deploy, test, and debug your Ethereum software.
意思为 Hardhat
是一个编译,部署,测试和调试以太坊程序的开发环境,在这里本文只涉及到其测试功能。
在Hardhat
之前,我们使用truffle
做为开发、部署和测试环境。作为后来者,Hardhat
的功能更强大,因此现在我们一般使用Hardhat
来作为智能合约开发和测试工具。
官方文档介绍了两种测试方式:ethers.js + Waffle
和Web3.js + Truffle
。在这里我们使用ethers.js + Waffle
模式。
我们进行单元测试,经常性的测试内容有:
- 状态检查,例如合约部署后检查初始状态是否正确,函数调用后检查状态是否改变。一般状态检查为读取view函数。
- 事件触发。基本上,合约中的关键操作都应该触发事件进行相应追踪。在单元测试中了可以测试事件是否触发,抛出的参数是否正确。
- 交易重置。在测试一些非预期条件时,交易应当重置并给出相应的原因。使用单元测试可以检测是否重置及错误原因是否相同。
- 函数计算。例如要计算不同条件下某函数的返回值(例如奖励值),我们需要循环调用 某个函数并输入不同的参数,看是否结果相符。
- 完全功能测试。例如我们合约中涉及到了区块高度或者 区块时间,比如质押一年后才能提取。此时我们一般需要加速区块时间或者区块高度来进行测试。幸运的是,
hardhat
提供了接口可以方便的进行此项测试。 - 测试覆盖率。包含代码覆盖率,函数覆盖率和分支覆盖率。一般情况下,应该追求 100%完全覆盖。比如你写了一个
modifier
,但是忘记加到函数上去了,而单元测试也漏掉了,此时代码覆盖就会显示该代码未测试,这样可以发现一些简单的BUG。特殊情况下或者确定有代码不会执行的情况下,不追求100%覆盖率。
接下来我们来详细介绍每项内容的测试方法。
三、示例合约我们按照官方介绍新建一个示例工程Greeting
。在工作目录下运行下列命令:
mkdir Greeting
cd Greeting
npm install --save-dev hardhat
npx hardhat
此时选择第二项,创建一个高级示例项目(当然也可以选第3项使用typescrit),等待依赖库安装完毕。
运行code .
使用vocode打开当前目录。
我们可以看到项目的contracts
目录下已经生成了一个示例合约Greeter.sol
,内容如下:
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
import "hardhat/console.sol";
contract Greeter {
string private greeting;
constructor(string memory _greeting) {
console.log("Deploying a Greeter with greeting:", _greeting);
greeting = _greeting;
}
function greet() public view returns (string memory) {
return greeting;
}
function setGreeting(string memory _greeting) public {
console.log("Changing greeting from '%s' to '%s'", greeting, _greeting);
greeting = _greeting;
}
}
代码比较简单,需要注意的是它使用了一个hardhat/console.sol
插件,该插件可以在hardhat netwrok
环境中打印出相应的值,方便开发时调试。可以看到,它支持占位符模式。
进一步查看其文档,它实现了类似Node.js
的console.log
格式,其底层调用是util.format
。这里我们看到它只使用了%s
这一种占位符。
打开项目根目录下的test
目录,我们可以看到有一个sample-test.js
的文件,其内容如下:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Greeter", function () {
it("Should return the new greeting once it's changed", async function () {
const Greeter = await ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy("Hello, world!");
await greeter.deployed();
expect(await greeter.greet()).to.equal("Hello, world!");
const setGreetingTx = await greeter.setGreeting("Hola, mundo!");
// wait until the transaction is mined
await setGreetingTx.wait();
expect(await greeter.greet()).to.equal("Hola, mundo!");
});
});
这里的测试也比较简单,一般使用describe
来代表测试某个项目或者功能,使用it
来代表具体某项测试。注意,describe
和it
是函数,在javascript中,一切都是函数。因此,我们可以在describe
中再次嵌套describe
,这样最外层的describe代表整个项目,内部的describe代表某项目功能。
在该测试文件中,先进行了合约的部署,然后验证合约的状态变量greeting
是否为部署时提供的Hello, world!
。然后运行setGreeting
函数改变问候语为Hola, mundo!
,并再次验证更改后的greeting
。
我们运行npx hardhat test ./test/sample-test.js
,结果如下:
Compiled 2 Solidity files successfully
Greeter
Deploying a Greeter with greeting: Hello, world!
Changing greeting from 'Hello, world!' to 'Hola, mundo!'
✔ Should return the new greeting once it's changed (946ms)
1 passing (949ms)
这里可以看到,我们打印出来了两个日志,刚好是我们合约中的console.log
语句。
这里,console.log支持的数据类型有限,它仅支持4种数据类型:
- uint
- string
- bool
- address
但是它又提供了额外的API来支持其它类型,如console.logBytes(bytes memory b)
等。详情见https://hardhat.org/hardhat-network/reference/#console-log 。
我们来简单测试一下,在Greeter.sol中添加如下函数:
function testConsole() public view returns(bool) {
console.log("Caller is '%s'", msg.sender);
console.log("Caller is '%d'", msg.sender);
console.log("Caller is ", msg.sender);
console.log("Number is '%s'", 0xff);
console.log("Number is '%d'", 0xff);
console.logBytes1(bytes1(0xff));
console.logBytes(abi.encode(msg.sender));
console.log("Reslut is ", true);
return true;
}
在sample-test.js
中添加一行代码expect(await greeter.testConsole()).to.be.equal(true);
,再次运行npx hardhat test ./test/sample-test.js
,结果如下:
Compiled 1 Solidity file successfully
Greeter
Deploying a Greeter with greeting: Hello, world!
Changing greeting from 'Hello, world!' to 'Hola, mundo!'
Caller is '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266'
Caller is '1.3908492957860717e+48'
Caller is 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Number is '255'
Number is '255'
0xff
0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
Reslut is true
✔ Should return the new greeting once it's changed (707ms)
1 passing (709ms)
可以看到,当我们把地址类型当成整数打印时,它打印了对应的整数值。通常情况下,对于console.log
支持的四种类型,我们可以不使用通配符或者全部使用%s
作为字符串输出,特殊类型的数据使用相应的API进行打印。
我们知道,合约中重要的操作基本上都会触发事件,因此,捕获抛出的事件并检查事件中的参数也是一项经常性的工作。在合约中添加如下代码。
function eventTest() public {
emit CallerEmit(msg.sender, 500);
}
我们这次修改我们的测试文件,将各功能均写一个describe来进行,代码如下:
const { expect, util, assert } = require("chai");
const { ethers } = require("hardhat");
describe("Greeter", function () {
let greeter;
let owner, user1, users;
beforeEach(async () => {
[owner, user1, ...users] = await ethers.getSigners();
const Greeter = await ethers.getContractFactory("Greeter");
greeter = await Greeter.deploy("Hello, world!");
await greeter.deployed();
});
describe("State check test", function () {
it("Should return the new greeting once it's changed", async function () {
expect(await greeter.greet()).to.equal("Hello, world!");
const setGreetingTx = await greeter.setGreeting("Hola, mundo!");
// wait until the transaction is mined
await setGreetingTx.wait();
expect(await greeter.greet()).to.equal("Hola, mundo!");
});
});
describe("Console test", function () {
it("Console.log should be successful", async function () {
expect(await greeter.testConsole()).to.be.equal(true);
});
});
describe("Event test", function () {
it("owner emit test", async () => {
await expect(greeter.eventTest())
.to.be.emit(greeter, "CallerEmit")
.withArgs(owner.address, 500);
});
it("user1 emit test", async () => {
await expect(greeter.connect(user1).eventTest())
.to.be.emit(greeter, "CallerEmit")
.withArgs(user1.address, 500);
});
it("Get emit params test", async () => {
const tx = await greeter.connect(users[0]).eventTest();
await tx.wait();
const receipt = await ethers.provider.getTransactionReceipt(tx.hash);
const hash = ethers.utils.solidityKeccak256(
["string"],
["CallerEmit(address,uint256)"]
);
const infos = receipt.logs[0];
assert.equal(infos.topics[0], hash);
const sender = ethers.utils.getAddress(
"0x" + infos.topics[1].substring(26)
);
assert.equal(sender, users[0].address);
const value = ethers.BigNumber.from(infos.data);
expect(value).to.be.equal(500);
});
});
});
可以看到,我们测试事件时进行了三项测试,分别为:
- 正常测试,主要是检查事件是否触发,参数是否正确。
- 同上,主要是切换合约调用者为
user1
。 - 这里是解析事件来获取事件参数,此场景应用于某些事件参数无法提前获取等,比如一个伪随机数。
我们来测试条件不满足时的交易重置,在合约中添加如下代码:
function revertTest(uint a, uint b) public {
require(a > 10, "a
关注
打赏
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?