译文出自:登链翻译计划 [1]
译者:翻译小组 [2]
校对:Tiny 熊 [3]
我知道,你想直接部署合约和前端,并立刻就开始在测试网上进行测试,但是 ...... 我们需要确定一切都按预期工作,而不需要在前端用户界面(UI) 上进行 monkey 测试。
因此,在文章的下一部分,我将介绍一些开发人员应该做的事情:测试合约逻辑 !
Waffle
Waffle[4] 是一个用于编写和测试智能合约的库,它与 ethers-js 配合得非常默契。
Waffle 有很多有帮助的工具。waffle 中的测试是用 Mocha[5] 和 Chai[6] 一起编写的。你可以使用不同的测试环境,但 Waffle 的匹配器 (matcher) 只能在
chai
下工作。
我们将使用 Chai 匹配器 [7] 来验证我们所期望的条件是否已经满足。
在写完所有的测试用例后,你只需要输入 yarn test
,就会自动针对你的合约进行测试。
我不会解释如何使用这个库(你可以简单地看一下下面的代码来了解),我将专注于应该测试什么。
我们的合约已经实现了一些逻辑:
用
mapping(address => uint256) public balances
保存用户余额有一个最小质押金额的阀值
uint256 public constant threshold = 1 ether
。有一个最大的时间限制 (deadline)
uint256 public deadline = block.timestamp 120 seconds
。如果外部合约不是
completed
并且deadline
还没有到,用户可以调用stake()
函数如果外部合约不是
completed
并且deadline
还没有到,用户可以调用execute
方法。如果时间已经到了
deadline
并且外部合约不是completed
,用户可以撤回资金。timeLeft()
返回剩余的秒数,直到时间到deadline
,之后它应该总是返回0
测试中应该涵盖什么
PS:这是我个人的测试方法,如果你有建议,请在 Twitter 上找我 !
我写测试的时候,习惯用一个独立的函数并且覆盖所有边缘情况。试试写一写测试用例来回答下面的问题:
是否已经涵盖所有边缘情况?
函数是否按预期回退?
函数是否按需发出事件?
输入特殊值时,函数是否输出预期结果?是否按预期达到新状态?
函数是否按预期返回值(如果它有返回)?
如何在测试中模拟挖矿
还记得我们说过吗,为了正确模拟 timeLeft()
,我们必须创建交易或从水龙头(Faucet)获取资金(这也是一种交易)。好吧,为了解决这个问题,我写了一个小程序(你可以直接复制到其他项目中)。
当你调用 increaseWorldTimeInSeconds(10, true)
时,EVM 内部时间戳会比当前时间快进 10 秒。之后,如果指定出块,它还会挖一个块来创建一个交易。
下次合约被调用时,timeLeft()
应该被更新。
测试 execute() 函数
我们先看这一部分测试,然后我将发布整段代码,我只解释其中一些特定的代码。这段代码涵盖了 execute()
函数:
describe('Test execute() method', () => { it('execute reverted because stake amount not reached threshold', async () => { await expect(stakerContract.connect(addr1).execute()).to.be.revertedWith('Threshold not reached'); }); it('execute reverted because external contract already completed', async () => { const amount = ethers.utils.parseEther('1'); await stakerContract.connect(addr1).stake({ value: amount, }); await stakerContract.connect(addr1).execute(); await expect(stakerContract.connect(addr1).execute()).to.be.revertedWith('staking process already completed'); }); it('execute reverted because deadline is reached', async () => { // reach the deadline await increaseWorldTimeInSeconds(180, true); await expect(stakerContract.connect(addr1).execute()).to.be.revertedWith('Deadline is already reached'); }); it('external contract sucessfully completed', async () => { const amount = ethers.utils.parseEther('1'); await stakerContract.connect(addr1).stake({ value: amount, }); await stakerContract.connect(addr1).execute(); // check that the external contract is completed const completed = await exampleExternalContract.completed(); expect(completed).to.equal(true); // check that the external contract has the staked amount in it's balance const externalContractBalance = await ethers.provider.getBalance(exampleExternalContract.address); expect(externalContractBalance).to.equal(amount); // check that the staking contract has 0 balance const contractBalance = await ethers.provider.getBalance(stakerContract.address); expect(contractBalance).to.equal(0); }); });
第一个测试:如果在质押金额没有达到阈值的情况下调用
execute()
函数,它将撤销交易并返回适当的错误信息。第二个测试:连续两次调用
execute()
函数,质押已经完成,交易应该被撤销,防止再次调用。第三个测试:在时间到 deadline 之后调用
execute()
函数。交易应该被撤销,因为只能在时间到 deadline 之前调用execute()
函数。最后一个测试:如果所有的要求都满足,那么
execute()
函数不会回退,并且所有都如预期一样。在函数调用外部合约后,completed
变量应该是true
,外部合约balance
应该等于用户的质押金额,我们的合约余额应该等于0
(已经将所有的余额转移到外部合约中)。
如果一切正常,运行 yarn test
应该会有这样的输出:
完整测试代码
下面我们来看看整个测试代码:
const {ethers} = require('hardhat'); const {use, expect} = require('chai'); const {solidity} = require('ethereum-waffle'); use(solidity); // Utilities methods const increaseWorldTimeInSeconds = async (seconds, mine = false) => { await ethers.provider.send('evm_increaseTime', [seconds]); if (mine) { await ethers.provider.send('evm_mine', []); } }; describe('Staker dApp', () => { let owner; let addr1; let addr2; let addrs; let stakerContract; let exampleExternalContract; let ExampleExternalContractFactory; beforeEach(async () => { // Deploy ExampleExternalContract contract ExampleExternalContractFactory = await ethers.getContractFactory('ExampleExternalContract'); exampleExternalContract = await ExampleExternalContractFactory.deploy(); // Deploy Staker Contract const StakerContract = await ethers.getContractFactory('Staker'); stakerContract = await StakerContract.deploy(exampleExternalContract.address); // eslint-disable-next-line no-unused-vars [owner, addr1, addr2, ...addrs] = await ethers.getSigners(); }); describe('Test contract utils methods', () => { it('timeLeft() return 0 after deadline', async () => { await increaseWorldTimeInSeconds(180, true); const timeLeft = await stakerContract.timeLeft(); expect(timeLeft).to.equal(0); }); it('timeLeft() return correct timeleft after 10 seconds', async () => { const secondElapsed = 10; const timeLeftBefore = await stakerContract.timeLeft(); await increaseWorldTimeInSeconds(secondElapsed, true); const timeLeftAfter = await stakerContract.timeLeft(); expect(timeLeftAfter).to.equal(timeLeftBefore.sub(secondElapsed)); }); }); describe('Test stake() method', () => { it('Stake event emitted', async () => { const amount = ethers.utils.parseEther('0.5'); await expect( stakerContract.connect(addr1).stake({ value: amount, }), ) .to.emit(stakerContract, 'Stake') .withArgs(addr1.address, amount); // Check that the contract has the correct amount of ETH we just sent const contractBalance = await ethers.provider.getBalance(stakerContract.address); expect(contractBalance).to.equal(amount); // Check that the contract has stored in our balances state the correct amount const addr1Balance = await stakerContract.balances(addr1.address); expect(addr1Balance).to.equal(amount); }); it('Stake 0.5 ETH from single user', async () => { const amount = ethers.utils.parseEther('0.5'); const tx = await stakerContract.connect(addr1).stake({ value: amount, }); await tx.wait(); // Check that the contract has the correct amount of ETH we just sent const contractBalance = await ethers.provider.getBalance(stakerContract.address); expect(contractBalance).to.equal(amount); // Check that the contract has stored in our balances state the correct amount const addr1Balance = await stakerContract.balances(addr1.address); expect(addr1Balance).to.equal(amount); }); it('Stake reverted if deadline is reached', async () => { // Let deadline be reached await increaseWorldTimeInSeconds(180, true); const amount = ethers.utils.parseEther('0.5'); await expect( stakerContract.connect(addr1).stake({ value: amount, }), ).to.be.revertedWith('Deadline is already reached'); }); it('Stake reverted if external contract is completed', async () => { const amount = ethers.utils.parseEther('1'); // Complete the stake process const txStake = await await stakerContract.connect(addr1).stake({ value: amount, }); await txStake.wait(); // execute it const txExecute = await stakerContract.connect(addr1).execute(); await txExecute.wait(); await expect( stakerContract.connect(addr1).stake({ value: amount, }), ).to.be.revertedWith('staking process already completed'); }); }); describe('Test execute() method', () => { it('execute reverted because stake amount not reached threshold', async () => { await expect(stakerContract.connect(addr1).execute()).to.be.revertedWith('Threshold not reached'); }); it('execute reverted because external contract already completed', async () => { const amount = ethers.utils.parseEther('1'); await stakerContract.connect(addr1).stake({ value: amount, }); await stakerContract.connect(addr1).execute(); await expect(stakerContract.connect(addr1).execute()).to.be.revertedWith('staking process already completed'); }); it('execute reverted because deadline is reached', async () => { // reach the deadline await increaseWorldTimeInSeconds(180, true); await expect(stakerContract.connect(addr1).execute()).to.be.revertedWith('Deadline is already reached'); }); it('external contract sucessfully completed', async () => { const amount = ethers.utils.parseEther('1'); await stakerContract.connect(addr1).stake({ value: amount, }); await stakerContract.connect(addr1).execute(); // it seems to be a waffle bug see https://github.com/EthWorks/Waffle/issues/469 // test that our Stake Contract has successfully called the external contract's complete function // expect('complete').to.be.calledOnContract(exampleExternalContract); // check that the external contract is completed const completed = await exampleExternalContract.completed(); expect(completed).to.equal(true); // check that the external contract has the staked amount in it's balance const externalContractBalance = await ethers.provider.getBalance(exampleExternalContract.address); expect(externalContractBalance).to.equal(amount); // check that the staking contract has 0 balance const contractBalance = await ethers.provider.getBalance(stakerContract.address); expect(contractBalance).to.equal(0); }); }); describe('Test withdraw() method', () => { it('Withdraw reverted if deadline is not reached', async () => { await expect(stakerContract.connect(addr1).withdraw(addr1.address)).to.be.revertedWith( 'Deadline is not reached yet', ); }); it('Withdraw reverted if external contract is completed', async () => { // Complete the stake process const txStake = await stakerContract.connect(addr1).stake({ value: ethers.utils.parseEther('1'), }); await txStake.wait(); // execute it const txExecute = await stakerContract.connect(addr1).execute(); await txExecute.wait(); // Let time pass await increaseWorldTimeInSeconds(180, true); await expect(stakerContract.connect(addr1).withdraw(addr1.address)).to.be.revertedWith( 'staking process already completed', ); }); it('Withdraw reverted if address has no balance', async () => { // Let time pass await increaseWorldTimeInSeconds(180, true); await expect(stakerContract.connect(addr1).withdraw(addr1.address)).to.be.revertedWith( "You don't have balance to withdraw", ); }); it('Withdraw success!', async () => { // Complete the stake process const amount = ethers.utils.parseEther('1'); const txStake = await stakerContract.connect(addr1).stake({ value: amount, }); await txStake.wait(); // Let time pass await increaseWorldTimeInSeconds(180, true); const txWithdraw = await stakerContract.connect(addr1).withdraw(addr1.address); await txWithdraw.wait(); // Check that the balance of the contract is 0 const contractBalance = await ethers.provider.getBalance(stakerContract.address); expect(contractBalance).to.equal(0); // Check that the balance of the user is 1 await expect(txWithdraw).to.changeEtherBalance(addr1, amount); }); }); });
你是否注意到,测试代码的覆盖率远远大于合约本身?这就是我们想看到的 !测试所有的东西!
本翻译由 CellETF[8] 赞助支持。
来源:https://stermi.medium.com/how-to-write-your-first-decentralized-app-scaffold-eth-challenge-1-staking-dapp-b0b6a6f4d242
参考资料
[1]
登链翻译计划 :https://github.com/lbc-team/Pioneer
[2]
翻译小组 :https://learnblockchain.cn/people/412
[3]
Tiny 熊 :https://learnblockchain.cn/people/15
[4]
Waffle:https://ethereum-waffle.readthedocs.io/en/latest/index.html
[5]
Mocha:https://mochajs.org/
[6]
Chai:https://www.chaijs.com/
[7]
Chai 匹配器 :https://ethereum-waffle.readthedocs.io/en/latest/matchers.html
[8]
CellETF:https://celletf.io/?utm_souce=learnblockchain