Тесты контрактов: Solidity и TypeScript рядом
- Зачем это нужно
- Два вида тестов в одном проекте
- Первый тест на TypeScript, построчно
- Проверки: события, откаты, балансы
- Фикстура: почему без неё тесты врут
- Тесты на Solidity: подменить отправителя, выдать эфир
- Случайные данные вместо придуманных
- Полный прогон
- Сколько это стоит газа
- До чего тесты не дотянулись
- Что вообще тестировать в контракте
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
У контракта нет отладчика, нет возможности «поправить и перезалить» и нет второго шанса: развёрнутый код неизменен, а ошибка в нём стоит денег. Проверять его руками через кошелёк — долго, ненадёжно и невоспроизводимо.
Поэтому здесь тесты — не хорошая практика, а единственный способ вообще узнать, что контракт работает. Задание это учитывает: тесты входят в оцениваемое.
В проекте, который мы создали, тестов два вида сразу, и они решают разные задачи. Эта глава — про оба, про то, почему без фикстур тесты врут, и про два флага, которые показывают, что дорого и что не проверено.
Два вида тестов в одном проекте
Тесты на Solidity лежат рядом с контрактом, в файлах *.t.sol. Они выполняются внутри сети, языка снаружи нет вовсе — отсюда скорость. Им доступны особые возможности: подменить отправителя, выдать адресу эфир, потребовать отката. Зато они ничего не знают про кошелёк, интерфейс и внешние службы.
Тесты на TypeScript лежат в test/. Они работают через ethers — тот же самый код, который потом пойдёт в скрипты развёртывания и на сайт. Медленнее, но ближе к жизни: проверяют не только «что записалось в хранилище», а весь путь от вызова до события.
Одна команда запускает оба набора:
npx hardhat test
Первый тест на TypeScript, построчно
Тестируем хранилище из прошлой главы: вклады, вывод, ставка, проценты.
import { expect } from "chai";
import { network } from "hardhat";
const { ethers, networkHelpers } = await network.create();
async function deployVault() {
const [owner, alice, bob] = await ethers.getSigners();
const vault = await ethers.deployContract("Vault", [500n]);
return { vault, owner, alice, bob };
}
describe("Vault", function () {
it("после развёртывания владелец и ставка те, что задали", async function () {
const { vault, owner } = await networkHelpers.loadFixture(deployVault);
expect(await vault.owner()).to.equal(owner.address);
expect(await vault.rate()).to.equal(500n);
expect(await vault.totalDeposits()).to.equal(0n);
});
});
Сравнивать числа контракта надо с большими целыми: 500n, а не 500. Обычное число и большое целое не равны друг другу даже при одинаковом значении — проверка молча не пройдёт, а сообщение будет выглядеть абсурдно: «ожидалось 500, получено 500».
Проверки: события, откаты, балансы
Обычного сравнения хватает не всегда. Набор инструментов приносит проверки, которые понимают устройство сети.
it("вклад порождает событие с адресом и суммой", async function () {
const { vault, alice } = await networkHelpers.loadFixture(deployVault);
await expect(vault.connect(alice).deposit({ value: 100n }))
.to.emit(vault, "Deposited")
.withArgs(alice.address, 100n);
});
it("вывод возвращает деньги на кошелёк", async function () {
const { vault, alice } = await networkHelpers.loadFixture(deployVault);
await vault.connect(alice).deposit({ value: ethers.parseEther("3") });
await expect(vault.connect(alice).withdraw())
.to.changeEtherBalances(ethers, [alice, vault],
[ethers.parseEther("3"), ethers.parseEther("-3")]);
});
it("вывод без вклада откатывается своей ошибкой", async function () {
const { vault, bob } = await networkHelpers.loadFixture(deployVault);
await expect(vault.connect(bob).withdraw())
.to.be.revertedWithCustomError(vault, "NothingToWithdraw");
});
Первым аргументом проверок балансов идёт ethers. Это особенность третьей версии: проверки живут отдельно от подключения и не знают, к какой сети вы подключились. Забыть его легко, а сообщение получается непрозрачным: «ожидалась строка или адрес, а получено 3000000000000000000».
Фикстура: почему без неё тесты врут
Сеть в тестах одна на весь файл. Значит, всё, что сделал первый тест, видит второй.
Прогон показывает это рядом — одинаковый второй тест в двух вариантах:
без фикстуры: состояние течёт между тестами
totalDeposits после первого теста: 1.0
✔ первый тест кладёт вклад
totalDeposits в начале второго : 1.0
1) второй тест ожидает ноль — и не получает его
с фикстурой: каждый тест с чистого листа
totalDeposits после первого теста: 1.0
✔ первый тест кладёт вклад
totalDeposits в начале второго : 0.0
✔ второй тест ожидает ноль — и получает его
1) без фикстуры: состояние течёт между тестами
второй тест ожидает ноль — и не получает его:
AssertionError: expected 1000000000000000000 to equal 0.
loadFixture делает две вещи. В первый раз — выполняет вашу функцию и запоминает снимок сети. Дальше — просто откатывает сеть к этому снимку. Откат дешевле развёртывания, поэтому фикстуры не только правильнее, но и быстрее.
Почему это важнее, чем кажется: тест без изоляции зависит от порядка запуска. Он проходит, пока файл гоняют целиком, и падает, если запустить его одного. Или наоборот. Отлаживать такое дороже, чем писать.
Тесты на Solidity: подменить отправителя, выдать эфир
import {Vault} from "./Vault.sol";
import {Test} from "forge-std/Test.sol";
contract VaultTest is Test {
Vault vault;
address alice = address(0xA11CE);
function setUp() public {
vault = new Vault(500);
vm.deal(alice, 100 ether);
}
function test_DepositAddsUp() public {
vm.prank(alice);
vault.deposit{value: 2 ether}();
require(vault.deposits(alice) == 2 ether, unicode"вклад не записался");
}
function test_WithdrawWithoutDepositReverts() public {
vm.expectRevert(Vault.NothingToWithdraw.selector);
vm.prank(alice);
vault.withdraw();
}
}
Случайные данные вместо придуманных
Один особый вид тестов заслуживает отдельной строки:
function testFuzz_InterestNeverExceedsDeposit(uint96 amount) public {
vm.assume(amount > 0);
vm.deal(alice, uint256(amount));
vm.prank(alice);
vault.deposit{value: amount}();
require(vault.interestOf(alice) <= amount, unicode"проценты больше вклада");
}
Приставка testFuzz_ означает: аргумент придумывает не автор, а машина. По умолчанию тест гоняется 256 раз с разными значениями.
✔ testFuzz_InterestNeverExceedsDeposit(uint96) (runs: 256)
Смысл в смене вопроса. Обычный тест спрашивает «работает ли на этом значении», случайный — «есть ли значение, на котором не работает». Проверяют так не конкретные числа, а утверждения, которые должны быть верны всегда: проценты не больше вклада, сумма вкладов равна общему счёту, баланс не уходит в минус.
vm.assume отсекает заведомо ненужные значения: если условие не выполнено, попытка не засчитывается и берётся другое число.
Полный прогон
Running Solidity tests
contracts/Vault.t.sol:VaultTest
✔ test_WithdrawWithoutDepositReverts()
✔ test_RateIsSet()
✔ test_OnlyOwnerSetsRate()
✔ test_DepositAddsUp()
✔ testFuzz_InterestNeverExceedsDeposit(uint96) (runs: 256)
Running Mocha tests
Vault
✔ после развёртывания владелец и ставка те, что задали
✔ вклад увеличивает и личный счёт, и общий
✔ вклад порождает событие с адресом и суммой
✔ вывод возвращает деньги на кошелёк
✔ вывод без вклада откатывается своей ошибкой
✔ ставку меняет только владелец
✔ проценты считаются от вклада по ставке
✔ каждый тест начинается с чистого состояния
8 passing (59ms)
13 passing (5 solidity, 8 mocha)
Тринадцать тестов за считанные миллисекунды, включая 256 случайных прогонов. Это и есть главный аргумент в пользу тестов вместо ручной проверки: то же самое кошельком заняло бы час и не повторилось бы дважды одинаково.
Сколько это стоит газа
╔═══════════════════════════════════════════════════════════════════╗
║ contracts/Vault.sol:Vault ║
╟───────────────┬────────┬─────────┬────────┬────────┬──────────────╢
║ Function name │ Min │ Average │ Median │ Max │ #calls ║
╟───────────────┼────────┼─────────┼────────┼────────┼──────────────╢
║ deposit │ 67481 │ 67481 │ 67481 │ 67481 │ 4 ║
║ deposits │ 24299 │ 24299 │ 24299 │ 24299 │ 1 ║
║ interestOf │ 26830 │ 26830 │ 26830 │ 26830 │ 1 ║
║ rate │ 23490 │ 23490 │ 23490 │ 23490 │ 2 ║
║ setRate │ 28823 │ 28823 │ 28823 │ 28823 │ 1 ║
║ withdraw │ 32285 │ 32285 │ 32285 │ 32285 │ 1 ║
╟───────────────┼────────┼─────────┼────────┼────────┼──────────────╢
║ Deployment │ 586384 │ 586384 │ 586384 │ 586384 │ 1 ║
╟───────────────┼────────┼─────────┴────────┴────────┴──────────────╢
║ Bytecode size │ 2253 │ ║
╚═══════════════╧════════╧══════════════════════════════════════════╝
Флаг --gas-stats добавляет к прогону эту таблицу. Читается она так.
Вклад дороже вывода вдвое — 67 481 против 32 285. Причина в главе про хранилище: запись в пустую ячейку стоит заметно дороже, чем изменение уже занятой, а обнуление ячейки часть газа возвращает.
Чтения тоже в таблице — rate, deposits, interestOf. Снаружи чтение бесплатно, но здесь оно идёт транзакцией, поэтому и попадает в статистику вместе с фиксированной ценой транзакции.
Развёртывание — 586 384, на порядок дороже любого вызова. За байт кода платят отдельно, и именно здесь оптимизатор из прошлой главы окупается.
Размер тела — 2253 байта, то же число, что мы видели в артефакте.
Таблицу газа полезно снять до и после правки контракта. Если цифра выросла вдвое от безобидного на вид изменения — вы, скорее всего, добавили запись в хранилище там, где её можно было избежать.
До чего тесты не дотянулись
── все 13 тестов ──
║ File Path │ Line % │ Statement % │ Uncovered Lines ║
║ contracts/Vault.sol │ 100.00 │ 100.00 │ - ║
── убрали два теста ──
║ contracts/Vault.sol │ 80.00 │ 76.47 │ 39-40, 44 ║
Флаг --coverage показывает, какие строки контракта не выполнились ни разу. Во втором прогоне убраны два теста — и покрытие сразу назвало номера строк: проверка владельца и расчёт процентов.
Читать эту цифру надо осторожно. Сто процентов покрытия не означают, что контракт верен. Они означают ровно одно: каждая строка хотя бы раз исполнялась. Строка deposits[msg.sender] += msg.value считается покрытой и при вкладе в один вей, и при вкладе, который переполнит счётчик.
Польза покрытия — не в проценте, а в списке непокрытых строк. Это готовый список того, о чём вы не подумали.
Что вообще тестировать в контракте
Список, по которому стоит идти для каждого контракта:
- Начальное состояние. Владелец, параметры конструктора, нулевые счётчики.
- Счастливый путь. Основное действие работает и меняет состояние ровно так, как задумано.
- События. Для каждого — факт и все аргументы. Снаружи это единственный способ узнать, что произошло.
- Права. Каждая ограниченная функция — дважды: разрешённым и запрещённым, с проверкой конкретной ошибки.
- Границы. Ноль, максимум, пустой список, повторный вызов.
- Деньги. Изменение балансов обеих сторон, а не только запись в хранилище.
- Утверждения, верные всегда — случайными данными.
Первые пять пунктов удобнее закрывать тестами на TypeScript, шестой — тоже, седьмой — тестами на Solidity.
Типичные ошибки
Тест на права без connect. Транзакция ушла от первого счёта, который и есть владелец, — тест проходит, дыра остаётся.
revertedWith вместо revertedWithCustomError. Первая проверка ждёт текстовое сообщение, наши ошибки объявлены отдельным видом. Тест упадёт, хотя контракт верен.
Просто expect(...).to.be.reverted. Пройдёт на любом падении, в том числе на опечатке в имени функции.
Сравнение с обычным числом вместо большого целого. equal(500) вместо equal(500n): сообщение «ожидалось 500, получено 500» приводит в отчаяние.
Забытый ethers первым аргументом в проверках балансов: «ожидалась строка или адрес, а получено 3000000000000000000».
Развёртывание вне фикстуры. Тесты начинают зависеть от порядка запуска.
vm.expectRevert после вызова, а не перед. Ставится строго перед той строкой, которая должна упасть.
Слова главы
- Тест на Solidity — файл
*.t.solрядом с контрактом; быстрый, с доступом кvm. - Тест на TypeScript — файл в
test/; работает через ethers, ближе к настоящему использованию. - Фикстура — состояние, к снимку которого сеть откатывается перед каждым тестом.
- Проверки набора — умеют события, откаты и изменения балансов; балансам нужен
ethersпервым аргументом. vm.prank/.connect— подписать вызов другим счётом.- Случайные данные — тест с приставкой
testFuzz_: 256 прогонов с разными значениями. - Статистика газа — сколько стоит каждая функция и развёртывание.
- Покрытие — какие строки не выполнялись ни разу; ценен список, а не процент.
Тренажёр: набрать вслепую
Цель: скорость ≥ 95 зн/мин, точность ≥ 88%
const { ethers, networkHelpers } = await network.create();
На чемпионате
Тесты пишут по ходу, а не в конце. Контракт, который не проверялся до последнего часа, почти наверняка содержит ошибку, а времени на неё уже нет.
Названия тестов — это отчёт. Список из тринадцати строк вида «вывод без вклада откатывается своей ошибкой» показывает проверяющему, что именно вы предусмотрели, — быстрее и убедительнее, чем рассказ.
Снимите таблицу газа и покрытие один раз перед сдачей. Обе команды занимают секунды, а результат — готовый материал для защиты: «вот сколько стоит вклад, вот что покрыто».
Тесты на права — обязательны. Это первое, что смотрят в контракте, и самая частая настоящая уязвимость.
И практическое: если тест падает, а контракт кажется верным, сначала перечитайте сам тест. По опыту этой главы — ethers первым аргументом, 500n вместо 500, забытый connect. Три ошибки из четырёх живут в тесте, а не в контракте.
Куда углубиться
- Управление временем — перевести часы сети вперёд и проверить то, что зависит от срока.
- Проверка через снимки — сравнение больших структур целиком вместо поля за полем.
- Тесты на форке настоящей сети — работа с уже развёрнутыми чужими контрактами.
- Инварианты — случайные данные не для одного вызова, а для целых последовательностей.
- Отчёт покрытия в виде страницы — он сохраняется рядом и показывает непокрытое прямо в исходнике.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Hardhat: тесты — оба вида тестов и их запуск.
- Hardhat: network helpers — фикстуры, время, балансы.
- Chai matchers для ethers — полный список проверок.
- Foundry Book: cheatcodes — справочник по
vmдля тестов на Solidity. - Foundry Book: fuzz testing — как устроены случайные данные.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.