1.概述
在构建智能合约时编写自动化测试至关重要,因为这关系到用户的资金。
为了测试我们的合约,我们将使用 Hardhat Network,这是一个专为开发而设计的本地以太坊网络。它内置于 Hardhat 中,并用作默认网络。无需设置任何内容即可使用它。
在我们的测试中我们将使用ethers.js与我们构建的以太坊合约进行交互,我们将使用mocha.js作为我们的测试运行者。使用Chai作为测试用例的断言库,进行测试用例的编写。
ethers.js 库旨在成为一个完整且紧凑的库,用于与以太坊区块链及其生态系统进行交互。
Mocha 是一个功能丰富的 JavaScript 测试框架,运行在Node.js和浏览器中,使异步测试变得简单而有趣。Mocha 测试串行运行,允许灵活、准确的报告,同时将未捕获的异常映射到正确的测试用例。
Chai 是一个用于节点和浏览器的 BDD / TDD 断言库,可以与任何 javascript 测试框架完美搭配。
2.合约示例
pragma solidity ^0.8.17;
contract Token{
string public name = "My Hardhat Token";
string public symbol = "MHT";
uint256 public totalSupply = 1000000;
address public owner;
//A mapping is a key/value map. Here we store each account's balance.
mapping(address=>uint256) balances;
// The Transfer event helps off-chain applications understand
// what happens within your contract.
event Transfer(address indexed _from,address indexed _to,uint256 value);
/*
*Contract initialization.
*/
constructor (){
// The totalSupply is assigned to the transaction sender, which is the
// account that is deploying the contract.
balances[msg.sender] = totalSupply;
owner = msg.sender;
}
/**
* A function to transfer tokens.
*
* The `external` modifier makes a function *only* callable from *outside*
* the contract.
*/
function transfer (address to,uint256 amount) external {
// Check if the transaction sender has enough tokens.
// If `require`'s first argument evaluates to `false` then the
// transaction will revert.
require(balances[msg.sender] >= amount,"Not enough tokens") ;
// Transfer the amount.
balances[msg.sender]-=amount;
balances[to]+=amount;
// Notify off-chain applications of the transfer.
emit Transfer(msg.sender,to,amount);
}
/**
* Read only function to retrieve the token balance of a given account.
*
* The `view` modifier indicates that it doesn't modify the contract's
* state, which allows us to call it without executing a transaction.
*/
function balanceOf(address account) external view returns(uint256){
return balances[account];
}
}
3.使用 Mocha + Chai 为合约编写测试用例
测试脚本
:测试用例的脚本,通常命名为*.js、
*.test.js
或*.spec.js。
一般情况下测试脚本是按功能模块划分,一个测试脚本里面包含一个或多个describe
块。describe
块:称为"测试套件"(test suite),表示一组相关的测试。一个describe
块里面包含一个或多个it
块,甚至describe
块里面还可能包含describe
块。it
块:称为"测试用例"(test case),表示一个单独的测试,是测试的最小单位。一个it
块里面包含一个或多个断言。
断言
:用于预判、校验用例逻辑的执行结果,我们使用了 Chai 来写断言,并使用 expect 这种BDD
方式,原因是它更接近于自然语言,相对比较容易理解些。路径:通常会在hardhat项目中的test目录下面编写测试脚本。
Token.sol测试用例Token.js:
const {loadFixture,} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const { expect } = require("chai");
describe("Token contract", function () {
async function deployTokenFixture() {
const [owner,addr1,addr2] = await ethers.getSigners();
const hardhatToken = await ethers.deployContract("Token");
//console.log("contract addr:",hardhatToken);
await hardhatToken.waitForDeployment();
// Fixtures can return anything you consider useful for your tests
return {owner,addr1,addr2,hardhatToken};
}
// You can nest describe calls to create subsections.
describe("Deployment", function () {
// `it` is another Mocha function. This is the one you use to define each
// of your tests. It receives the test name, and a callback function.
//
// If the callback function is async, Mocha will `await` it.
it("Should set the right owner", async function () {
// We use loadFixture to setup our environment, and then assert that
// things went well
const { hardhatToken, owner } = await loadFixture(deployTokenFixture);
// `expect` receives a value and wraps it in an assertion object. These
// objects have a lot of utility methods to assert values.
// This test expects the owner variable stored in the contract to be
// equal to our Signer's owner.
expect(await hardhatToken.owner()).to.equal(owner.address);
});
it("Should assign the total supply of tokens to the owner", async function () {
const { hardhatToken, owner } = await loadFixture(deployTokenFixture);
const ownerBalance = await hardhatToken.balanceOf(owner.address);
expect(await hardhatToken.totalSupply()).to.equal(ownerBalance);
});
});
describe("Transactions", function () {
it("Should transfer tokens between accounts", async function () {
const { hardhatToken, owner, addr1, addr2 } = await loadFixture(
deployTokenFixture
);
// Transfer 50 tokens from owner to addr1
await expect(
hardhatToken.transfer(addr1.address, 50)
).to.changeTokenBalances(hardhatToken, [owner, addr1], [-50, 50]);
// Transfer 50 tokens from addr1 to addr2
// We use .connect(signer) to send a transaction from another account
await expect(
hardhatToken.connect(addr1).transfer(addr2.address, 50)
).to.changeTokenBalances(hardhatToken, [addr1, addr2], [-50, 50]);
});
it("Should emit Transfer events", async function () {
const { hardhatToken, owner, addr1, addr2 } = await loadFixture(
deployTokenFixture
);
// Transfer 50 tokens from owner to addr1
await expect(hardhatToken.transfer(addr1.address, 50))
.to.emit(hardhatToken, "Transfer")
.withArgs(owner.address, addr1.address, 50);
// Transfer 50 tokens from addr1 to addr2
// We use .connect(signer) to send a transaction from another account
await expect(hardhatToken.connect(addr1).transfer(addr2.address, 50))
.to.emit(hardhatToken, "Transfer")
.withArgs(addr1.address, addr2.address, 50);
});
it("Should fail if sender doesn't have enough tokens", async function () {
const { hardhatToken, owner, addr1 } = await loadFixture(
deployTokenFixture
);
const initialOwnerBalance = await hardhatToken.balanceOf(owner.address);
// Try to send 1 token from addr1 (0 tokens) to owner.
// `require` will evaluate false and revert the transaction.
await expect(
hardhatToken.connect(addr1).transfer(owner.address, 1)
).to.be.revertedWith("Not enough tokens");
// Owner balance shouldn't have changed.
expect(await hardhatToken.balanceOf(owner.address)).to.equal(
initialOwnerBalance
);
});
});
});
我们对关键代码进一步解释说明:
3.1 getSigners()
const [owner,addr1,addr2] = await ethers.getSigners();
ethers.js 中的Signer
是代表以太坊账户的对象。它用于将交易发送到合约和其他帐户。getSigners() 获取了我们连接到的节点中的帐户列表,其中owner账户是部署合约的账户。默认情况下,合约
实例连接到第一个签名者owner。
3.2 deployContract()
const hardhatToken = await ethers.deployContract("Token");
调用ethers.deployContract("Token")
将开始部署我们的代币合约,并返回该合约的对象实例,通过实例我们能够调用合约内部的函数方法。
3.3 balanceOf()
const ownerBalance = await hardhatToken.balanceOf(owner.address);
部署完成后,我们通过hardhatToken来调用
合约中的balanceOf(),
获取owner账户的余额。
3.4 equal()
expect(await hardhatToken.totalSupply()).to.equal(ownerBalance);
chai.js的断言函数,它在测试中被经常使用,用于验证前后值是否相等,在相等的情况测试才会通过。而在hardhat中,断言函数被集成在@nomicfoundation/hardhat-chai-matchers 插件中, 封装成matchers(匹配器),更方便合约的测试。
3.5 loadFixture()
// We use loadFixture to setup our environment, and then assert that
// things went well
const { hardhatToken, owner } = await loadFixture(deployTokenFixture);
加载固定配置。在测试用例中,会出现一些重复性的代码和事务,在每个测试开始时执行许多事务可能会使测试套件变慢,因此我们引入loadFixture()来优雅的避免代码重复并提高测试套件的性能,再后续的调用中,不会重复执行其内的操作,而是直接获取其快照的状态和返回值。
3.6 changeTokenBalances()
// Transfer 50 tokens from owner to addr1
await expect(
hardhatToken.transfer(addr1.address, 50)
).to.changeTokenBalances(hardhatToken, [owner, addr1], [-50, 50]);
验证账户余额变化情况的匹配器。交易函数会对账户余额进行操作,我们通过changeTokenBalances()来验证账户余额的变化情况是否与预期一致。
3.7 emit()
// Transfer 50 tokens from owner to addr1
await expect(hardhatToken.transfer(addr1.address, 50))
.to.emit(hardhatToken, "Transfer")
.withArgs(owner.address, addr1.address, 50);
验证合约是否触发了某个事件的匹配器,句式一般是这样:await expect(...).to.emit(...).withArgs(),在expect(...)中执行事件,且必须加上await同步块保证事件在区块链上执行完毕,通过.withArgs() 中的参数来验证事件中的参数列表是否一致。
3.8 revertedWith()
// Try to send 1 token from addr1 (0 tokens) to owner.
// `require` will evaluate false and revert the transaction.
await expect(
hardhatToken.connect(addr1).transfer(owner.address, 1)
).to.be.revertedWith("Not enough tokens");
验证合约事务回退的匹配器,revertedWith()中的参数为回退时发生的错误,案例中,验证了回退和原因,并验证通过。原因是addr1账户余额小于转账数量,因此无法进行转账操作,并提示"Not enough tokens"的错误信息。
4.运行测试用例
npx hardhat test test/Token.js
运行结果:
Token contract
Deployment
✔ Should set the right owner (2424ms)
✔ Should assign the total supply of tokens to the owner
Transactions
✔ Should transfer tokens between accounts (106ms)
✔ Should emit Transfer events
✔ Should fail if sender doesn't have enough tokens (71ms)
5 passing (3s)