Hardhat: юнит-тесты (ethers + chai)
Как написать юнит-тест смарт-контракта в Hardhat 3 через ethers, chai и network helpers.
Что проверяет юнит-тест
Юнит-тест проверяет один контракт в контролируемой локальной сети. Hardhat поднимает эту сеть сам, деплоит контракт перед тестом и откатывает состояние между сценариями.
В связке ethers + chai вы вызываете контракт как обычный TypeScript-объект, а expect() проверяет события, значения и reverts.
Пишем тест для Counter
Создайте проект:
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:
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:
// 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:
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);
});
});Запустите тест:
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(), чтобы тесты были быстрее и не зависели друг от друга.
Что дальше
- Hardhat: fork mainnet для тестов — проверить контракт на состоянии реальной сети
- Деплой контракта через ethers — задеплоить тот же контракт скриптом
- События и emit — понять, что именно проверяет matcher
.to.emit()