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, суммы только черезparseEther -
Node ниже 22.13 — самая частая причина «ничего не работает» сразу после init. Ошибки поймаешь не про версию ноды, а про синтаксис, так что может быть не очевидно
Запуск — npx hardhat test. Семь тестов моей копилки пробегают за треть секунды, потому что сеть живёт в памяти процесса. CI-конфиг сводится к npm ci && npx hardhat test — никаких сервисов и RPC-ключей. Не это ли счастье?
Полный тест-файл и контракт — в репо, тег post-12. Если соберёте по этому рецепту свой набор, буду рад услышать, какой ассерт окажется самым ходовым у вас. Ну и не забывайте подписываться на мой телеграм, ссылка в профиле, там каждый день посты, словари и впереди только больше и лучше.
-
Это Райт. Ни бага, ни костыля тебе, друг!
ссылка на оригинал статьи https://habr.com/ru/articles/1082316/