No artigo anterior desta série, vimos como construir um contrato inteligente usando o Solidity que gerenciava uma troca de casas entre dois usuários.
Neste artigo, vamos aprender como testar o contrato usando Hardhat e TypeScript.
Preparando o Hardhat
Antes de instalar o Hardhat, você precisa instalar o Node em seu computador. Você pode ver como instalar o Node neste link. É um processo simples.
Depois de ter o Node instalado, você está pronto para instalar o Hardhat. Vamos fazer isso seguindo as próximas etapas:
Inicialize um projeto Node
Para inicializar um projeto Node, crie uma pasta na qual deseja armazenar seu projeto, entre na nova pasta e então inicie um projeto Node.
mkdir <myprojectfolder>
cd <myprojectfolder>
npm init
Instale o Hardhat
Para instalar o Hardhat, continue na pasta do seu projeto e execute os seguintes comandos:
npm install --save-dev hardhat
npx hardhat init
O primeiro comando irá instalar o Hardhat como uma dependência de desenvolvimento no seu projeto e o próximo iniciará um projeto Hardhat.
Escolha Create an empty hardhat.config.js após executar o comando
hardhat init
.
Agora que você iniciou um projeto Hardhat, mova o contrato Solidity (o arquivo com extensão .sol) para o diretório contracts. Se esse diretório não existir, crie-o.
Instale o TypeScript
Para instalar e usar o TypeScript, você terá que instalar o plugin @nomicfoundation/hardhat-toolbox
fornecido pelo Hardhat. Esse plugin contém todos os recursos comuns para trabalhar com o Hardhat. Você pode aprender mais sobre ele aqui.
Após instalar este plugin, você estará pronto para usar o TypeScript. Primeiro, encontre o arquivo hardhat.config.js
na pasta raiz do seu projeto e altere seu nome para hardhat.config.ts
.
mv hardhat.config.js hardhat.config.ts
Após fazer isso, você terá que alterar o conteúdo para adaptá-lo ao TypeScript. Você pode copiar o conteúdo do projeto no GitHub ou configurá-lo conforme necessário para o seu projeto específico.
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@typechain/hardhat"
const config: HardhatUserConfig = {
solidity: "0.8.19",
};
export default config;
Como você pode ver no código acima, importamos
hardhat-toolbox
e tambémtypechain/hardhat
, mas o que é o Typechain? O Typechain é uma biblioteca que, basicamente, gera tipos TypeScript para seus contratos e suas funções. Isso é realmente útil, pois você não precisa se lembrar dos nomes das funções do contrato. Eles serão listados pelo seu IDE como se fossem métodos de uma classe.
Nas próximas seções, veremos como gerar esses tipos e usá-los.
Finalmente, você só precisa criar um arquivo chamado tsconfig.json
na pasta raiz do seu projeto e colar o seguinte conteúdo:
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
}
Escreva os testes
Os testes devem estar localizados no diretório test (teste). Portanto, se ele não existir, crie a pasta dentro da raiz do seu projeto. Em seguida, acesse essa nova pasta e crie um arquivo chamado HouseSwap.ts.
Este arquivo conterá nossos testes de contrato. Neste artigo, vamos mostrar e explicar cada teste separadamente. Verifique o projeto no GitHub para ver o arquivo completo.
Importações e variáveis comuns para todos os testes.
import { expect } from "chai";
import { ethers } from "hardhat";
import { HouseSwap } from "../typechain-types";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
let houseSwapContract: HouseSwap;
let owner: SignerWithAddress;
let addr1: SignerWithAddress;
let addr2: SignerWithAddress;
let targetAddress: string;
let targetAddress2: string;
let house: HouseSwap.HouseStruct;
let houseToSwap: HouseSwap.HouseStruct;
let amountPayOriginToTarget: number;
let amountPayTargetToOrigin: number;
beforeEach(async function() {
[owner, addr1, addr2] = await ethers.getSigners();
targetAddress = await addr1.getAddress();
targetAddress2 = await addr2.getAddress();
house = {houseType: "semi-detached house", value: 16698645, link: "https://example.com", propietary: targetAddress};
houseToSwap = {houseType: "duplex", value: 16698645, link: "https://example2.com", propietary: targetAddress}
amountPayOriginToTarget = 0;
amountPayTargetToOrigin = 0;
houseSwapContract = await ethers.deployContract("HouseSwap") as HouseSwap;
});
Vamos começar com as importações. Como podemos ver, a primeira importação é da Chai. Chai é uma biblioteca de assertiva JavaScript (que é incluída na instalação do Hardhat).
Como um executor de testes, o Hardhat usa o Mocha, que também está incluído na instalação do Hardhat. O Mocha é chamado internamente pelo Hardhat quando você executa o conjunto de testes (veremos isso mais tarde).
A segunda importação é do ethers. O ethers é uma biblioteca para interação com a blockchain Ethereum e seu ecossistema. Neste artigo, vamos usar o ethers para obter os endereços de teste e implantar o contrato.
A terceira é realmente importante. É aqui que o Typechain entra em jogo. Essa linha importa o HouseSwap do diretório typechain-types. O tipo HouseSwap contém todos os tipos de contrato e funções mapeadas para tipos TypeScript. Então, como podemos gerar tal pasta? Vamos fazer uma pausa aqui e mostrar isso.
Como importamos o Typechain em nosso arquivo hardhat.config.ts, essa pasta será gerada automaticamente após a compilação do contrato.
npx hardhat compile
Se você importar o Typechain após compilar o contrato, será necessário limpá-lo antes de compilar novamente para gerar a pasta Typechain.
npx hardhat clean
npx hardhat compile
Voltando para as importações, a última importação traz a classe SignerWithAddress. Vamos atribuir esse tipo às variáveis que usaremos como endereços, já que os endereços são obtidos usando o método getSigners do ethers e os endereços retornados por esse método são instâncias de SignerWithAddress.
Agora, vamos avançar para a próxima parte do código. Lá, podemos ver a função beforeEach. Essa função é executada antes da execução de cada teste. Neste caso, essa função cria um conjunto de variáveis que estarão disponíveis para todos os testes e implanta o contrato para que possamos invocar suas funções. Vamos explicar essas variáveis uma por uma:
- [owner, addr1, addr2]: estes são os endereços que vamos usar para nos comunicarmos com o contrato. O endereço owner (do proprietário) será o que implantará o contrato.
- targetAddress e targetAddress2: estas variáveis mantêm os endereços brutos de addr1 e addr2. Quando enviamos um endereço como um parâmetro de função para o Solidity, precisamos enviá-lo como uma string.
- house e houseToSwap: estas variáveis são do tipo HouseSwap.HouseStruct. Esse tipo contém as informações da casa que nosso contrato espera como parâmetro em algumas de suas funções.
- amountPayOriginToTarget: esta é uma variável numérica que especifica se a origem tem que pagar alguma quantia ao destino.
- amountPayTargetToOrigin: esta é uma variável numérica que especifica se o destino tem que pagar alguma quantia à origem.
Após declarar e inicializar as variáveis, a função beforeEach implanta o contrato usando o ethers. Após ser implantado, obtemos um tipo HouseSwap (graças ao Typechain) que contém todas as funções mapeadas do contrato.
Espera-se que o estado inicial seja PENDING (pendente).
it("Expects initial status to be P", async function () {
expect(await houseSwapContract.getStatus()).to.equal(0);
})
Este teste é bastante simples. Ele apenas garante que o status do contrato seja PENDENTE logo após o contrato ser implantado. Se voltarmos ao código do contrato, poderemos ver que o contrato define esse estado no construtor (que é executado quando o contrato é implantado).
Deve rejeitar a adição de uma nova oferta, já que o contrato não foi inicializado.
await expect(houseSwapContract.connect(addr1).addOffer(house, amountPayOriginToTarget, amountPayTargetToOrigin)).to.be.revertedWith('An offer has been already accepted or contract has not been initialized');
Neste caso, garantimos que a função do contrato reverta, pois o contrato não foi inicializado e ainda não podemos enviar uma oferta.
Vamos analisar o uso do método
connect
. Ele nos permite alterar o chamador do contrato.
Deve emitir um evento NewOffer após enviar uma oferta.
await houseSwapContract.initialize(house);
expect(await houseSwapContract.getStatus()).to.equal(1);
await expect(houseSwapContract.connect(addr1).addOffer(house, amountPayOriginToTarget, amountPayTargetToOrigin)).to.emit(houseSwapContract, "NewOffer");
Neste teste, verificamos dois casos.
- Certificamos-nos de que o estado do contrato mantenha o valor INITIALIZED (inicializado) após chamar a função initialize.
- Em seguida, certificamo-nos de que um evento NewOffer é emitido após enviar uma oferta usando o método addOffer.
Não deve aceitar a oferta, pois apenas o proprietário pode aceitar uma oferta.
await houseSwapContract.initialize(house);
await expect(houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin)).to.emit(houseSwapContract, "NewOffer");
await expect(houseSwapContract.connect(addr2).acceptOffer(targetAddress, house, amountPayOriginToTarget, amountPayTargetToOrigin)).to.be.revertedWith('Required contract owner');
Neste teste, verificamos que uma oferta não pode ser aceita, pois o endereço que está chamando acceptOffer não é o proprietário do contrato.
Deve aceitar uma oferta e rejeitar outras.
await houseSwapContract.initialize(house);
await expect(houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin)).to.emit(houseSwapContract, "NewOffer");
houseSwapContract.acceptOffer(targetAddress, houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
expect(await houseSwapContract.getStatus()).to.equal(2);
await expect(houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin)).to.be.revertedWith('An offer has been already accepted or contract has not been initialized');
Neste caso, também testamos dois cenários:
- Inicializamos o contrato, enviamos uma oferta e a aceitamos. Em seguida, garantimos que o status do contrato mantenha o valor ACCEPTED (aceito).
- Em seguida, garantimos que se tentarmos enviar outra oferta, a função reverte, pois uma oferta já foi aceita.
Não deve realizar a troca porque o remetente não é quem enviou a oferta.
await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
await expect(houseSwapContract.connect(addr2).performSwap()).to.be.revertedWith('Only target user can confirm swap');
Neste caso, adicionamos uma oferta e o proprietário a aceita. Em seguida, quando a função performSwap é chamada e o remetente não é o mesmo que enviou a oferta aceita, a função reverte.
Deve realizar a troca sem transferências extras.
await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, amountPayOriginToTarget, amountPayTargetToOrigin);
await houseSwapContract.connect(addr1).performSwap();
expect(await houseSwapContract.getStatus()).to.equal(3);
Neste teste, garantimos que o contrato altera o status para o valor FINISHED (finalizado) após a realização da troca. Como amountPayOriginToTarget e amountPayTargetToOrigin têm valor 0, não é necessário enviar transferências.
O depósito deve falhar, pois o proprietário deve enviar fundos.
await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, 1, amountPayTargetToOrigin);
await expect(houseSwapContract.connect(addr1).deposit({ value: ethers.utils.parseEther('1') })).to.be.revertedWith("Origin must deposit enougth funds");
Agora, testamos a função de depósito. Neste caso, como amountPayOriginToTarget é maior que 0, a origem deve depositar ETH suficientes para que o contrato possa enviar a transferência. O teste irá reverter, pois não é o proprietário que tenta fazer o depósito.
O depósito deve falhar, pois o destinatário deve enviar fundos.
await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, amountPayOriginToTarget, 1);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, amountPayOriginToTarget, 1);
await expect(houseSwapContract.deposit({ value: ethers.utils.parseEther('1') })).to.be.revertedWith("Target must deposit enougth funds");
Este teste é o mesmo que o anterior, mas neste caso ele reverte porque o destinatário deveria ser quem envia o depósito.
O depósito do proprietário deve funcionar.
await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, 1, amountPayTargetToOrigin);
await expect(houseSwapContract.deposit({ value: ethers.utils.parseEther('1') })).to.emit(houseSwapContract, "BalanceUpdated");
Neste caso, como o depósito é enviado pelo endereço correto, ele é feito com sucesso e a função emite um evento BalanceUpdated.
A troca deve falhar porque os fundos depositados não são suficientes.
await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.deposit({ value: ethers.utils.parseEther('0.8') });
await expect(houseSwapContract.connect(addr1).performSwap()).to.be.revertedWith("Deposit has not been sent or is lower than required")
Neste teste, depositamos com sucesso 0,8 ETH, mas, como a origem deve enviar 1 ETH para o destinatário, o processo de troca reverte, pois não há fundos suficientes.
A troca, transferindo fundos da origem para o destinatário, deve ser realizada.
await houseSwapContract.initialize(house);
await houseSwapContract.connect(addr1).addOffer(houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.acceptOffer(targetAddress, houseToSwap, 1, amountPayTargetToOrigin);
await houseSwapContract.deposit({ value: ethers.utils.parseEther('1.05') });
await houseSwapContract.connect(addr1).performSwap();
expect(await houseSwapContract.getStatus()).to.equal(3);
Finalmente, como depositamos 1,05 ETH e a origem deve enviar 1 ETH para o destinatário, o processo de troca é realizado e o contrato muda seu status para FINISHED.
Executando os testes
Para executar os testes, primeiro é necessário compilar o contrato. Mostramos como fazer isso no início do artigo. Vamos lembrar aqui novamente:
npx hardhat compile
Depois que o contrato for compilado sem erros, é necessário executar o seguinte comando para rodar os testes. Neste ponto, o Hardhat utiliza o Mocha para executá-los (isso é transparente para nós).
npx hardhat test
Após a execução dos testes, você deverá ver a seguinte saída.
Conclusão
Este artigo, como uma continuação do anterior, mostrou como testar um contrato usando o Hardhat. Esses testes tentam garantir que as funções funcionem como esperado, mas também tentam testar se o contrato reverte quando necessário.
Após explicar os testes, aprendemos como usar o terminal do Hardhat para compilar e executar os testes.
No próximo artigo, aprenderemos como implantar o contrato em uma rede de teste e como chamar as funções do contrato.
Artigo escrito por Nacho Colomina Torregrosa. Traduzido por Marcelo Panegali.
Oldest comments (0)