китаев.tech

Hardhat: юнит-тесты (ethers + chai)

Как написать юнит-тест смарт-контракта в Hardhat 3 через ethers, chai и network helpers.

Что проверяет юнит-тест

Юнит-тест проверяет один контракт в контролируемой локальной сети. Hardhat поднимает эту сеть сам, деплоит контракт перед тестом и откатывает состояние между сценариями.

В связке ethers + chai вы вызываете контракт как обычный TypeScript-объект, а expect() проверяет события, значения и reverts.


Пишем тест для Counter

Создайте проект:

bash
mkdir hardhat-unit-test-demo
cd hardhat-unit-test-demo

npm init -y
npm pkg set type="module"
npm install --save-dev hardhat typescript tsx @types/node @nomicfoundation/hardhat-toolbox-mocha-ethers

mkdir contracts test

Создайте файл hardhat.config.ts:

typescript
import { defineConfig } from "hardhat/config";
import hardhatToolboxMochaEthers from "@nomicfoundation/hardhat-toolbox-mocha-ethers";

export default defineConfig({
  plugins: [hardhatToolboxMochaEthers],
  solidity: {
    version: "0.8.28",
  },
});

Создайте файл contracts/Counter.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Counter {
    uint256 public value;
    address public owner;

    event Incremented(address indexed caller, uint256 newValue);

    error NotOwner(address caller);

    constructor() {
        owner = msg.sender;
    }

    function increment() external {
        if (msg.sender != owner) {
            revert NotOwner(msg.sender);
        }

        value += 1;

        emit Incremented(msg.sender, value);
    }
}

Создайте файл test/Counter.ts:

typescript
import { expect } from "chai";
import { network } from "hardhat";

const { ethers, networkHelpers } = await network.create();

describe("Counter", function () {
  async function deployCounterFixture() {
    const [owner, user] = await ethers.getSigners();
    const counter = await ethers.deployContract("Counter");

    return { counter, owner, user };
  }

  it("увеличивает value и эмитит событие", async function () {
    const { counter, owner } = await networkHelpers.loadFixture(deployCounterFixture);

    await expect(counter.increment())
      .to.emit(counter, "Incremented")
      .withArgs(owner.address, 1n);

    expect(await counter.value()).to.equal(1n);
  });

  it("отклоняет вызов не от owner", async function () {
    const { counter, user } = await networkHelpers.loadFixture(deployCounterFixture);

    await expect(counter.connect(user).increment())
      .to.be.revertedWithCustomError(counter, "NotOwner")
      .withArgs(user.address);
  });
});

Запустите тест:

bash
npx hardhat test

Как это работает

network.create() — создаёт Hardhat-сеть для тестов и возвращает подключённые ethers и networkHelpers.

ethers.deployContract("Counter") — компилирует и деплоит контракт по имени из артефактов Hardhat.

networkHelpers.loadFixture() — один раз выполняет setup, сохраняет snapshot и быстро откатывает сеть к нему перед каждым тестом.

.to.emit(...).withArgs(...) — проверяет, что транзакция выпустила нужное событие с нужными аргументами.

.revertedWithCustomError() — проверяет custom error, а не строку revert. Это точнее и дешевле в Solidity.

Частые ошибки

Забыли await перед транзакцией → chai matcher должен получить Promise: await expect(counter.increment()).to.emit(...).

Сравниваете uint256 с number → ethers v6 возвращает bigint, поэтому пишите 1n, а не 1.

Деплоите контракт в каждом тесте вручную → используйте loadFixture(), чтобы тесты были быстрее и не зависели друг от друга.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.