Тестируем смарт-контракты на Hardhat 3: node:test, viem и ни одного mocha

Hardhat в третьей версии решили не мелочиться, взяли и сменили весь тестовый стек. Вместо привычной связки mocha + chai + ethers теперь такой родной node:test и viem. Апгрейд-гайды в основном рассказывают, как мигрировать конфиг, а вот как теперь писать тесты - материала заметно меньше. Я не так давно, буквально несколько дней назад, писал свой учебный проект на Hardhat 3 и собрал рецепт целиком. От init до ассертов на балансы. Всё из этой статьи ты сможешь повторить за вечер
Тестировать будем мой учебный проект контракт-копилку. Его суть максимально простая. Положить деньги в копилку могут все, забирать может только владелец, и только тогда, когда накоплена цель. Полный код в репозитории, но для статьи достаточно его интерфейса
function deposit() external payable; // revert EmptyDeposit()
function withdraw() external; // revert NotOwner() / GoalNotReached(current, goal)
function progress() external view returns (uint256 current, uint256 target);
event Deposited(address indexed from, uint256 amount, uint256 total);
event Withdrawn(address indexed to, uint256 amount);Что изменилось в Hardhat 3
Было (HH2) | Стало (HH3) |
|---|---|
mocha |
|
chai + chai-matchers |
|
ethers | viem |
|
|
хардкодная сеть |
|
Лично мой фаворит среди приятных плюшек - это обычный TypeScript без глобальной магии. Из того, к чему придется немного привыкать - часть API стала асинхронной там, где раньше была синхронной.
Требования: Node 22.13+ (жёсткое, node:test нужной версии), дальше:
npx hardhat --init # шаблон: node-test-runner-viemЕсли выбрал шаблон, как из примера выше, тогда у тебя есть contracts/, test/, ignition/ и конфиг на TypeScript. Пуляем контракт в contracts/ и полетели тестить.
Каркас теста
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseEther, getAddress } from "viem";
import { network } from "hardhat";
describe("Kopilka", async function () {
const { viem } = await network.create();
const [owner, stranger] = await viem.getWalletClients();
const GOAL = parseEther("1");
const deploy = () => viem.deployContract("Kopilka", [GOAL]);
// тесты ниже
});
Две вещи, которые, на мой взгляд, стоит посмотреть дважды. Первая это describe, и он асинхронный, а вместе с ним network.create(), который живёт внутри него. Каждый describe получает собственную EVM в памяти. "Зачем это нужно?" - спросишь ты. Отвечаю: мгновенные блоки, 20 предзаряженных аккаунтов, ноль сетевых вызовов. Вторая вещь это deploy и он стал фабрикой. И снова тот же вопрос от тебя и снова ответ от меня: Свежий контракт на каждый тест дешевле, чем отлов межтестового состояния между тестами и попытка понять, "А как так выходит, что тест вроде правильный, а падает с кривыми цифрами?"
Первый тест — тривиальный, но он проверяет весь конвейер от компиляции до чтения:
it("деплой: владелец — деплоер, цель записана", async function () {
const kopilka = await deploy();
assert.equal(await kopilka.read.owner(), getAddress(owner.account.address));
const [current, target] = await kopilka.read.progress();
assert.equal(current, 0n);
assert.equal(target, GOAL);
});
Запоминаем, где зарыта первая грабля: read.owner() возвращает адрес в EIP-55 checksum-регистре, а owner.account.address в нижнем. Из этого следует, что сравнивать надо через getAddress(), иначе тест падает на «одинаковых» адресах.
События: emitWithArgs
it("deposit эмитит Deposited", async function () {
const kopilka = await deploy();
await viem.assertions.emitWithArgs(
kopilka.write.deposit({ value: parseEther("0.4"), account: stranger.account }),
kopilka,
"Deposited",
[getAddress(stranger.account.address), parseEther("0.4"), parseEther("0.4")],
);
});
Тут все довольно стандартно. Аргументы сверяются позиционно по ABI, не перепутай порядок. Обрати внимание на третий аргумент. Контракт пишет в событие address(this).balance, а он к моменту emit уже включает пришедший депозит. Небольшой вывод из опыта: Тест на событие - это прекрасный способ зафиксировать тонкости и сложности семантики контракта, о которых не расскажет ни один геттер. Поменяешь контракт и упадет тест из-за несоответствия нового ABI и аргументов теста.
Отказы: revertWithCustomError
Тест «функция работает» пишут все. Давай говорить откровенно: на такой "сложный" тест ума много не надо. Но в жизни все наоборот. Деньги теряют там, где функция не отказала, хотя должна была:
it("withdraw чужаком откатывается", async function () {
const kopilka = await deploy();
await kopilka.write.deposit({ value: GOAL });
await viem.assertions.revertWithCustomError(
kopilka.write.withdraw({ account: stranger.account }),
kopilka,
"NotOwner",
);
});
Теперь под другим углом, тот же профиль. Версия с аргументами, которая проверяет не только причину отказа, но и цифры в ней:
await viem.assertions.revertWithCustomErrorWithArgs(
kopilka.write.withdraw(),
kopilka,
"GoalNotReached",
[parseEther("0.3"), GOAL],
);
И опять из опыта: это намного сильнее, чем может казаться: «отказал, но не по той причине» - это полноценный баг, который строковые require-сообщения ловили плохо, а типизированные custom errors ловят бесплатно и главное "красиво".
Деньги: balancesHaveChanged
Мой любимый ассерт нового стека:
it("после цели владелец забирает всё", async function () {
const kopilka = await deploy();
await kopilka.write.deposit({ value: GOAL, account: stranger.account });
await viem.assertions.balancesHaveChanged(kopilka.write.withdraw(), [
{ address: owner.account.address, amount: GOAL },
]);
});
Здесь одна строка кода утверждает главное для любого контракта с деньгами. Проверяем, что после транзакции баланс владельца вырос ровно на GOAL. Что мне нравится здесь особенно, так это то, что комиссию за газ ассерт учитывает сам, не нужно ничего вычитать руками и следить за математикой лишний раз. Типичное место кривых тестов - плохо посчитанная вручную математика, при их написании. В HH2 такой тест собирался из getBalance-до и getBalance-после и ручной арифметики с receipt.gasUsed. Теперь это просто хорошо забытое старое.
Шпаргалка по viem.assertions
Ассерт | Проверяет |
|---|---|
| откат по любой причине |
| откат со строкой из require |
| откат с конкретной custom error |
| …и с конкретными аргументами |
| событие было |
| событие с аргументами (позиционно) |
| дельты ETH-балансов с учетом газа |
Опыт, собранный по дороге
Checksum-адреса - сравнивай через
getAddress()describeобязан быть async, аnetwork.create()внутри негоВсё в bigint.
assert.equal(current, 0)упадёт:0 !== 0n. Литералы с суффиксомn, суммы только черезparseEtherNode ниже 22.13 - самая частая причина «ничего не работает» сразу после init. Ошибки поймаешь не про версию ноды, а про синтаксис, так что может быть не очевидно
Запуск - npx hardhat test. Семь тестов моей копилки пробегают за треть секунды, потому что сеть живёт в памяти процесса. CI-конфиг сводится к npm ci && npx hardhat test - никаких сервисов и RPC-ключей. Не это ли счастье?
Полный тест-файл и контракт — в репо, тег post-12. Если соберёте по этому рецепту свой набор, буду рад услышать, какой ассерт окажется самым ходовым у вас. Ну и не забывайте подписываться на мой телеграм, ссылка в профиле, там каждый день посты, словари и впереди только больше и лучше.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.