Перейти к основному содержимому
Блокчейн36ГЛАВА 36Тесты контрактов: Solidity и TypeScript рядомVault.t.solVault.ts13 passing

Тесты контрактов: Solidity и TypeScript рядом

Прочитано0%
Квизы0/4
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

У контракта нет отладчика, нет возможности «поправить и перезалить» и нет второго шанса: развёрнутый код неизменен, а ошибка в нём стоит денег. Проверять его руками через кошелёк — долго, ненадёжно и невоспроизводимо.

Поэтому здесь тесты — не хорошая практика, а единственный способ вообще узнать, что контракт работает. Задание это учитывает: тесты входят в оцениваемое.

В проекте, который мы создали, тестов два вида сразу, и они решают разные задачи. Эта глава — про оба, про то, почему без фикстур тесты врут, и про два флага, которые показывают, что дорого и что не проверено.

Два вида тестов в одном проекте​

ДВА ВИДА ТЕСТОВ В ОДНОМ ПРОЕКТЕcontracts/Vault.t.solпишут на Solidity, рядом с контрактомподменяют отправителя и выдают эфиргоняют случайные входные данныебыстрые: сети снаружи нет вовсене видят кошелька и внешних службtest/Vault.tsпишут на TypeScript, через ethersпроверяют события и изменения балансовтот же код, что пойдёт в скрипты и сайтумеют ходить в настоящую сетьмедленнее, зато ближе к жизниодна команда запускает оба набора: 5 тестов на Solidity и 8 на TypeScriptлогику контракта удобнее проверять первым видом, сценарий целиком — вторым
Логику контракта проверяют тестами на Solidity, сценарий целиком — тестами на TypeScript

Тесты на 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.
ОДИН И ТОТ ЖЕ ВТОРОЙ ТЕСТ, ДВА ИСХОДАконтракт развёрнут один раз на всехпосле первого теста: 1.0в начале второго : 1.0expected 1000000000000000000 to equal 0тесты зависят от порядка запускакаждый тест берёт фикстурупосле первого теста: 1.0в начале второго : 0.0сеть откатилась к снимкупорядок запуска перестал значить что-либофикстура выполняется один раз, дальше сеть просто откатывается к снимку — это быстрее развёртываниятест, который проходит только вторым по счёту, — не тест, а совпадение
Один и тот же второй тест: без фикстуры видит чужой вклад, с фикстурой — чистое состояние

loadFixture делает две вещи. В первый раз — выполняет вашу функцию и запоминает снимок сети. Дальше — просто откатывает сеть к этому снимку. Откат дешевле развёртывания, поэтому фикстуры не только правильнее, но и быстрее.

Почему это важнее, чем кажется: тест без изоляции зависит от порядка запуска. Он проходит, пока файл гоняют целиком, и падает, если запустить его одного. Или наоборот. Отлаживать такое дороже, чем писать.

Отвечено вопросов: 0 из 2
Что делает loadFixture при втором вызове?
Чем плох тест, который зависит от порядка запуска?
Отвечено верно: 0 из 2

Тесты на 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-statsdeposit67 481withdraw32 285setRate28 823rate (чтение)23 490развёртывание — 586 384; тело контракта — 2253 байта--coverageвсе 13 тестов: 100.00 %непокрытых строк нетубрали два теста: 80.00 %Uncovered Lines: 39-40, 44это проверка владельца и расчётгаз показывает, что дорого; покрытие — до чего тесты не дотянулись ни разусто процентов покрытия не означают, что логика верна: означают лишь, что каждая строка исполнялась
Газ показывает, что дорого; покрытие — до чего тесты не дотянулись ни разу

Флаг --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 считается покрытой и при вкладе в один вей, и при вкладе, который переполнит счётчик.

Польза покрытия — не в проценте, а в списке непокрытых строк. Это готовый список того, о чём вы не подумали.

Отвечено вопросов: 0 из 2
Почему вклад стоит вдвое дороже вывода — 67 481 против 32 285?
Покрытие 100 %. Что это доказывает?
Отвечено верно: 0 из 2

Что вообще тестировать в контракте​

Список, по которому стоит идти для каждого контракта:

  1. Начальное состояние. Владелец, параметры конструктора, нулевые счётчики.
  2. Счастливый путь. Основное действие работает и меняет состояние ровно так, как задумано.
  3. События. Для каждого — факт и все аргументы. Снаружи это единственный способ узнать, что произошло.
  4. Права. Каждая ограниченная функция — дважды: разрешённым и запрещённым, с проверкой конкретной ошибки.
  5. Границы. Ноль, максимум, пустой список, повторный вызов.
  6. Деньги. Изменение балансов обеих сторон, а не только запись в хранилище.
  7. Утверждения, верные всегда — случайными данными.

Первые пять пунктов удобнее закрывать тестами на TypeScript, шестой — тоже, седьмой — тестами на Solidity.

Отвечено вопросов: 0 из 2
Что из списка проверок удобнее закрывать тестами на Solidity?
Где ставится vm.expectRevert?
Отвечено верно: 0 из 2

Типичные ошибки​

Тест на права без 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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоSolidity и смарт-контракты Ethereum, урок #5, АПДЕЙТ в закрепе | Тестирование, Hardhat, Ethers, ChaiIlya Krukowski
ВидеоЗапускаем тесты на HardHatРоман Р
ВидеоSolidity и смарт-контракты Ethereum, урок #33 | Тесты, управление временем, BigNumber, хелперы HHIlya Krukowski
ВидеоТестирование смарт-контрактов в Foundry. Установка foundry на mac os. #solidity #foundryDima HideCrypto - Web3 | Solidity
ВидеоFoundry Cheat Codes That Will 10x Your Solidity TestingWhat The Func? w/ Ed Zynda

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.