当前在各种区块链中,生态最全的要属兼容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.jso + 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
这里,console.log支持的数据类型有限,它仅支持4种数据类型:
-
uint
-
string
-
bool
-
address
但是它又提供了额外的API来支持其它类型,如`console.logBytes(bytes memory b)
等。详情见Hardhat Network Reference | Hardhat | Ethereum development environment for professionals by Nomic Foundation 。
我们来简单测试一下,在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 <= 10"); if(b > 10) { revert("b > 10 "); }else { revert(); } }
注意:这里会有编译警告,提示我们最后一个revert缺少提示字符串,我们是故意这样的,请忽略它。
在测试文件中添加如下describe
:
describe("Revert test", function () { it("a < 10 should be failed", async () => { await expect(greeter.revertTest(5, 5)).to.be.revertedWith("a <= 10"); }); it("b > 10 should be failed", async () => { await expect(greeter.revertTest(15, 55)).to.be.revertedWith("b > 10"); }); it("b < 10 should be failed", async () => { await expect(greeter.revertTest(15, 5)).to.be.reverted; }); });
然后我们运行测试通过。
九、区块测试
当我们涉及到和区块相关时,我们就需要进行相应的区块高度或者区块时间测试。先在测试合约中添加如下内容:
function blockNumberTest() public { require(block.number >= 10000,"not matched block number"); console.log("block number: %d", block.number); } function blockTimeTest() public { require(block.timestamp >= 1750631915,"not matched block time"); console.log("block timestamp: %d", block.timestamp); }
编译时会提示上面两个函数为view
函数,但是如果我们把它标记为view
函数,那么测试时便不会重新mine
一个区块。为了模拟真实场景,我们不把它标记为view
函数,从而在调用时产生一个新的区块。
然后在测试文件中增加如下describe
:
describe("Block test", () => { let block; let timestamp; beforeEach(async () => { block = await ethers.provider.getBlockNumber(); timestamp = (await ethers.provider.getBlock()).timestamp; }); // 注意,这里hardhat network 默认是一秒一个区块 it("Call before timestamp 1651631915 should be failed", async () => { assert.ok(timestamp < 1651631915); await expect(greeter.blockTimeTest()).to.be.revertedWith( "not matched block time" ); }); it("Call at timestamp 1651631915 should be successfult", async () => { await ethers.provider.send("evm_mine", [1651631915 - 1]); await greeter.blockTimeTest(); }); it("Call before block 10000 should be failed", async () => { assert.ok(block < 10000); await expect(greeter.blockNumberTest()).to.be.revertedWith( "not matched block number" ); }); it("Call at block 10000 should be successful", async () => { let value = 10000 - block - 1; value = ethers.BigNumber.from(value); value = value.toHexString(); await ethers.provider.send("hardhat_mine", [value]); await greeter.blockNumberTest(); }); });
注意,在上面的子describe
中又使用了beforeEach
函数。这里讲一下beforeEach
和before
的区别,beforeEach 顾名思义,在每项测试前都会执行一次,before
,在一个describe中只会执行一次。
这里it
函数要使用的全局变量都放在describe
中定义,通常我们测试时会使用一个全新的状态,所以使用了beforeEach
,但特殊场景会使用before
,比如后面的测试依赖于前面的测试结果的。
执行测试后输出的结果显示,我们确定是在block == 10000
和timestamp == 5555555555
调用了相应的函数。
十、测试覆盖率
打开hardhat.config.js
,我们可以看到有如下两行:
require("hardhat-gas-reporter"); require("solidity-coverage");
分别是gas消耗统计插件和测试覆盖率插件。这里我们只介绍第二个插件的用法。由于插件已经装好了,我们直接运行如下命令:
npx hardhat coverage
预料之外的事情发生了,提示我们gasPrice太小了,这个笔者也不知道原因,不过我们可以解决它。
将上面的相关代码替换为如下:
await greeter.blockTimeTest({ gasPrice: ethers.utils.parseUnits("10", "wei"), }); await greeter.blockNumberTest({ gasPrice: ethers.utils.parseUnits("20", "wei"), });
好了,我们再次运行npx hardhat coverage
。四个100%,Perfect !
然而我们这里有点小问题,如果更换blockNumber
和blockTime
的测试顺序,这里仍然会有错误提示,这里估计是插件库之间不兼容造成的吧,暂无解决方案。
我们添加一个未完全测试的函数:
function getValue(uint a) public pure returns(uint) { if(a > 10) { return 3; }else { return 1; } }
我们新添加一个describe,这里同样会出现block测试时的错误,因此我们将它放到Block test之前,Revert test 之后。
再次运npx hardhat coverage
,得到如下结果:
13 passing (1s) --------------|----------|----------|----------|----------|----------------| File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines | --------------|----------|----------|----------|----------|----------------| contracts/ | 96.15 | 90 | 100 | 96.15 | | Greeter.sol | 96.15 | 90 | 100 | 96.15 | 66 | --------------|----------|----------|----------|----------|----------------| All files | 96.15 | 90 | 100 | 96.15 | | --------------|----------|----------|----------|----------|----------------|
可以看到66行代码没有测试到。这里只有一行未测试到,所以很直接。当我们有很多行未测试到时,怎么查看呢?这小小的地方是显示不了那么多行数的。
Coverage同时提供了一个网页版的结果图,在项目根目录下找到coverage
目录并点开,右键点击index.html
并选择在默认浏览器中打开。类似如下图:
点击那个contracts目录,会显示下面所有的合约,这里就只有一个,Greeter.sol
。然后再点击Greeter.sol
,会进入源代码界面,如下图:
这里的1x
,3x
代表执行次数,E
应该是代表分支情况吧,粉色背景的就是未执行的代码了。(这里我也是猜的,但是1x
代表执行次数是确定的。
我们只需要在测试文件里再调用 一次getValue
并将参数设置为5,就又可以达到 100%覆盖了。
好了,基本的单元测试方法介绍就结束了,更深层次的了解还是需要多看hardhat自己的官方文档。
十一、最终代码
最终合约文件Greeter.sol
为:
//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0; import "hardhat/console.sol"; contract Greeter { string private greeting; event CallerEmit(address indexed sender, uint value); 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; } 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; } function eventTest() public { emit CallerEmit(msg.sender, 500); } function revertTest(uint a, uint b) public { require(a > 10, "a <= 10"); if(b > 10) { revert("b > 10 "); }else { revert(); } } function blockNumberTest() public { require(block.number >= 10000,"not matched block number"); console.log("block number: %d", block.number); } function blockTimeTest() public { require(block.timestamp >= 1651631915,"not matched block time"); console.log("block timestamp: %d", block.timestamp); } function getValue(uint a) public pure returns(uint) { if(a > 10) { return 3; }else { return 1; } } }
最终测试文件sample-test.js
为:
const { expect, 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); }); }); describe("Revert test", function () { it("a < 10 should be failed", async () => { await expect(greeter.revertTest(5, 5)).to.be.revertedWith("a <= 10"); }); it("b > 10 should be failed", async () => { await expect(greeter.revertTest(15, 55)).to.be.revertedWith("b > 10"); }); it("b < 10 should be failed", async () => { await expect(greeter.revertTest(15, 5)).to.be.reverted; }); }); describe("Get value test", () => { it("a > 10 test", async () => { expect(await greeter.getValue(15)).to.be.equal(3); }); }); describe("Block test", () => { let block; let timestamp; beforeEach(async () => { block = await ethers.provider.getBlockNumber(); timestamp = (await ethers.provider.getBlock()).timestamp; }); // 注意,这里hardhat network 默认是一秒一个区块 it("Call before timestamp 1651631915 should be failed", async () => { assert.ok(timestamp < 1651631915); await expect(greeter.blockTimeTest()).to.be.revertedWith( "not matched block time" ); }); it("Call at timestamp 1651631915 should be successfult", async () => { await ethers.provider.send("evm_mine", [1651631915 - 1]); await greeter.blockTimeTest({ gasPrice: ethers.utils.parseUnits("10", "wei"), }); }); it("Call before block 10000 should be failed", async () => { assert.ok(block < 10000); await expect(greeter.blockNumberTest()).to.be.revertedWith( "not matched block number" ); }); it("Call at block 10000 should be successful", async () => { let value = 10000 - block - 1; value = ethers.BigNumber.from(value); value = value.toHexString(); await ethers.provider.send("hardhat_mine", [value]); await greeter.blockNumberTest({ gasPrice: ethers.utils.parseUnits("20", "wei"), }); }); }); });
当前在各种区块链中,生态最全的要属兼容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.jso + 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
这里,console.log支持的数据类型有限,它仅支持4种数据类型:
-
uint
-
string
-
bool
-
address
但是它又提供了额外的API来支持其它类型,如`console.logBytes(bytes memory b)
等。详情见Hardhat Network Reference | Hardhat | Ethereum development environment for professionals by Nomic Foundation 。
我们来简单测试一下,在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 <= 10"); if(b > 10) { revert("b > 10 "); }else { revert(); } }
注意:这里会有编译警告,提示我们最后一个revert缺少提示字符串,我们是故意这样的,请忽略它。
在测试文件中添加如下describe
:
describe("Revert test", function () { it("a < 10 should be failed", async () => { await expect(greeter.revertTest(5, 5)).to.be.revertedWith("a <= 10"); }); it("b > 10 should be failed", async () => { await expect(greeter.revertTest(15, 55)).to.be.revertedWith("b > 10"); }); it("b < 10 should be failed", async () => { await expect(greeter.revertTest(15, 5)).to.be.reverted; }); });
然后我们运行测试通过。
九、区块测试
当我们涉及到和区块相关时,我们就需要进行相应的区块高度或者区块时间测试。先在测试合约中添加如下内容:
function blockNumberTest() public { require(block.number >= 10000,"not matched block number"); console.log("block number: %d", block.number); } function blockTimeTest() public { require(block.timestamp >= 1750631915,"not matched block time"); console.log("block timestamp: %d", block.timestamp); }
编译时会提示上面两个函数为view
函数,但是如果我们把它标记为view
函数,那么测试时便不会重新mine
一个区块。为了模拟真实场景,我们不把它标记为view
函数,从而在调用时产生一个新的区块。
然后在测试文件中增加如下describe
:
describe("Block test", () => { let block; let timestamp; beforeEach(async () => { block = await ethers.provider.getBlockNumber(); timestamp = (await ethers.provider.getBlock()).timestamp; }); // 注意,这里hardhat network 默认是一秒一个区块 it("Call before timestamp 1651631915 should be failed", async () => { assert.ok(timestamp < 1651631915); await expect(greeter.blockTimeTest()).to.be.revertedWith( "not matched block time" ); }); it("Call at timestamp 1651631915 should be successfult", async () => { await ethers.provider.send("evm_mine", [1651631915 - 1]); await greeter.blockTimeTest(); }); it("Call before block 10000 should be failed", async () => { assert.ok(block < 10000); await expect(greeter.blockNumberTest()).to.be.revertedWith( "not matched block number" ); }); it("Call at block 10000 should be successful", async () => { let value = 10000 - block - 1; value = ethers.BigNumber.from(value); value = value.toHexString(); await ethers.provider.send("hardhat_mine", [value]); await greeter.blockNumberTest(); }); });
注意,在上面的子describe
中又使用了beforeEach
函数。这里讲一下beforeEach
和before
的区别,beforeEach 顾名思义,在每项测试前都会执行一次,before
,在一个describe中只会执行一次。
这里it
函数要使用的全局变量都放在describe
中定义,通常我们测试时会使用一个全新的状态,所以使用了beforeEach
,但特殊场景会使用before
,比如后面的测试依赖于前面的测试结果的。
执行测试后输出的结果显示,我们确定是在block == 10000
和timestamp == 5555555555
调用了相应的函数。
十、测试覆盖率
打开hardhat.config.js
,我们可以看到有如下两行:
require("hardhat-gas-reporter"); require("solidity-coverage");
分别是gas消耗统计插件和测试覆盖率插件。这里我们只介绍第二个插件的用法。由于插件已经装好了,我们直接运行如下命令:
npx hardhat coverage
预料之外的事情发生了,提示我们gasPrice太小了,这个笔者也不知道原因,不过我们可以解决它。
将上面的相关代码替换为如下:
await greeter.blockTimeTest({ gasPrice: ethers.utils.parseUnits("10", "wei"), }); await greeter.blockNumberTest({ gasPrice: ethers.utils.parseUnits("20", "wei"), });
好了,我们再次运行npx hardhat coverage
。四个100%,Perfect !
然而我们这里有点小问题,如果更换blockNumber
和blockTime
的测试顺序,这里仍然会有错误提示,这里估计是插件库之间不兼容造成的吧,暂无解决方案。
我们添加一个未完全测试的函数:
function getValue(uint a) public pure returns(uint) { if(a > 10) { return 3; }else { return 1; } }
我们新添加一个describe,这里同样会出现block测试时的错误,因此我们将它放到Block test之前,Revert test 之后。
再次运npx hardhat coverage
,得到如下结果:
13 passing (1s) --------------|----------|----------|----------|----------|----------------| File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines | --------------|----------|----------|----------|----------|----------------| contracts/ | 96.15 | 90 | 100 | 96.15 | | Greeter.sol | 96.15 | 90 | 100 | 96.15 | 66 | --------------|----------|----------|----------|----------|----------------| All files | 96.15 | 90 | 100 | 96.15 | | --------------|----------|----------|----------|----------|----------------|
可以看到66行代码没有测试到。这里只有一行未测试到,所以很直接。当我们有很多行未测试到时,怎么查看呢?这小小的地方是显示不了那么多行数的。
Coverage同时提供了一个网页版的结果图,在项目根目录下找到coverage
目录并点开,右键点击index.html
并选择在默认浏览器中打开。类似如下图:
点击那个contracts目录,会显示下面所有的合约,这里就只有一个,Greeter.sol
。然后再点击Greeter.sol
,会进入源代码界面,如下图:
这里的1x
,3x
代表执行次数,E
应该是代表分支情况吧,粉色背景的就是未执行的代码了。(这里我也是猜的,但是1x
代表执行次数是确定的。
我们只需要在测试文件里再调用 一次getValue
并将参数设置为5,就又可以达到 100%覆盖了。
好了,基本的单元测试方法介绍就结束了,更深层次的了解还是需要多看hardhat自己的官方文档。
十一、最终代码
最终合约文件Greeter.sol
为:
//SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0; import "hardhat/console.sol"; contract Greeter { string private greeting; event CallerEmit(address indexed sender, uint value); 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; } 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; } function eventTest() public { emit CallerEmit(msg.sender, 500); } function revertTest(uint a, uint b) public { require(a > 10, "a <= 10"); if(b > 10) { revert("b > 10 "); }else { revert(); } } function blockNumberTest() public { require(block.number >= 10000,"not matched block number"); console.log("block number: %d", block.number); } function blockTimeTest() public { require(block.timestamp >= 1651631915,"not matched block time"); console.log("block timestamp: %d", block.timestamp); } function getValue(uint a) public pure returns(uint) { if(a > 10) { return 3; }else { return 1; } } }
最终测试文件sample-test.js
为:
const { expect, 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); }); }); describe("Revert test", function () { it("a < 10 should be failed", async () => { await expect(greeter.revertTest(5, 5)).to.be.revertedWith("a <= 10"); }); it("b > 10 should be failed", async () => { await expect(greeter.revertTest(15, 55)).to.be.revertedWith("b > 10"); }); it("b < 10 should be failed", async () => { await expect(greeter.revertTest(15, 5)).to.be.reverted; }); }); describe("Get value test", () => { it("a > 10 test", async () => { expect(await greeter.getValue(15)).to.be.equal(3); }); }); describe("Block test", () => { let block; let timestamp; beforeEach(async () => { block = await ethers.provider.getBlockNumber(); timestamp = (await ethers.provider.getBlock()).timestamp; }); // 注意,这里hardhat network 默认是一秒一个区块 it("Call before timestamp 1651631915 should be failed", async () => { assert.ok(timestamp < 1651631915); await expect(greeter.blockTimeTest()).to.be.revertedWith( "not matched block time" ); }); it("Call at timestamp 1651631915 should be successfult", async () => { await ethers.provider.send("evm_mine", [1651631915 - 1]); await greeter.blockTimeTest({ gasPrice: ethers.utils.parseUnits("10", "wei"), }); }); it("Call before block 10000 should be failed", async () => { assert.ok(block < 10000); await expect(greeter.blockNumberTest()).to.be.revertedWith( "not matched block number" ); }); it("Call at block 10000 should be successful", async () => { let value = 10000 - block - 1; value = ethers.BigNumber.from(value); value = value.toHexString(); await ethers.provider.send("hardhat_mine", [value]); await greeter.blockNumberTest({ gasPrice: ethers.utils.parseUnits("20", "wei"), }); }); }); });