Testes de Smart Contracts com HardHat

Cripto

Testes de Smart Contracts com HardHat

Luiz Duarte
Escrito por Luiz Duarte em 08/02/2024
Junte-se a mais de 34 mil devs

Entre para minha lista e receba conteúdos exclusivos e com prioridade

Uma das maiores vantagens de você usar um toolkit como o HardHat para apoiar o ciclo de desenvolvimento de projetos de smart contracts é poder contar com testes unitários automatizados. Como em todo tipo de software, buscar uma alta cobertura de testes unitários é metade do caminho para ter uma entrega de qualidade e especificamente no caso de smart contracts, pode ser metade do caminho para evitar um desastre também, já que muitas brechas de segurança podem ser detectadas nesta etapa do desenvolvimento.

No tutorial de hoje, eu vou focar em lhe mostrar os tipos de testes possíveis de serem implementados com o HardHat, fornecendo um guia que lhe auxilie na construção das mais variadas baterias de testes de smart contracts. Para que consiga acompanhar este tutorial você deve ter conhecimentos básicos de smart contracts Solidity, coisa que você aprende neste tutorial, e conhecimentos básicos do toolkit HardHat em si, que você aprende neste outro tutorial.

Caso prefira, você pode assistir ao vídeo abaixo ao invés de ler o tutorial, embora o texto vá ainda mais longe que o vídeo.

Vamos lá!

#1 – Ambiente de Testes

Eu não vou escrever com você um projeto novo de smart contract neste tutorial. Ao invés disso, passarei por todos os tipos de testes que já tive a oportunidade de fazer com este toolkit e pretendo ir atualizando este artigo conforme for aprendendo novos “truques”.

Você pode presumir para todos os testes que criamos um projeto do tipo TypeScript com HardHat, que nosso contrato está na pasta contracts e que nossos testes estão na pasta test. Além disso, todos os testes estão sendo executados na rede local nativa do HardHat, a HardHat Network que já expliquei em muito mais detalhes neste artigo.

Por fim, um conceito importante de ser entendido é o de fixture. Antes de cada teste é comum executarmos um script de setup da blockchain de testes através de uma função de fixture, como abaixo.

Repare que esta função serve para obter contas de teste e fazer o deploy de um contrato “zerado”, retornando estes objetos para uso dos testes. Claro, você ainda pode fazer qualquer outro tipo de setup aqui, como transferências, chamadas de funções e preparação de qualquer outro objeto. Olhe outro exemplo, mais complexo, que faz bem mais do que o anterior.

Note como fazemos o deploy de um token ERC-20 (CerberusCoin) para ser usados nos testes, depois de um protocolo DeFi (CerberusPay), depois usamos uma função do protocolo para definir qual moeda será usada e mintamos tokens para um usuário de teste, retornando todos os objetos que usaremos nos testes.

Independente se sua função de fixture é simples ou mais complexa, em todos os testes você deverá obter uma blockchain configurada através dela, através de um código parecido com o abaixo.

Mas Luiz, porque não chamamos a função deployFixture diretamente ao invés de usar loadFixture passando deployFixture?

Porque é uma fixture, ou seja, um template ou combinação de objetos a serem usados nos testes. As etapas da fixture não são todas executadas antes de cada teste ou eles demorariam demais para acontecer, elas são executadas uma vez, na primeira vez que loadFixture chamar ela, e mantida em cache para que seja usada antes de cada teste. Assim, cada teste terá uma cópia da fixture, mas não precisará executá-la do zero.

Entendidos estes conceitos fundamentais do ambiente de testes do HardHat, vamos aos cenários de testes.

Curso Node.js e MongoDB

#2 – Cenários de Sucesso

Primeiro vamos falar dos cenários de sucesso, que são os casos de uso mais comum de serem testados e que buscam verificar se a funcionalidade está, bem, funcionando como deveria.

Expect Simples

O mais comum dos testes é o que chamo de “expect simples”, onde você chama uma função, pega seu retorno e verifica se ele bate com um valor específico, como abaixo.

No teste acima, que é de um token ERC-20, verificamos se o nome da moeda está correto usando um expect com to.equal, fornecendo inclusive como segundo parâmetro a mensagem de erro personalizada, algo completamente opcional e que não costumo usar muito, mas vale a pena ser mencionado. Os expects de cenários de sucesso esperam sempre um valor por parâmetro, então atente-se ao uso de await quando chamar as funções do smart contract que está sendo testado. Outra variação do mesmo teste poderia ser como abaixo.

O resultado final do teste é o mesmo, mas com menos linhas de código.

Além do to.equal você pode fazer comparações como:

  • to.greaterThan
  • to.greaterThanOrEqual
  • to.lessThan
  • to.lessThanOrEqual

E uma infinidade de operadores para serem aplicados em resultados multivalorados (arrays) também. Você também vai reparar, navegando pelo autocomplete do VS Code vários sinônimos, ou seja, formas iguais de escrever os mesmos testes. Na prática, sendo bem sincero, para cenários de sucesso eu costumo usar apenas o to.equal em 90% dos casos, mas enfim, as opções estão aí.

Expect de Transações

Existem ocasiões em que você precisa testar transações na blockchain, ou seja, funções que escrevem na mesma e que portanto retornam sempre um recibo de transação e não um valor simples que pode ser comparado. Nesses casos você tem duas opções de como escrever os testes.

O jeito mais simples de conseguir fazer um expect que diga se a transação funcionou ou não é chamando uma função de leitura que verifique a variável que foi alterada pela transação, como abaixo, em um teste de transferência de saldo onde usamos a função de balance para ver se os saldos foram alterados. Além disso, o exemplo abaixo mostra a possibilidade de usar múltiplos expects, algo desejável em diversos cenários em que apenas uma variável não garante que o teste passou 100%.

Note que eu sequer pego o retorno da chamada de transfer, que é a função-alvo do nosso teste. Ao invés disso, eu pego os saldos antes e depois do “from” e do “to” e comparo eles para ver se as quantias estão corretas com um expect simples novamente. Isso geralmente é possível de fazer, mas caso você não possua funções de leitura que entreguem o dado atualizado que precisa, o caminho é a segunda opção.

A opção 2, testar se um evento foi emitido após a transação, é interessante em dois cenários: no cenário em que você tem essa opção (a função emite eventos) e no cenário que você deseja testar de fato se o evento está sendo emitido corretamente. Independente da situação, o exemplo de código abaixo mostra como testar se um determinado evento foi emitido após uma transação.

Este teste está ligeiramente reduzido pois é de um sistema bem complexo (uma DAO de condomínio), mas ilustra o expect de evento. Um expect de evento espera uma promise ao invés de um valor literal pois ele precisará capturar (catch) o resultado da transação usando o .to.emit, onde passamos no primeiro parâmetro o contrato e no segundo o nome do evento. Opcionalmente, você ainda pode usar o withArgs para incluir no expect os argumentos do evento (basta passar os valores em ordem).

Aqui vale um ponto de atenção: o expect deve receber uma promise, então não use await na função que estamos testando. No entanto, use await antes do expect pois ele precisa ser resolvido para o que teste seja dado como finalizado.

Curso Beholder
Curso Beholder

#3 – Cenários de Fracasso

Como nem tudo são flores, após escrever os testes de sucesso das suas funcionalidades do smart contract é hora de se preocupar com os cenários de fracasso ou de falha. Nestes cenários, o seu smart contract deve emitir um erro adequado, geralmente usando funções como require, revert e outras. Para testar esses erros de execução você deve sempre passar ao expect uma promise, para que ele consiga fazer o catch da exceção e a função de comparação será a revertedWith ou a revertedWithCustomError, dependendo da forma que o erro está sendo emitido pelo contrato.

Caso o erro seja emitido usando o tipo genérico, onde você apenas passa uma mensagem, o seu expect se parecerá como abaixo, onde deve informar exatamente a mensagem de erro completa que será emitida.

Note que devo colocar um await antes do expect a fim de que o teste somente termine quando a promise da função for finalizada com erro. Só para ajudar no entendimento, abaixo você encontra o trecho de código que dispara o erro anteriormente testado (é um contrato ERC-1155).

Agora se o erro emitido não é genérico mas sim um erro personalizado (custom error), você deverá usar o revertedWithCustomError, informando a instância do contrato no primeiro parâmetro e o nome exato do custom error no segundo, como abaixo.

Esse tipo de erro é muito usado em bibliotecas profissionais de smart contracts como a OpenZeppelin. Abaixo, exemplo de código onde o erro anteriormente capturado foi emitido, no smart contract da OpenZeppelin (omiti algumas partes que não agregam a este exemplo).

Em 99% dos testes de falhas em smart contracts você usará as duas funções acima.

#4 – Truques em Testes

Para fechar este artigo com chave de ouro, que tal eu passar alguns “truques” que uso na escrita dos meus testes? Nenhum deles é segredo, na verdade alguns provavelmente você já conhece, mas acho que vale a pena serem mencionados aqui para garantir que o conhecimento seja passado adiante.

Connect

Primeiro, você deve saber que tanto o deploy quanto a execução dos testes sempre são realizados em nome da conta owner, a primeira das contas de teste que a HardHat Network gera pra gente, certo? Mas e quando queremos impersonar outra carteira, ou seja, executar uma função em nome de outra conta que não o owner do contrato?

Neste caso devemos usar a função connect, presente no objeto contract, que permite passar um novo signer pra ela e terá como retorno uma nova instância do contrato. Esta nova instância tem as mesmas funções do contrato original, mas todas elas serão executadas como sendo da conta que você informou no connect.

Uma atenção especial em relação ao connect é que qualquer função de teste que exija a passagem da instância do contrato deverá usar instance ao invés de contract neste caso, como no próprio exemplo acima, em que estamos testando um erro na instância “connectada” ao invés do contract original, que não disparou erro algum.

ZeroAddress

Muitas vezes precisamos de um endereço zerado para realizar um teste, geralmente para simular um erro ou então para verificar um retorno zerado. Ao invés de escrever “0x0000000000000000000000000000000000000000” você pode usar a constante ethers.ZeroAddress, que tem exatamente esse valor definido, ficando muito mais elegante no seu código de teste, como abaixo.

Time

Eventualmente você terá de testar funcionalidades sensíveis ao tempo, como por exemplo cobranças de mensalidades ou timelocks (travas de tempo). Nesses casos você tem de ter um mecanismo que “viaje no tempo” para que a blockchain de testes esteja em uma data futura entre duas instruções de um teste. Pensando nisso o HardHat fornece um objeto chamado time que permite justamente manipulações do tempo da blockchain.

O primeiro passo para usar o time é importá-lo no módulo de testes, como abaixo.

Depois, você pode usá-lo através de uma série de funções como por exemplo a setNextBlockTimestamp, onde definimos o timestamp do próximo bloco que será registrado na blockchain, lembrando que a HardHat Network minera um bloco a cada transação, então essa função define o timestamp em que a próxima transação será registrada, permitindo por exemplo a simulação de dias no futuro, como abaixo.

Outra forma de fazer esse tipo de movimentação do tempo é com a função increase. A diferença entre as duas funções é que a primeira seta o timestamp específico do próximo bloco, enquanto que a função abaixo “aumenta” o timestamp em cima do atual.

Funções Auxiliares

Outra dica muito útil é na sua bateria de testes você ter funções auxiliares, ou seja, funções que realizam algumas atividades que auxiliem nos testes.

Mas Luiz, isso não deveria estar contemplado na função de fixture?

Não necessariamente, já que a função de fixture é o modelo GERAL de setup para os testes. Muitas vezes existem setups específicos de ALGUNS testes ou mesmo este setup pode exigir parametrização diferente para testes diferentes. Como exemplo trago abaixo uma função auxiliar de uma DAO, onde quero poder adicionar votos em alguns testes para conseguir simular o volume de votos que alguns cenários de teste exigem.

Assim, com uma função addVotes eu posso adicionar quantos votos eu quiser e com os parâmetros que quiser em cada teste individual sem comprometer os demais e sem ficar com um monte de linhas de código repetidas na minha bateria de testes.

E estas foram as lições de testes com HardHat de hoje, espero que tenha gostado. O próximo passo é você aprender como usar as ferramentas de Code Coverage da suíte de testes, o que ensino neste tutorial.

Até a próxima!

Olá, tudo bem?

O que você achou deste conteúdo? Conte nos comentários.

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *