Testes de Smart Contracts com HardHat v3

Web3 e Blockchain

Testes de Smart Contracts com HardHat v3

Luiz Duarte
Escrito por Luiz Duarte em 27/01/2026
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. Ajuda também se você souber o básico de TypeScript que ensino aqui.

Vamos lá!

Curso Web3 para Iniciantes

#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”. Vamos ver tanto os testes escritos com TypeScript quanto os escritos com Solidity, novidades na versão 3. Você não é obrigado a escrever os testes em ambas linguagens, pode escolher uma, mas é sempre interessante saber o básico nas duas se deseja trabalhar como desenvolvedor em alguma empresa.

Você pode presumir para todos os testes que criamos um projeto do tipo TypeScript com HardHat, que os contratos estão na pasta contracts (a partir do HH v3 temos o contrato e o contrato de testes) e que nossos testes TypeScript 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.

Testes em TypeScript

Os testes em TypeScript realizados neste tutorial podem ser acompanhados pelo vídeo abaixo, se preferir.

YouTube player

No topo do arquivo de testes TypeScript temos as seguintes configurações:

Aqui eu importo a função expect, que será usada nas asserções e o objeto network, que usaremos para simular a blockchain nos testes. Com ele, fazemos um connect para obter uma instância da Ethers JS, a biblioteca JS/TS para comunicação com a blockchain nos testes destas linguagens. Por fim, usando esse objeto, conseguimos pegar algumas carteiras para os testes (owner e otherAccount).

Aí para cada teste teremos a seguinte estrutura:

Esse é um pseudo-código, então não se apegue aos nomes de objetos e valores usados. Todo teste começa pelo deploy do contrato e o uso dessa nova instância para chamar a função a ser testada. Dependendo da função ela pode ter retorno (call) ou não (transaction), sendo que no exemplo acima é uma transaction, logo eu uso uma call depois para checar se tudo saiu como esperado.

Testes em Solidity

Se preferir, todo o conteúdo sobre testes em Solidity pode ser conferido no vídeo abaixo.

YouTube player

No topo do arquivo de testes Solidity temos as seguintes informações:

A versão de Solidity deve ser a mesma do seu contrato, que você vai importar logo abaixo. Por último, importamos o contrato-base dos testes para conseguir fazer os mesmos. Nos testes Solidity nós precisamos extender o contrato Test que importamos e sobrescrever uma função setUp que será chamada automaticamente para inicializar o contrato de testes adequadamente. No exemplo abaixo, eu inicializo um contrato de CRUD de livros e gero dois endereços para os testes (user1 e owner).

Depois, para cada teste, eu escrevo uma função de teste como abaixo.

Nesta função, eu uso a instância do contrato real que inicializamos no setUp para chamar a função a ser testada. Como ela não tem retorno (como a imensa maioria das transações) eu uso uma função leitura no require que valida o teste.

Outra opção em Solidity, que não envolve require, é usando a função utilitária assertEq, onde você passa dois parâmetros que serão comparados para ver se são iguais, como abaixo.

Outra opção é a assertNotEq (para comparar valores diferentes) e a assertTrue (para valores booleanos).

Entendidos estes conceitos fundamentais dos dois ambientes de testes do HardHat, vamos aos cenários de testes.

#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.

Testes Simples

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

No teste acima, que é do mesmo CRUD de livros que citei antes, verificamos se a quantidade de livros está correta após a adição de um novo. Outra opção seria acessar o mapping de livros pelo id e ver se o title está correto, por exemplo.

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í.

Em Solidity, o equivalente do mesmo teste seria aquele último código que coloquei na seção anterior, de fundamentos. Não temos a biblioteca Chai em Solidity, por isso não temos um expect, mas sim o require nativo da linguagem. Se a condição do primeiro parâmetro do require retornar true, o teste passou, caso contrário, ele falhou com a mensagem fornecida no segundo parâmetro.

Testes de Transações (sem eventos)

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/requires, 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, que mostrarei logo mais.

O equivalente aos múltiplos expects em Solidity é usar múltiplos requires, seguindo a mesma lógica dos testes simples que vimos no tópico anterior. Outra opção em ambos os casos é usar apenas um expect/require, mas com uma condição booleana composta, usando lógica AND ou OR. No entanto, essa abordagem não permite tanta personalização nas mensagens de erro, por isso dependendo do caso pode ser uma má prática.

Testes de Transações (com eventos)

A segunda opção para testar transações é verificar se um evento foi emitido após a transação e é 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 (teste de evento). Independente da situação, o exemplo de código abaixo mostra como testar se um determinado evento foi emitido após uma transação em TypeScript.

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. E caso você precise validar diversos eventos emitidos no mesmo teste, pode usar mais de um expect.

O equivalente em Solidity seria:

Aqui a mecânica é ligeiramente diferente para validar eventos. Primeiro, você deve chamar o vm.expectEmit, dizendo se a validação deve considerar os parâmetros do evento, sendo que cada “true” que passei ali são para os parâmetros indexed 1, 2 e 3 (mesmo que não possua 3 indexed, tem de passar os booleanos). Já o quarto booleano são para o “data” do evento (demais parâmetros não indexed). Já o último parâmetro é opcional e serve para quando o evento é emitido por outro contrato, apenas quando há cascateamento de chamadas entre contratos diferentes. Na sequência, você deve emitir o evento pelo contrato de teste, com a exata configuração que será usada na validação, como se fosse para “ensinar” a vm como deve ser o evento correto. Por último, chamamos a função que vai disparar o evento em si.

Caso necessário validar diversos eventos no mesmo teste, você pode repetir o processo para cada um deles, um abaixo do outro.

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.

Teste de Generic Error

Para testar esses erros de execução em TypeScript 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 em Solidity, o mesmo teste acima fica assim:

O vm.expectRevert vai esperar que, na próxima instrução, um erro seja disparado com os bytes especificados. Assim, o require interno da contract.uri(10) vai disparar o erro que vai ser capturado e processado como a validação do teste. Caso a mensagem em si não lhe importe, você pode chamar o expectRevert sem parâmetros.

Teste de Custom Error

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).

Agora em Solidity, para testar custom errors não é muito diferente de erros genéricos pois também usamos a vm.expectRevert, mas ao invés de passar a mensagem, passamos o selector do custom error existente em algum arquivo Solidity, geralmente o próprio contrato. Na sequência chamamos a função que irá ocasionar o erro.

O único ponto de atenção acima é o import do arquivo onde estão os custom errors e também o encoding correto que varia de custom error para custom error. No caso do ERC20InsufficientBalance, ele pede a conta que disparou o erro (otherAccount), o saldo dela (0) e a quantia que queria transferir (1).

Em 99% dos testes de falhas em smart contracts você usará as duas formas 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.

Impersonate

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 geramos na configuração inicial dos testes, 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?

Nos testes TypeScript 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 a partir 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.

Já nos testes em Solidity, este mesmo recurso pode ser feito através do código abaixo.

O vm.startPrank troca o contexto para o da conta informada até que você chame o vm.stopPrank. Ou seja: a chamada de removeBook que está entre eles será executada como sendo feita pela “otherAccount”.

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 os recursos abaixo.

Em TypeScript, a constante ethers.ZeroAddress, que tem exatamente esse valor definido, ficando muito mais elegante no seu código de teste, como abaixo.

Enquanto em Solidity usamos:

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 VM de testes esteja em uma data futura entre duas instruções de um teste.

Em testes TypeScript, o HardHat fornece um objeto chamado networkHelpers que permite justamente manipulações dos blocos da blockchain e consequentemente da simulação de passagem do tempo em uma situação real. O primeiro passo para usar o networkHelpers é carregá-lo no módulo de testes, como abaixo, antes de qualquer teste e substituindo o carregamento original do objeto ethers.

Depois, você pode usar a propriedade time do networkHelpers com uma série de funções úteis 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 time.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.

Em Solidity você consegue o mesmo efeito usando a vm.warp, uma função que faz a blockchain “viajar” pro futuro x tempo, como no exemplo abaixo onde posicionei o teste 2 dias no futuro.

Funções Auxiliares (Helpers)

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. 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 TypeScript 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.

Em Solidity, teríamos algo equivalente abaixo (vm.prank usa a wallet especificada apenas na próxima instrução). Apenas atenção que o primeiro parâmetro é para mudar o msg.sender, já o segundo (opcional) é para mudar o tx.origin:

Querendo fazer “prank” de um bloco de linhas, pode usar a combinação vm.startPrank e vm.stopPrank.

E quando eu preciso de fundos nas contas de teste? No caso dos testes com Mocha + Ethers, que usam a HardHat Network, elas já vem com 10 mil ETH cada, mas no caso de Solidity + Forge-STD, não. Neste caso você pode usar vm.deal para adicionar fundos facilmente.

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!

TAGS:

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 *