Testando contratos inteligentes usando Hardhat e Chai
*Caso você tenha perdido, Tutorial de cultivo de rendimento - Parte 1.
Introdução
Como desenvolvedor do Solidity, você passará a maior parte do tempo testando seus contratos. Eu vejo meu código de contrato inteligente como uma tese. Os testes que crio culminam nas evidências que sustentam esta tese. Essa postura me permite garantir a validade da minha lógica, em vez de apenas fazê-la funcionar. Reservar um tempo para criar testes inteligentes desde o início poupa você (e/ou sua equipe) de futuras dívidas técnicas.
Antes de começarmos a testar contratos inteligentes usando Hardhat e Chai, gostaria de sugerir que você teste seu código enquanto o escreve. Eu escolhi separar a escrita e o teste dos nossos contratos inteligentes para fins de organização deste tutorial. Testar unitariamente as funções à medida que você as escreve pode economizar muito tempo de refatoração no futuro - posso atestar isso com certeza.
Avante!
Configurar
Em seu diretório raiz, crie uma pasta de teste e um arquivo de teste:
mkdir test
touch test/pmknFarm.test.ts
Vamos lançar nossas importações:
import { ethers } from "hardhat";
import { expect } from "chai";
import { Contract, BigNumber } from "ethers";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { time } from "@openzeppelin/test-helpers";
Hardhat já incorpora uma biblioteca Ethers especificamente para o tempo de execução do Hardhat. Estamos importando expect do Chai como nossa principal ferramenta de teste. Como estamos declarando tipos com TypeScript, incluiremos os tipos de importação Contract e BigNumber da biblioteca Ethers. Vamos declarar as contas do proprietário, Alice e Bob com o SignerWithAddress. Por fim, vamos importar o tempo dos auxiliares de teste do OpenZeppelin.
Em seguida, vamos declarar nossas variáveis constantes:
describe("PmknFarm", () => {
let owner: SignerWithAddress;
let alice: SignerWithAddress;
let bob: SignerWithAddress;
let res: any;
let pmknFarm: Contract;
let pmknToken: Contract;
let mockDai: Contract;
const daiAmount: BigNumber = ethers.utils.parseEther("25000");
});
O primeiro describe atua como um guarda-chuva para a instância de teste. Owner, Alice, e Bob compõem nossos agentes criptográficos durante esses testes. Eu incluí res (como em resultado) para me salvar de redeclarar a mesma variável repetidamente. Nossos contratos necessários vêm a seguir. Observe que eles são declarados no formato camelCasing. Finalmente, temos daiAmount, que usaremos para financiar os saldos do MockDai de nosso ator.
beforeEach(async() => {
const PmknFarm = await ethers.getContractFactory("PmknFarm");
const PmknToken = await ethers.getContractFactory("PmknToken");
const MockDai = await ethers.getContractFactory("MockERC20");
mockDai = await MockDai.deploy("MockDai", "mDAI");
[owner, alice, bob] = await ethers.getSigners();
await Promise.all([
mockDai.mint(owner.address, daiAmount),
mockDai.mint(alice.address, daiAmount),
mockDai.mint(bob.address, daiAmount)
]);
pmknToken = await PmknToken.deploy();
pmknFarm = await PmknFarm.deploy(mockDai.address, pmknToken.address);
})
Ao testar contratos inteligentes, o Chai permite várias configurações diferentes. As duas que eu mais uso incluem os ganchos before() e beforeEach () . O gancho beforeEach() executa todo o bloco de código antes de cada caso de teste. Isso fornece uma cobertura excelente para testes de unidades menores. Você pode economizar algum tempo com testes se usar o gancho before(), como o bloco de código é executado apenas uma vez antes do primeiro teste. Todos os testes subsequentes compartilham o mesmo estado. Em outras palavras, se você enviar 5 ETH de Alice para Bob no caso de teste nº 1 e não movê-lo, Bob ainda manterá os 5 ETH em todos os casos de teste subsequentes dentro do guarda-chuva inicial describe mencionado acima.
Primeiro, buscamos as fábricas de contrato de nossos contratos e as armazenamos em declarações PascalCasing. Em seguida, armazenamos o contrato MockDai implantado no mockDai (conforme declarado anteriormente) e declaramos nossos signatários. Como estamos usando nosso contrato mockDai local, podemos cunhar mDAI para nossos atores usarem (no lugar do DAI real). Finalmente, inicialize os contratos pmknToken e pmknFarm. Precisamos do endereço do contrato MockDai e PmknToken no construtor do PmknFarm; portanto, certifique-se de implantá-los antes da instância pmknFarm.
A etapa final de nossa configuração inclui a criação de um arquivo de configuração TypeScript.
Sem esse arquivo, você encontrará um erro informando que "chai" só pode ser importado por padrão usando o sinalizador 'esModuleInterop'. Isso tem a ver com falhas de suposição no TypeScript. Leia mais sobre este assunto no Documentos TS.
Na raiz do projeto, digite: touch tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"resolveJsonModule": true,
"esModuleInterop": true
}
}
Insira este JSON no arquivo.
Agora, estamos prontos para testar nosso código.
Casos de teste
Inicializar
Vamos criar nosso primeiro caso de teste describe e chamá-lo de Init. Antes de se aprofundar nos testes, encorajo você a garantir a precisão de sua configuração de teste. Adicione uma declaração it e, em seguida, teste se os contratos foram implantados sem erros. Seu código até agora deve ficar assim:
import { ethers } from "hardhat";
import chai, { expect, use} from "chai";
import { Contract, BigNumber } from "ethers";
import { solidity } from "ethereum-waffle";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
describe("PmknFarm", () => {
let owner: SignerWithAddress;
let alice: SignerWithAddress;
let bob: SignerWithAddress;
let res: any;
let pmknFarm: Contract;
let pmknToken: Contract;
let mockDai: Contract;
const daiAmount: BigNumber = ethers.utils.parseEther("25000");
beforeEach(async() => {
const PmknFarm = await ethers.getContractFactory("PmknFarm");
const PmknToken = await ethers.getContractFactory("PmknToken");
const MockDai = await ethers.getContractFactory("MockERC20");
mockDai = await MockDai.deploy("MockDai", "mDAI");
[owner, alice, bob] = await ethers.getSigners();
await Promise.all([
mockDai.mint(owner.address, daiAmount),
mockDai.mint(alice.address, daiAmount),
mockDai.mint(bob.address, daiAmount)
]);
pmknToken = await PmknToken.deploy();
pmknFarm = await PmknFarm.deploy(mockDai.address, pmknToken.address);
})
describe("Init", async() => {
it("should initialize", async() => {
expect(pmknToken).to.be.ok
expect(pmknFarm).to.be.ok
expect(mockDai).to.be.ok
})
})
})
No seu terminal, na raiz do projeto, digite: npx hardhat test
Se você configurar tudo corretamente, deverá ver a palavra verde indutora de dopamina: passing.
Eu geralmente organizo meus testes por funções - ou seja, cada describe testa uma função específica e os efeitos colaterais nela contidos. Vou fornecer alguns dos testes essenciais para você começar. Vamos começar com a função stake().
Stake()
describe("Stake", async() => {
it("should accept DAI and update mapping", async() => {
let toTransfer = ethers.utils.parseEther("100")
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
expect(await pmknFarm.isStaking(alice.address))
.to.eq(false)
expect(await pmknFarm.connect(alice).stake(toTransfer))
.to.be.ok
expect(await pmknFarm.stakingBalance(alice.address))
.to.eq(toTransfer)
expect(await pmknFarm.isStaking(alice.address))
.to.eq(true)
})
it("should update balance with multiple stakes", async() => {
let toTransfer = ethers.utils.parseEther("100")
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
await pmknFarm.connect(alice).stake(toTransfer)
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
await pmknFarm.connect(alice).stake(toTransfer)
expect(await pmknFarm.stakingBalance(alice.address))
.to.eq(ethers.utils.parseEther("200"))
})
it("should revert with not enough funds", async() => {
let toTransfer = ethers.utils.parseEther("1000000")
await mockDai.approve(pmknFarm.address, toTransfer)
await expect(pmknFarm.connect(bob).stake(toTransfer))
.to.be.revertedWith("You cannot stake zero tokens")
})
})
O primeiro teste em nosso caso de teste stake() verifica se nossa função funciona conforme o esperado. O segundo teste verifica um caso extremo — o que você deve testar sempre. Se o usuário fizer stake várias vezes, o stakingBalance total refletirá o saldo correto? Por fim, testamos se a função reverte com precisão. Eu encorajo você a adicionar alguns de seus próprios testes. Algumas ideias:
- it(“deve reverter o stake sem subsídio”)
- it(“deve reverter o stake com zero como valor em stake”)
*Lembre-se de executar o hardhat npx
após cada teste!
Retirar stake()
describe("Unstake", async() => {
beforeEach(async() => {
let toTransfer = ethers.utils.parseEther("100")
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
await pmknFarm.connect(alice).stake(toTransfer)
})
it("should unstake balance from user", async() => {
let toTransfer = ethers.utils.parseEther("100")
await pmknFarm.connect(alice).unstake(toTransfer)
res = await pmknFarm.stakingBalance(alice.address)
expect(Number(res))
.to.eq(0)
expect(await pmknFarm.isStaking(alice.address))
.to.eq(false)
})
})
A função unstake() não oferece muitos efeitos colaterais a serem observados. Aqui, testamos que o saldo volta a zero ao retirar todo o valor em stake. Deixei alguns testes importantes para você fazer sozinho:
- it(“deve mostrar o saldo correto ao retirar parte do saldo em stake”)
- it(“o mapeamento isStaking deve ser igual a true quando parcialmente retirado”)
WithdrawYield()
describe("WithdrawYield", async() => {
beforeEach(async() => {
await pmknToken._transferOwnership(pmknFarm.address)
let toTransfer = ethers.utils.parseEther("10")
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
await pmknFarm.connect(alice).stake(toTransfer)
})
it("should return correct yield time", async() => {
let timeStart = await pmknFarm.startTime(alice.address)
expect(Number(timeStart))
.to.be.greaterThan(0)
// Fast-forward time
await time.increase(86400)
expect(await pmknFarm.calculateYieldTime(alice.address))
.to.eq((86400))
})
it("should mint correct token amount in total supply and user", async() => {
await time.increase(86400)
let _time = await pmknFarm.calculateYieldTime(alice.address)
let formatTime = _time / 86400
let staked = await pmknFarm.stakingBalance(alice.address)
let bal = staked * formatTime
let newBal = ethers.utils.formatEther(bal.toString())
let expected = Number.parseFloat(newBal).toFixed(3)
await pmknFarm.connect(alice).withdrawYield()
res = await pmknToken.totalSupply()
let newRes = ethers.utils.formatEther(res)
let formatRes = Number.parseFloat(newRes).toFixed(3).toString()
expect(expected)
.to.eq(formatRes)
res = await pmknToken.balanceOf(alice.address)
newRes = ethers.utils.formatEther(res)
formatRes = Number.parseFloat(newRes).toFixed(3).toString()
expect(expected)
.to.eq(formatRes)
})
it("should update yield balance when unstaked", async() => {
await time.increase(86400)
await pmknFarm.connect(alice).unstake(ethers.utils.parseEther("5"))
res = await pmknFarm.pmknBalance(alice.address)
expect(Number(ethers.utils.formatEther(res)))
.to.be.approximately(10, .001)
})
})
Testar a função WithdrawYield(() requer algumas etapas extras em nossa configuração. Como estamos automatizando a emissão do PmknToken, primeiro precisamos transferir a propriedade para o contrato PmknFarm.
Estamos testando o rendimento calculado que requer tempo para passar; portanto, utilizamos a função do OpenZeppelin ajudantes de teste (test-helpers) time() . Isso permite que nossos contratos inteligentes viajem no tempo. A função time() recebe 86400 como seu argumento (o mesmo 86400 codificado rigidamente na função calculateYieldTotal()). No primeiro teste, verificamos que o tempo total passado equivale a 86400.
pragma solidity 0.8.4;
function calculateYieldTime(address user) public view returns(uint256){
uint256 end = block.timestamp;
uint256 totalTime = end - startTime[user];
return totalTime;
}
Se você se lembra do tutorial inicial do contrato inteligente, demos à função calculateYieldTime() uma visibilidade pública. Esta é a razão.
No segundo teste, garantimos a validade de nossa matemática imitando os cálculos usando TypeScript. Depois disso, chamamos a função pullYield() e verificamos se o suprimento total de PmknToken, bem como o saldo de PmknToken de Alice, equivale ao mesmo depósito inicial apostado. Os valores não corresponderão exatamente devido à exclusão do Solidity de números de ponto flutuante (e nossa solução alternativa para porcentagens); portanto, definimos o resultado formatado para um ponto flutuante fixo de três.
O terceiro teste verifica a precisão do rendimento não realizado do usuário ao retirar parte de sua DAI.
Conclusão
Este tutorial não deve ser visto como uma lista abrangente de testes; em vez disso, encorajo você a tomar isso como ponto de partida para expandir. Quanto mais desenvolvedores constroem em DeFi, mais fortes os protocolos se tornam. Espero que isso lhe tenha fornecido algum valor e fortalecido suas habilidades em desenvolvimento. Aqui estão os testes cumulativos que examinamos:
import { ethers } from "hardhat";
import { expect } from "chai";
import { Contract, BigNumber } from "ethers";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { time } from "@openzeppelin/test-helpers";
describe("PmknFarm", () => {
let owner: SignerWithAddress;
let alice: SignerWithAddress;
let bob: SignerWithAddress;
let res: any;
let pmknFarm: Contract;
let pmknToken: Contract;
let mockDai: Contract;
const daiAmount: BigNumber = ethers.utils.parseEther("25000");
beforeEach(async() => {
const PmknFarm = await ethers.getContractFactory("PmknFarm");
const PmknToken = await ethers.getContractFactory("PmknToken");
const MockDai = await ethers.getContractFactory("MockERC20");
mockDai = await MockDai.deploy("MockDai", "mDAI");
[owner, alice, bob] = await ethers.getSigners();
await Promise.all([
mockDai.mint(owner.address, daiAmount),
mockDai.mint(alice.address, daiAmount),
mockDai.mint(bob.address, daiAmount)
]);
pmknToken = await PmknToken.deploy();
pmknFarm = await PmknFarm.deploy(mockDai.address, pmknToken.address);
})
describe("Init", async() => {
it("should initialize", async() => {
expect(await pmknToken).to.be.ok
expect(await pmknFarm).to.be.ok
expect(await mockDai).to.be.ok
})
})
describe("Stake", async() => {
it("should accept DAI and update mapping", async() => {
let toTransfer = ethers.utils.parseEther("100")
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
expect(await pmknFarm.isStaking(alice.address))
.to.eq(false)
expect(await pmknFarm.connect(alice).stake(toTransfer))
.to.be.ok
expect(await pmknFarm.stakingBalance(alice.address))
.to.eq(toTransfer)
expect(await pmknFarm.isStaking(alice.address))
.to.eq(true)
})
it("should update balance with multiple stakes", async() => {
let toTransfer = ethers.utils.parseEther("100")
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
await pmknFarm.connect(alice).stake(toTransfer)
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
await pmknFarm.connect(alice).stake(toTransfer)
expect(await pmknFarm.stakingBalance(alice.address))
.to.eq(ethers.utils.parseEther("200"))
})
it("should revert with not enough funds", async() => {
let toTransfer = ethers.utils.parseEther("1000000")
await mockDai.approve(pmknFarm.address, toTransfer)
await expect(pmknFarm.connect(bob).stake(toTransfer))
.to.be.revertedWith("You cannot stake zero tokens")
})
})
describe("Unstake", async() => {
beforeEach(async() => {
let toTransfer = ethers.utils.parseEther("100")
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
await pmknFarm.connect(alice).stake(toTransfer)
})
it("should unstake balance from user", async() => {
let toTransfer = ethers.utils.parseEther("100")
await pmknFarm.connect(alice).unstake(toTransfer)
res = await pmknFarm.stakingBalance(alice.address)
expect(Number(res))
.to.eq(0)
expect(await pmknFarm.isStaking(alice.address))
.to.eq(false)
})
})
describe("WithdrawYield", async() => {
beforeEach(async() => {
await pmknToken._transferOwnership(pmknFarm.address)
let toTransfer = ethers.utils.parseEther("10")
await mockDai.connect(alice).approve(pmknFarm.address, toTransfer)
await pmknFarm.connect(alice).stake(toTransfer)
})
it("should return correct yield time", async() => {
let timeStart = await pmknFarm.startTime(alice.address)
expect(Number(timeStart))
.to.be.greaterThan(0)
// Fast-forward time
await time.increase(86400)
expect(await pmknFarm.calculateYieldTime(alice.address))
.to.eq((86400))
})
it("should mint correct token amount in total supply and user", async() => {
await time.increase(86400)
let _time = await pmknFarm.calculateYieldTime(alice.address)
let formatTime = _time / 86400
let staked = await pmknFarm.stakingBalance(alice.address)
let bal = staked * formatTime
let newBal = ethers.utils.formatEther(bal.toString())
let expected = Number.parseFloat(newBal).toFixed(3)
await pmknFarm.connect(alice).withdrawYield()
res = await pmknToken.totalSupply()
let newRes = ethers.utils.formatEther(res)
let formatRes = Number.parseFloat(newRes).toFixed(3).toString()
expect(expected)
.to.eq(formatRes)
res = await pmknToken.balanceOf(alice.address)
newRes = ethers.utils.formatEther(res)
formatRes = Number.parseFloat(newRes).toFixed(3).toString()
expect(expected)
.to.eq(formatRes)
})
it("should update yield balance when unstaked", async() => {
await time.increase(86400)
await pmknFarm.connect(alice).unstake(ethers.utils.parseEther("5"))
res = await pmknFarm.pmknBalance(alice.address)
expect(Number(ethers.utils.formatEther(res)))
.to.be.approximately(10, .001)
})
})
})
Definitivamente, entre em contato se tiver alguma dúvida. Obrigado por ler!
Parte 3: Implantando Contratos Inteligentes com Hardhat
*Dicas são muito apreciadas!
Endereço ETH: 0xD300fAeD55AE89229f7d725e0D710551927b5B15
Este artigo foi escrito por Andrew Flaming e traduzido por Diogo Jorge. O artigo original pode ser encontrado aqui.
Top comments (0)