Следующие пару дней мы посвятим теме invariant тестов. Сейчас о них много, кто говорит, но крайне мало команд используют в своей работе. А все из-за того, что этот паттерн, возможно, для некоторых разработчиков несколько сложен для понимания и написания.
Для начала поговорим о том, что вообще такое инварианты.
Я сам, давно, потратил некоторое время, пытаясь усвоить это определение. И то, что практически в каждом описании, которое я встречал, было "ивариант - это некоторое условие, которое всегда должно оставаться неизменным..." не давало мне полного представления.
Ну вот есть у нас переменные, есть константы, есть immutables, а как установить инвариант? Как его прописать в коде?
Забавно было понять для себя, что инварианты появляются сами собой при написании проекта. Ну, в том смысле, что для каждого протокола они могут быть и одинаковыми и разными. И заранее определить количество инвариантов в своем будущем проекте крайне сложно. К слову сказать, их может и не быть вовсе!
Попробую объяснить, что такое инвариант на простом популярном примере.
Вот мы написали некий контракт, в котором есть наш собственный токен. Наши пользователи могут его минтить, пересылать друг другу, сжигать и любые другие действия. При этом мы понимаем, что общее количество сминченых токенов будет равно количеству токенов на балансах наших пользователей. Т.е. если сложить все балансы токенов пользователей - сумма будет равна общему количеству токенов когда-либо выпущенных. Это и есть наш первый инвариант.
Это такое условие в нашем коде, которое всегда должно оставаться одинаковым. Вне зависимости от действий пользователя: перевода, сжигания и т.д.
Другим инвариантом, например, может быть условие, что каждый ваш конкретный пользователь токена не может пересылать больше, чем у него есть на балансе.
Или, в случае nft, что, например, у пользователя может быть только один токен на счету и не больше.
Вообще эти условия нужно искать в своем проекте уже после его написания. Это не только может помочь понять сой код чуть лучше, но найти потенциальные уязвимости.
Теперь давайте поговорим об инвариант тестах в Foundry на простом примере. У нас есть небольшой контракт:
contract InvariantIntro {
bool public flag;
function func_1() external {}
function func_2() external {}
function func_3() external {}
function func_4() external {}
function func_5() external {
flag = true;
}
}Представим, что переменная flag всегда должна оставаться false не смотря ни на что.
И небольшой тест к нему:
contract IntroInvariantTest is Test {
InvariantIntro private target;
function setUp() public {
target = new InvariantIntro();
}
function invariant_flag_is_always_false() public {
assertEq(target.flag(), false);
}
}Мы как обычно создаем переменную объекта нашего контракта и в setUp() подключаем его. А вот дальше...
Помните перед каждым тестом нам требовалось прописывать название, начиная с test..., например testIncrement(), testBlaBlaBla() и т.д.?
С инвариант тестами теперь нужно писать ключевое слово invariant. Это покажет Foundry, что мы переходим на другой вид тестов.
Как вообще работают инвариант тесты?
Если в обычных тестах (unit test) мы хотели сделать проверку конкретной части кода, например, require, то перед каждым конкретным тестом выполнялась функция setUp() и разворачивался новый контракт и его состояние памяти обнулялось.
Каждый тест мы проводили "с чистого листа".
Также и в случае с фаззингом. Каждый отдельный тест - это обнуленное состояние контракта. Поэтому, в случае unit и fuzz тестов, можно встретить определение - stateless tests.
Инвариант тесты - это уже stateful tests, когда состояние контракта не обнуляется, а наоборот, запоминается для каждого последующего вызова функции.
Смотрите, что получается:
function invariant_flag_is_always_false() public {
assertEq(target.flag(), false);
}В этот тесте через assertEq мы проверяем нашу переменную, которая всегда должна держать состояние false.