• 译文出自:登链翻译计划 [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)获取资金(这也是一种交易)。好吧,为了解决这个问题,我写了一个小程序(你可以直接复制到其他项目中)。

scaffold-eth 挑战:测试覆盖率(Part3)

当你调用 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 应该会有这样的输出:

scaffold-eth 挑战:测试覆盖率(Part3)

完整测试代码

下面我们来看看整个测试代码:

    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

scaffold-eth 挑战:测试覆盖率(Part3)