
Руководство по тестированию программ Solana
Содержание
- Введение
- Что такое тестирование?
- Модульное тестирование
- Интеграционное тестирование
- Сквозное тестирование (E2E)
- Совместное применение методов тестирования
- Как писать хорошие тесты
- Разработка через тестирование (TDD)
- Шаблон Arrange-Act-Assert (AAA)
- Лучшие отраслевые практики
- Написание базовых модульных тестов
- Модульное тестирование в Rust
- Модульное тестирование в TypeScript с Mocha и Chai
- Популярные фреймворки для тестирования Solana
- Bankrun
- solana-program-test
- solana-test-framework
- Пример сценария тестирования
- Программа
- Написание тестов
- Заключение
- Дополнительные ресурсы
Введение
Тестирование в блокчейн-среде выходит за рамки традиционной парадигмы тестирования ПО. Оно сопряжено с уникальными трудностями и более серьезными последствиями ошибок. В высокопроизводительной среде Solana с низкой задержкой допустимый уровень ошибок крайне мал. Автоматизированное тестирование становится не просто рекомендуемой практикой, а необходимостью для обеспечения надежности и безопасности программ, работающих в динамичной и требовательной среде Solana.
В этой статье рассматриваются основные виды автоматизированного тестирования: модульное, интеграционное и сквозное (E2E). Мы также разберем написание базовых модульных тестов на JavaScript/TypeScript и Rust, а затем проанализируем популярные фреймворки для тестирования Solana. В конце статьи приведен практический пример тестирования игровой программы «Царь горы».
Статья предполагает знание модели программирования и разработки программ Solana. Мы не будем рассматривать процесс создания программы или специфические концепции Solana — сосредоточимся на том, как тестировать программы Solana.
Если вы только начинаете работать с Solana, рекомендуем сначала прочитать предыдущие статьи в нашем блоге:
- Модель программирования Solana: введение в разработку на Solana
- Введение в Anchor: руководство по созданию программ Solana для начинающих
Эта статья также дополняет нашу предыдущую статью о безопасности программ Solana. Рекомендуем читать их вместе.
Что такое тестирование?
Тестирование позволяет проверить, работает ли фрагмент кода или целое приложение должным образом. Существует два основных вида тестирования:
- Ручное тестирование: процесс с участием человека, при котором тестовые сценарии выполняют разработчики, специалисты по обеспечению качества, пентестеры или другие ответственные лица
- Автоматизированное тестирование: процесс, ориентированный на код, при котором для программного выполнения заранее определенных тестовых сценариев пишутся скрипты
Ручное тестирование — очень гибкий процесс, не зависящий от типа тестируемого приложения. Оно хорошо подходит для проверки новых функций, удобства использования и доступности. Ручное тестирование опирается на представление тестировщика о том, как должно вести себя приложение. Однако из-за отсутствия инструментов оно по своей природе медленно, подвержено ошибкам, требует много времени и часто остается неполным, то есть охватывает не все сценарии.
Автоматизированное тестирование призвано устранить недостатки ручного. Например, обычно оно выполняется быстрее, особенно при параллельном запуске тестов. Автоматизированное тестирование менее подвержено человеческим ошибкам, поскольку следует заранее определенному сценарию. Оно расширяет тестовое покрытие благодаря эффективной обработке большого количества тестовых сценариев, что делает его хорошо масштабируемым решением. Однако из-за строгой объективности автоматизированное тестирование менее точно в случаях, требующих участия человека, субъективной оценки или критического мышления.
В этой статье мы сосредоточимся на автоматизированном тестировании программ Solana, поскольку ручное тестирование в mainnet обошлось бы дорого, а в devnet потребовало бы много времени. При этом перед выпуском кода в production процесс тестирования должен включать как ручные, так и автоматизированные проверки. Надежный процесс тестирования помогает сократить количество ошибок в production, выявляя их на ранних этапах разработки.
Существуют разные виды автоматизированного тестирования:
- Модульное тестирование
- Интеграционное тестирование
- Сквозное тестирование (E2E)
Модульное тестирование
Модульное тестирование — это процесс проверки самых маленьких функциональных единиц кода, который подтверждает их корректную работу. В идеале модули — это минимальные составные части программы, например отдельные функции или модули, из которых складывается готовый продукт. Основная идея заключается в том, что вся программа будет работать должным образом, если тщательно протестировать ее составные части.
Модульное тестирование лежит в основе разработки на Solana, поскольку позволяет убедиться, что каждая часть программы ведет себя должным образом. Возможность повторного использования тестов помогает проверять соответствие новых функций и обновлений спецификациям проекта и ожиданиям пользователей, заданным в тестовом сценарии. Поэтому модульное тестирование естественным образом способствует оптимизации и рефакторингу кода, чтобы улучшения не нарушали функциональность программы. Оно не только подтверждает корректную работу каждого фрагмента кода при разных условиях, но и обеспечивает эффективное и безопасное взаимодействие с блокчейном. Раннее обнаружение ошибок с помощью модульных тестов крайне важно, поскольку не позволяет потенциальным уязвимостям попасть в production.
Различные фреймворки упрощают модульное тестирование, в том числе моделирование сетевых условий и управление состоянием программы. Мы рассмотрим их далее в статье. С помощью модульного тестирования разработчики Solana могут добиться высокой надежности и производительности кода.
Интеграционное тестирование
Интеграционное тестирование выходит за рамки модульного и проверяет совместную работу разных компонентов программы. Проверка взаимодействия функций и модулей программы особенно важна при разработке на Solana, где такое взаимодействие часто бывает сложным и связано с финансовыми последствиями. Интеграционное тестирование помогает выявлять и устранять проблемы, незаметные при изолированной проверке компонентов, но возникающие при их взаимодействии. Это могут быть несовпадения форматов данных, несоответствия типов, зависимости программ или проблемы со сторонними API.
В Solana программы по своей природе взаимодействуют с другими программами, кошельками и оракулами. Интеграционное тестирование проверяет, что эти взаимодействия происходят должным образом. Даже если каждый компонент работает безупречно, их сочетание может привести к неожиданному поведению или снижению эффективности, проявляющимся в смоделированных условиях. Разработчики могут использовать разные фреймворки для имитации потоков транзакций и взаимодействия программ, максимально приближенных к реальным сценариям. Например, Bankrun — надежный и легковесный фреймворк для тестирования, который позволяет разработчикам перемещаться вперед и назад во времени и динамически задавать данные аккаунтов. При использовании solana-test-validator это невозможно. Интеграционное тестирование необходимо, чтобы программа была надежной, стабильной и готовой к требованиям сетевой среды Solana.
Сквозное тестирование (E2E)
Сквозное тестирование (E2E) завершает процесс тестирования. Оно оценивает полный рабочий процесс программы в условиях, приближенных к реальным. Эта методика отличается от модульного и интеграционного тестирования тем, что рассматривает программу с точки зрения пользователя: все возможные сценарии и функции, с которыми сталкивается конечный пользователь, должны работать должным образом.
E2E-тестирование необходимо для проверки соответствия программы функциональным требованиям и обеспечения беспрепятственного пользовательского опыта. Этот этап помогает выявить проблемы, которые могли остаться незаметными при модульном или интеграционном тестировании: задержки обработки транзакций, проблемы с сохранением состояния, необходимость оптимизации вычислительных единиц или неожиданные сетевые условия. Хотя E2E-тестирование обычно применяют ко всему dApp, проверка рабочих процессов программы и взаимодействия пользовательской транзакции с различными функциями и модулями крайне важна для создания успешной и безопасной программы.
Совместное применение методов тестирования
В процессе разработки важно применять многоуровневую стратегию, сочетающую модульное, интеграционное и E2E-тестирование. Каждый метод играет отдельную роль в жизненном цикле разработки и проверяет разные аспекты функциональности и производительности программы.
Модульное тестирование служит основой многоуровневого подхода и позволяет разработчикам быстро выявлять и устранять проблемы на самом детальном уровне кода. Оно эффективно проверяет объективную корректность отдельных функций или модулей, но не учитывает их совместную работу и роль в пользовательском опыте.
Интеграционное тестирование устраняет этот пробел: оно оценивает взаимодействие компонентов и выявляет проблемы, возникающие при их объединении. Однако только этого может быть недостаточно для полной оценки опыта конечного пользователя или поведения программы в реальных условиях.
E2E-тестирование дополняет модульное и интеграционное, моделируя реальные пользовательские сценарии и проверяя приложения целиком. Такой подход незаменим для оценки общего пользовательского опыта, но не дает детальных данных, необходимых для быстрого выявления и устранения конкретных проблем.
Объединив эти методы, разработчики могут создать надежную систему тестирования, охватывающую весь спектр потенциальных проблем. Комплексный подход не только повышает качество и безопасность программы, но и упрощает процесс разработки. Разработчики могут быстро принимать обоснованные решения и вносить изменения, будучи уверенными, что их проверят на нескольких уровнях. Сочетание этих методов необходимо, чтобы до развертывания убедиться в технической надежности программы Solana и ее соответствии ожиданиям пользователей в реальных условиях.
Как писать хорошие тесты
Эффективные тесты крайне важны для разработки надежных и безопасных программ Solana. Хороший тест сосредоточен на проверяемом поведении, а не на используемом фреймворке или деталях реализации кода. Разработчики могут создать эффективную стратегию тестирования и повысить качество кода, объединив принципы разработки через тестирование (TDD), шаблон Arrange-Act-Assert (AAA) и лучшие отраслевые практики.
Разработка через тестирование (TDD)
TDD — надежный подход к разработке ПО, основанный на написании тестов. Иными словами, главная идея состоит в том, чтобы писать тесты до фактического кода. Цикл TDD обычно включает три этапа:
- Напишите тест, который завершается неудачей: разработку следует начинать с теста для следующей функции, которую нужно добавить. Тест неизбежно завершится неудачей, поскольку проверяемой функции еще не существует
- Реализуйте код: разработчики должны написать минимальный объем кода, необходимый для успешного прохождения теста. Здесь важны скорость и простота
- Выполните рефакторинг: после успешного прохождения теста разработчики должны улучшить структуру и ясность кода, не меняя его поведения. Успешный тест служит защитой от несовместимых изменений. На этом этапе можно удалить дублирующийся код, разделить методы на меньшие части, перестроить иерархии наследования или сделать имена самодокументируемыми. В контексте Solana сюда может входить оптимизация количества CU, запрашиваемых для транзакции, сокращение числа CPI или упрощение потоков транзакций.
Хотя TDD не обязателен для создания хороших программ Solana, разработчикам стоит учитывать его философию. Он способствует тщательному подходу к созданию программ, поддерживает гибкий и адаптивный итеративный процесс разработки и полностью соответствует требованиям к точности, безопасности и эффективности. TDD побуждает разработчиков писать более чистый и целенаправленный код, оптимизированный для уникальных требований Solana к сети и производительности, например к использованию CU.
Шаблон Arrange-Act-Assert (AAA)
Шаблон AAA предлагает простую, но эффективную структуру для написания ясных, лаконичных и полезных тестов. Он формирует дисциплинированный подход, разделяя написание тестов на три четких этапа:
- Arrange: сначала настройте тестовую среду и подготовьте необходимые входные данные. Это может включать создание аккаунтов, имитацию их балансов или подготовку инструкций. Цель — создать контролируемый сценарий, воспроизводящий условия проверки поведения
- Act: выполните проверяемое действие. Здесь основное внимание уделяется действию, которое запускает нужное поведение. Например, что произойдет, если вызвать функцию x и передать ей аккаунт y?
- Assert: сопоставьте результат действия с ожидаемым. Этот этап необходим, чтобы определить, успешно ли пройден тест. Проверки могут варьироваться от простого сравнения значений до сложной валидации нескольких изменений состояния. Их реализация зависит от используемого фреймворка или протокола. Lighthouse — программа с инструкциями проверок, которые можно добавлять в транзакции, например для выявления нежелательных состояний, поддельных результатов симуляции или чрезмерных расходов. Преимущества и особенности Lighthouse мы подробно рассмотрим в отдельной статье.
Сильная сторона шаблона AAA — его адаптивность, благодаря которой он подходит для модульного, интеграционного и E2E-тестирования. Например:
- Модульное тестирование: тест функции может на этапе Arrange настроить состояние программы, на этапе Act вызвать функцию, а на этапе Assert проверить возвращаемое значение или итоговые изменения состояния
- Интеграционное тестирование: при проверке взаимодействия нескольких программ этап Arrange может включать развертывание программ и настройку начальных состояний, этап Act — выполнение соответствующих транзакций, а этап Assert — проверку итогового состояния каждой задействованной программы
- E2E-тестирование: E2E-тест программы может на этапе Arrange настроить ее состояние, на этапе Act выполнить полный ожидаемый пользовательский сценарий, например создать аккаунт и предложение, проголосовать за него и завершить этап голосования, а на этапе Assert сопоставить результаты с ожидаемыми
Шаблон AAA крайне важен для разработки программ. Он задает ориентированный на поведение подход, необходимый для проверки правильности работы программы. Тесты со структурой AAA легче понимать и поддерживать, поскольку настройка, действие и проверка четко разделены. Кроме того, AAA способствует созданию независимых, несвязанных тестов, сосредоточенных на конкретном поведении или взаимодействии.
Лучшие отраслевые практики
Написание хороших тестов актуально не только для разработки на Solana. Общие знания из разработки ПО можно применять при тестировании программ Solana, уделяя внимание ожидаемому поведению, а не погружаясь в детали реализации.
Например, модульные тесты обычно должны проверять публичный интерфейс метода: передавать определенные аргументы и подтверждать ожидаемый результат. Благодаря этому тесты остаются действительными даже при изменении внутренней реализации метода, если его поведение не меняется. В контексте разработки на Solana это означает, что изменения логики программы, не влияющие на ее внешнее поведение, не должны требовать рефакторинга тестов.
Распространенная ошибка при написании модульных тестов — чрезмерная зависимость от внутреннего устройства проверяемого метода. Например, ожидание, что определенные приватные методы будут вызваны конкретное число раз или реализованы определенным образом. Такие тесты слишком хрупки и могут перестать работать после любого рефакторинга, даже если фактическое поведение проверяемого метода не изменилось. Вместо этого следует сосредоточиться на результатах и внешне наблюдаемых побочных эффектах метода. Инструменты анализа покрытия кода помогают обеспечить полноту тестов без чрезмерной зависимости от внутренних механизмов.
Применение этих практик при разработке на Solana повышает надежность и адаптивность программ. Сосредоточенность на поведении, а не деталях реализации, позволяет создавать более устойчивый и удобный в сопровождении код. Это особенно важно при развертывании в динамичной сетевой среде, такой как Solana. Такой подход гарантирует, что изменения логики программы не потребуют масштабного повторного тестирования и что программа будет готова к развертыванию.
Теперь приступим к написанию тестов.
Написание базовых модульных тестов
Модульное тестирование в Rust
Rust использует особый подход к модульному тестированию и рекомендует разработчикам размещать тесты в тех же файлах, что и код. Для этого используется tests модуль, ограниченный #[cfg(test)] атрибутом. Атрибут тестирования гарантирует, что тесты компилируются и запускаются только при явном тестировании ПО командой cargo test, то есть не запускаются командой cargo build. Разработчики также могут исключить тесты из обычного запуска с помощью атрибута #[ignore]. Это удобно для особенно медленных тестов, которые по-прежнему можно запустить явно командой cargo test -- --ignored.
В качестве примера рассмотрим следующую функцию Rust:
pub fn bubble_sort<T: Ord>(array: &mut [T]) {
if array.is_empty() {
return;
}
for i in 0..array.len() {
for j in 0..array.len() - 1 - i {
if array[j] > array[j + 1] {
array.swap(j, j + 1);
}
}
}
}Пузырьковая сортировка — это алгоритм, который многократно перебирает элементы списка, сравнивает текущий элемент со следующим и при необходимости меняет их местами. Чтобы проверить правильность работы этой функции, можно написать следующие тесты:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bubble_sort() {
let mut test1 = vec![12, 39, 4, 36, 777];
assert_eq!(bubble_sort(&mut test1), vec![4, 12, 36, 39, 777]);
let mut test2 = vec![21, 55, 14, -123, 32, 0];
assert_eq!(bubble_sort(&mut test2), vec![-123, 0, 14, 21, 32, 55]);
let mut test3 = vec!["Orange", "Pear", "Apple", "Grape", "Banana"];
assert_eq!(bubble_sort(&mut test3), vec!["Apple", "Banana", "Grape", "Orange", "Pear"]);
}
}Этот пример показывает, как наш модуль tests снабжен атрибутом #[cfg(test)]. Внутри модуля мы импортируем все публичные элементы родительского модуля в область видимости текущего тестового модуля с помощью use super::*;. Затем выполняем несколько тестовых сценариев и проверяем ожидаемый вид отсортированных векторов. Rust предоставляет несколько макросов для проверок: assert! для общей проверки истинности, assert_eq! для равенства и assert_ne! для неравенства. Эти проверки лежат в основе стратегии тестирования Rust — для начала написания тестов больше ничего не требуется.
Рассмотрим простой пример: представьте функцию, определяющую, достаточно ли на аккаунте средств для оплаты транзакции:
pub fn has_sufficient_balance(account_balance: u64, transaction_fee: u64) -> bool {
account_balance >= transaction_fee
}Эта функция принимает два аргумента: текущий баланс аккаунта и ожидаемую комиссию за транзакцию. Она возвращает true, если баланса достаточно для оплаты комиссии, и false в противном случае. Это легко проверить следующим модульным тестом:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sufficient_funds_for_transaction() {
let account_balance = 1_000_000;
let transaction_fee = 5_000;
assert!(has_sufficient_balance(account_balance, transaction_fee));
}
#[test]
fn insufficient_funds_for_transaction() {
let account_balance = 1_000;
let transaction_fee = 5_000;
assert!(!has_sufficient_balance(account_balance, transaction_fee));
}
}В первом тесте мы проверяем, что has_sufficient_balance возвращает true, когда баланс аккаунта значительно превышает комиссию за транзакцию, то есть средств достаточно. Во втором тесте мы проверяем, что has_sufficient_funds возвращает false, когда баланс аккаунта меньше комиссии, то есть средств недостаточно.
Другие важные особенности тестирования в Rust
В Rust есть атрибут #[should_panic] для обозначения тестов, которые при определенных условиях должны вызвать панику. Он полезен для проверки путей обработки ошибок и указания ожидаемых сообщений паники:
#[test]
#[should_panic(expected = "Divide-by-zero error")]
fn test_divide_by_zero() {
divide_non_zero_result(0, 0);
}В отличие от многих других языков, Rust позволяет напрямую тестировать приватные функции. Это дает возможность проводить более подробное модульное тестирование и охватывать тестами все аспекты функциональности кода.
Rust также поддерживает более продвинутые способы организации тестов:
- Вложенные модули: в сложных проектах тесты можно организовать во вложенные модули, создав четкую иерархическую структуру, которая повторяет структуру проекта
- Тесты на основе результата: Rust позволяет тестам возвращать тип
Result<(), E>. Благодаря этому разработчики могут использовать в тестах оператор?для более выразительной обработки ошибок
Модульное тестирование в TypeScript с Mocha и Chai
TypeScript стал популярным выбором для тестирования программ благодаря полному доминированию Anchor как лингва франка разработки на Rust для Solana. При выполнении команды anchor init в новых проектах Anchor по умолчанию инициализируются тестовый фреймворк Mocha и библиотека проверок Chai.
Mocha — многофункциональный тестовый фреймворк JavaScript, работающий на Node.js. Он значительно упрощает асинхронное тестирование. При разработке на Solana Mocha преимущественно используют для тестирования клиентской логики dApp и других взаимодействий с блокчейном.
Chai — библиотека проверок, которую можно использовать с любым тестовым фреймворком JavaScript, например Mocha. Она предоставляет разработчикам набор функций для записи проверок в удобочитаемом стиле. Интерфейсы expect, should и assert в Chai позволяют писать комплексные тесты, которые легко читать и создавать. Для повышения читаемости проверок интерфейсы expect и should используют языковые цепочки, то есть связываемые геттеры. Благодаря Chai выражение expect({a: 1, b: 2}).to.not.have.any.keys(“c”, “d”); представляет собой понятную и корректную проверку.
Например, если создать проект hello_world командой anchor init hello_world, в каталоге hello_world/tests появится следующий тестовый файл hello_world.ts:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { HelloWorld } from "../target/types/hello_world";
describe("hello_world", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.HelloWorld as Program<HelloWorld>;
it("Is initialized!", async () => {
// Add your test here.
const tx = await program.methods.initialize().rpc();
console.log("Your transaction signature", tx);
});
});Разберем, что все это означает.
Mocha использует блоки describe для группировки тестов и функции it для определения тестовых сценариев. Этот пример следует шаблону AAA, обеспечивающему структурированный подход к тестированию:
- Arrange: здесь
anchor.setProvider(anchor.AnchorProvider.env());настраивает клиент Anchor на использование стандартного провайдера среды, который обычно указывает на локальный тестовый валидатор Solana. Затем объявление константыprogramинициализирует экземпляр тестируемой программы, чтобы мы могли вызывать ее методы в тесте - Act: в тестовом сценарии
“Is initialized!”мы вызываем методinitializeнашей программы и отправляем транзакцию - Assert: в этом тестовом сценарии мы записываем сигнатуру транзакции в журнал, не выполняя проверку. Обычно на этом этапе подключают Chai. В простейшем случае стандартный тестовый код можно дополнить проверкой наподобие
expect(tx).to.be.a(“string”);. Более подробный тест может получить и изучить состояние программы после инициализации, а затем проверить его соответствие ожидаемым значениям
Сочетание Mocha и Chai с шаблоном AAA, который по умолчанию настроен в проектах Anchor, создает надежную среду для тестирования программ. Четко настраивая тестовую среду, вызывая методы программы и проверяя результаты, разработчики Solana могут обеспечить предсказуемую и надежную работу программ.
Представьте, например, что вы разрабатываете программу, которая позволяет пользователям вносить SOL в хранилище Solana и выводить их оттуда. Вот как может выглядеть тест на TypeScript с Mocha и Chai, проверяющий правильность внесения средств:
import { expect } from "chai";
import { PublicKey } from "@solana/web3.js";
import { depositSOL } from "../src/vault";
describe("Vault Program", function() {
describe("Deposit functionality", function() {
it("should correctly deposit SOL into the vault", async function() {
const vaultPublicKey = new PublicKey(/* vault public key */);
const userPublicKey = new PublicKey(/* user public key */);
const depositAmount = 1; // 1 SOL
const initialVaultBalance = await getVaultBalance(vaultPublicKey);
await depositSOL(vaultPublicKey, userPublicKey, depositAmount);
const finalVaultBalance = await getVaultBalance(vaultPublicKey);
expect(finalVaultBalance).to.equal(initialVaultBalance + depositAmount);
});
});
});В этом примере тестируется гипотетическая функция depositSOL, которая обрабатывает внесение SOL в хранилище. Тест проверяет, что после внесения средств баланс хранилища увеличился на правильную сумму. Мы используем предполагаемую вспомогательную функцию getVaultBalance, которая получает текущий баланс хранилища.
Другие важные особенности тестирования в TypeScript с Mocha и Chai
Статическая система типов TypeScript иногда усложняет написание тестов, особенно при работе со сложными или недостаточно четко определенными типами. Чтобы избежать связанных с типами проблем в тестах, используйте утверждения типов. Однако убедитесь, что они не скрывают потенциальные ошибки времени выполнения, вызванные некорректными типами.
При создании моков объектов или функций в TypeScript убедитесь, что они соответствуют правильным типам. Такие библиотеки, как ts-sinon и ts-mockito, помогают создавать типобезопасные моки, чтобы тесты оставались точными и отражали реальное поведение программы.
Mocha предоставляет методы only и skip для эксклюзивного запуска или пропуска отдельных тестов. Они удобны при разработке, но их легко случайно отправить в production, что приведет к неполному запуску тестов. Перед отправкой тестов в production всегда проверяйте наличие only или skip. Кроме того, будьте осторожны при использовании хуков Mocha, то есть beforeEach, afterEach, before и after, с асинхронным кодом. Убедитесь, что промисы корректно обрабатываются с помощью async/await, либо вызывайте метод обратного вызова done, чтобы избежать незавершенных промисов или невызванных обратных вызовов.
При использовании expect().to.deep.equal() из Chai учитывайте его поведение с объектами, содержащими динамически создаваемые свойства, например даты или случайные значения. Эти свойства могут приводить к неожиданным сбоям в тестах, ожидающих глубокого равенства. По возможности используйте expect().to.include() из Chai для более точечных проверок.
Популярные фреймворки для тестирования Solana
Bankrun
Банк отслеживает клиентские аккаунты, управляет выполнением программ и поддерживает целостность и развитие реестра Solana. По сути, это снимок реестра на определенный момент времени, который содержит состояние, сформированное транзакциями конкретного блока.
Bankrun — легковесный и гибкий фреймворк для тестирования программ Solana, написанный на Node.js. Он ориентирован на простоту и скорость, поэтому разработчики могут быстро писать и запускать тесты для своих программ. Главная ценность Bankrun в том, что этот фреймворк позволяет разработчикам моделировать банки Solana и взаимодействовать с ними в контролируемой и эффективной среде. Bankrun упрощает тестирование, воспроизводя динамику банков Solana без накладных расходов, обычно связанных с настройкой такой среды.
В основе Bankrun лежит легковесный BanksServer, который имитирует поведение RPC-ноды, но обеспечивает значительно более высокую производительность и гибкость. Разработчики могут взаимодействовать с этим сервером через BanksClient. Этот клиент предоставляет полный набор инструментов и методы для получения балансов аккаунтов, проверки статусов транзакций и моделирования транзакций. В частности, метод tryProcessTransaction позволяет обрабатывать транзакции, которые должны завершиться сбоем, не вызывая ошибок JavaScript. Благодаря этому разработчики могут напрямую проверять конкретные сценарии сбоев или сообщения журналов.
GitHub-репозиторий Futarchy от Meta-DAO — отличный пример использования Bankrun для тестирования кода, готового к эксплуатации.
Интеграция с Anchor
Интегрировать Bankrun с Anchor очень просто. С помощью startAnchor разработчики могут автоматически развернуть в тестовой среде все программы из рабочего пространства Anchor. Это гарантирует, что тесты точно воспроизводят поведение программы в полноценной среде Solana. В документации Bankrun приведен следующий пример кода:
import { startAnchor } from "solana-bankrun";
import { PublicKey } from "@solana/web3.js";
test("anchor", async () => {
const context = await startAnchor("tests/anchor-example", [], []);
const programId = new PublicKey(
"Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS",
);
const executableAccount = await context.banksClient.getAccount(programId);
expect(executableAccount).not.toBeNull();
expect(executableAccount?.executable).toBe(true);
});Пакет anchor-bankrun — мощное расширение для совместной работы Anchor и Bankrun. Он экспортирует класс BankrunProvider, который при тестировании можно использовать как полноценную замену AnchorProvider.
Запись произвольных аккаунтов с помощью Bankrun
Одна из ключевых возможностей Bankrun — запись произвольных данных аккаунтов. Она позволяет разработчикам обходить ограничения состояний аккаунтов и обеспечивает беспрецедентную гибкость. Например, разработчик может смоделировать аккаунт со значительным объемом USDC, не имея пары ключей минта USDC. Это крайне полезно для тестирования, поскольку не требует манипуляций с реальными токенами и тем самым упрощает подготовку сложных сценариев.
В документации Bankrun приведен пример кода для бесконечного минта USDC, демонстрирующий эту возможность с помощью функции start. Функция start подготавливает тестовую среду, развертывая программы и задавая указанные данные аккаунтов.
Путешествия во времени
Еще одна самостоятельная возможность Bankrun — путешествия во времени, то есть управление временем для тестирования. Разработчики могут перематывать часы кластера Solana вперед или назад, то есть изменять сисвар Clock, чтобы мгновенно моделировать определенные временные условия. Эта возможность критически важна для тестирования программ с логикой, зависящей от времени: графиков вестинга, блокировок токенов и любых функций, которые активируются в определенный момент.
Путешествовать во времени легко благодаря методу setClock. Он позволяет установить текущее время кластера на заданную метку времени Unix и перенести всю тестовую среду в этот момент прошлого или будущего. Операции и транзакции в тесте выполняются так, будто указанное время является текущим. Это позволяет точно оценить поведение программы в таких условиях.
Ниже приведен очень простой пример путешествия во времени с помощью Bankrun:
import { start } from "solana-bankrun";
import { PublicKey, Transaction, SystemProgram } from "@solana/web3.js";
async function simulateTimeTravel(context, secondsForward) {
const newTimestamp = context.clock.unixTimestamp + secondsForward;
context.adjustClock(newTimestamp);
}
test("One Year Later...", async () => {
const context = await start([], []);
const { banksClient, payer } = context;
// Simulate setting the cluster clock forward by one year (in seconds)
const oneYearInSeconds = 365 * 24 * 60 * 60;
await simulateTimeTravel(context, oneYearInSeconds);
// Proceed with tests assuming the future time
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: PublicKey.unique(),
lamports: 100,
}),
);
transaction.recentBlockhash = context.lastBlockhash;
transaction.sign(payer);
await banksClient.processTransaction(transaction);
// Add assertions here to test expected future behavior
});Bankrun и solana-test-validator
Выбор между Bankrun и solana-test-validator во многом зависит от конкретных требований тестовых сценариев. Благодаря скорости, гибкости и специализированным возможностям Bankrun лучше подходит для большинства сценариев разработки, особенно если нужны быстрые итерации или детальное моделирование. Однако solana-test-validator по-прежнему актуален для тестов, которые зависят от реального поведения валидатора и используют RPC-методы, не поддерживаемые BanksServer.
solana-program-test
Крейт solana-program-test предоставляет написанный на Rust фреймворк, предназначенный специально для тестирования программ Solana. В основе этого фреймворка лежит BanksClient. Он моделирует работу банка Solana и позволяет разработчикам развертывать программы, взаимодействовать с ними и оценивать их поведение в тестовых условиях, имитирующих mainnet, как и Bankrun. Структура ProgramTest дополняет BanksClient и служит инструментом для инициализации тестовой среды. Она упрощает разработку указанных программ и настройку необходимых аккаунтов. Дополнительные структуры, такие как BanksTransactionResultWithMetadata, InvokeContext и ProgramTestContext, предоставляют подробные сведения и контекст для транзакций, обработанных во время тестирования. Это упрощает отладку и проверку.
Чтобы упростить локальную разработку и тестирование, solana-program-test автоматически предварительно загружает несколько программ:
- SPL Token (и его версию 2022 года)
- SPL Memo (версии 1.0 и 3.0)
- SPL Associated Token Account
Эти предварительно загруженные программы ускоряют тестирование и позволяют сосредоточиться на главном, поскольку разработчикам не нужно вручную настраивать распространенные программы.
В GitHub-репозитории Marginfi есть несколько отличных примеров использования solana-program-test в коде, готовом к эксплуатации. В руководстве Bonfida для разработчиков также подробно описано написание интеграционных тестов с помощью фреймворка solana-program-test.
solana-test-framework
solana-test-framework — расширение solana-program-test, разработанное компанией Halborn. Оно расширяет возможности тестовой среды, добавляя в BanksClient, RpcClient, ProgramTest и ProgramTestContext несколько вспомогательных методов. Как и в Bankrun, расширения ProgramTestContext позволяют реализовать сложные сценарии тестирования, в которых разработчики могут переходить к определенным меткам времени и обновлять цены оракулов.
Эти расширения предоставляют следующие улучшения:
- Управление транзакциями: упрощает сборку, подписание и оплату транзакций через
transaction_from_instructions - Десериализация аккаунтов: упрощает получение и десериализацию аккаунтов Anchor и Borsh с помощью
get_account_with_anchor and get_account_with_borshсоответственно - Создание аккаунтов и развертывание программ: позволяет разработчикам эффективно настраивать тестовую среду с помощью таких функций, как
create_account,create_token_mint,create_token_accountиdeploy_program.
solana-test-framework поддерживает как внешние кластеры, так и моделируемую среду выполнения. Он совместим с несколькими версиями Solana и Anchor, включая Solana с 1.9 по 1.14 и соответствующие версии Anchor для 1.9, 1.10 и 1.14.
Пример сценария тестирования
Программа
Рассмотрим следующую программу:
use anchor_lang::prelude::*;
use anchor_lang::solana_program::system_instruction;
use solana_program::program::invoke;
declare_id!("3vMZa7r3CpHGejvXYbUpPXmm54FxCDPF1QAYnnzL88J9");
#[program]
pub mod king_of_the_hill {
use super::*;
pub fn initialize(ctx: Context<Initialize>, initial_prize: u64) -> Result<()> {
// In case the person who went first didn't send any SOL as the initial prize
require!(initial_prize > 0, ErrorCode::NeedAnInitialPrize);
let game_state = &mut ctx.accounts.game_state;
game_state.king = ctx.accounts.initial_king.key();
game_state.prize = initial_prize;
let transfer_instruction = system_instruction::transfer(
&ctx.accounts.initial_king.key(),
&ctx.accounts.prize_pool.key(),
initial_prize,
);
invoke(
&transfer_instruction,
&[
ctx.accounts.initial_king.to_account_info(),
ctx.accounts.prize_pool.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
)?;
Ok(())
}
pub fn become_king(ctx: Context<BecomeKing>, new_prize: u64) -> Result<()> {
require!(
new_prize > ctx.accounts.game_state.prize,
ErrorCode::BidTooLow
);
let transfer_to_pool_instruction = system_instruction::transfer(
&ctx.accounts.payer.key(),
&ctx.accounts.prize_pool.key(),
new_prize,
);
// Send the new king's funds to the pool
invoke(
&transfer_to_pool_instruction,
&[
ctx.accounts.payer.to_account_info(),
ctx.accounts.prize_pool.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
)?;
// Send the old king's funds back
ctx.accounts.prize_pool.sub_lamports(ctx.accounts.game_state.prize);
ctx.accounts.king.add_lamports(ctx.accounts.game_state.prize);
ctx.accounts.game_state.king = ctx.accounts.payer.key();
ctx.accounts.game_state.prize = new_prize;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = initial_king,
space = 8 + 32 + 8 + 1,
seeds = [b"game_state"],
bump,
)]
pub game_state: Account<'info, GameState>,
#[account(mut)]
pub initial_king: Signer<'info>,
#[account(
init,
payer = initial_king,
space = 8 + 8,
seeds = [b"prize_pool"],
bump,
)]
/// CHECK: This is okay - it's a PDA to store SOL and doesn't need a data layout
pub prize_pool: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct BecomeKing<'info> {
#[account(
mut,
has_one = king,
)]
pub game_state: Account<'info, GameState>,
#[account(mut)]
/// CHECK: This is okay - it's only receiving SOL and we don't need any other access
pub king: UncheckedAccount<'info>,
#[account(mut)]
pub payer: Signer<'info>,
#[account(
mut,
seeds = [b"prize_pool"],
bump,
)]
/// CHECK: This is okay - it's a PDA to store SOL and doesn't need a data layout
pub prize_pool: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct GameState {
pub king: Pubkey,
pub prize: u64,
pub prize_pool_bump: u8,
}
#[error_code]
pub enum ErrorCode {
#[msg("The initial prize must be greater than zero")]
NeedAnInitialPrize,
#[msg("The bid must be higher than the current prize")]
BidTooLow,
#[msg("Invalid prize pool account")]
InvalidPrizePoolAccount,
}Эта программа реализует в Solana простую игру «Царь горы». Пользователь становится «царем», если отправляет в призовой пул больше SOL, чем текущий царь. Когда новый царь занимает место предыдущего, отправленные предыдущим царем SOL возвращаются ему.
Программа работает следующим образом:
- Инициализация: эта функция настраивает игру с первым царем, то есть первым игроком, который инициализирует игру, и начальной суммой приза. Начальный приз должен быть больше нуля. Затем функция переводит начальный приз от первого царя в призовой пул
- Стать царем: эта функция позволяет новому игроку стать царем, предложив больше SOL, чем составляет текущий приз. Она переводит текущий приз уходящему царю и обновляет призовой пул ставкой нового царя, который занимает его место. Ставка должна превышать текущий приз
Написание тестов
Следующий код позволяет успешно протестировать игру «Царь горы»:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KingOfTheHill } from "../target/types/king_of_the_hill";
import { assert } from "chai";
const web3 = require("@solana/web3.js");
describe("King of the Hill Tests", () => {
// Configure the client to use the local cluster.
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.KingOfTheHill
as Program<KingOfTheHill>;
let initialKing, newKing;
let gameStatePDA, prizePoolPDA;
// Utility function for airdrops
async function fundWallet(account, amount) {
const publicKey = account.publicKey ? account.publicKey : account;
await provider.connection.confirmTransaction(
await provider.connection.requestAirdrop(publicKey, amount),
"confirmed"
);
}
before(async () => {
initialKing = web3.Keypair.generate();
newKing = web3.Keypair.generate();
await fundWallet(initialKing, 25 * web3.LAMPORTS_PER_SOL);
await fundWallet(newKing, 30 * web3.LAMPORTS_PER_SOL);
[gameStatePDA] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("game_state")],
program.programId
);
[prizePoolPDA] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("prize_pool")],
program.programId
);
});
it("Initializes the game correctly", async () => {
// Arrange
await fundWallet(gameStatePDA, 1 * web3.LAMPORTS_PER_SOL);
await fundWallet(prizePoolPDA, 1 * web3.LAMPORTS_PER_SOL);
let initialPrize = new anchor.BN(1 * web3.LAMPORTS_PER_SOL);
// Act
const tx = await program.methods
.initialize(initialPrize)
.accounts({
gameState: gameStatePDA,
initialKing: initialKing.publicKey,
prizePool: prizePoolPDA,
systemProgram: web3.SystemProgram.programId,
})
.signers([initialKing])
.rpc();
// Assert
let gameState: any = await program.account.gameState.fetch(gameStatePDA);
assert.equal(gameState.king.toBase58(), initialKing.publicKey.toBase58());
assert.equal(
gameState.prize.toString(),
new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toString()
);
});
it("Changes the king correctly", async () => {
// Arrange
const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);
// Act
const becomeKingTx = await program.methods.becomeKing(newPrize)
.accounts({
gameState: gameStatePDA,
king: initialKing.publicKey, // Correct usage of current king
payer: newKing.publicKey, // New king who pays and becomes the king
prizePool: prizePoolPDA,
systemProgram: web3.SystemProgram.programId,
})
.signers([newKing]) // Signing by newKing
.rpc();
// Assert
const initialKingBalanceAfter = await provider.connection.getBalance(initialKing.publicKey);
const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
assert.ok(initialKingBalanceAfter >= expectedBalance, "Old king did not receive the funds back correctly");
// Fetch the updated game state.
const updatedGameState = await program.account.gameState.fetch(gameStatePDA);
// Assertions to confirm the state has updated as expected.
assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "King should be updated to newKing.");
assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "Prize should be updated to newPrize.");
})
});Разберем все по порядку.
Сначала импортируем зависимости и настраиваем тестовую среду в Anchor. В этом примере тесты написаны на TypeScript с использованием Mocha и Chai и выполняются на localhost:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KingOfTheHill } from "../target/types/king_of_the_hill";
import { assert } from "chai";
const web3 = require("@solana/web3.js");Мы используем describe для группировки тестовых случаев. Также настраиваем клиент для работы с локальным кластером, правильно задаем программу, инициализируем переменные для первого царя, нового царя, PDA состояния игры и PDA призового пула, а затем создаем вспомогательную функцию для упрощения аирдропов:
describe("King of the Hill Tests", () => {
// Configure the client to use the local cluster.
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.KingOfTheHill as Program<KingOfTheHill>;
let initialKing, newKing;
let gameStatePDA, prizePoolPDA;
// Utility function for airdrops
async function fundWallet(account, amount) {
const publicKey = account.publicKey ? account.publicKey : account;
await provider.connection.confirmTransaction(
await provider.connection.requestAirdrop(publicKey, amount),
"confirmed"
);
}
// Other code
});Затем с помощью хука before настраиваем и пополняем пары ключей первого и нового царей, а также выводим PDA для состояния игры и призового пула. Этот блок выполняется один раз перед тестовыми случаями, что позволяет четко структурировать каждый из них по шаблону AAA:
before(async () => {
initialKing = web3.Keypair.generate();
newKing = web3.Keypair.generate();
await fundWallet(initialKing, 25 * web3.LAMPORTS_PER_SOL);
await fundWallet(newKing, 30 * web3.LAMPORTS_PER_SOL);
[gameStatePDA] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("game_state")],
program.programId
);
[prizePoolPDA] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("prize_pool")],
program.programId
);
});Первый тестовый случай очень прост: мы проверяем, правильно ли инициализируется игра. На этапе подготовки пополняем PDA состояния игры и призового пула, чтобы позднее взаимодействовать с ними, и задаем начальный приз в размере 1 SOL. Затем вызываем метод initialize с initialPrize. В качестве аккаунтов передаем PDA состояния игры, первого царя, PDA призового пула и системную программу. Первый царь подписывает эту операцию. После этого проверяем, правильно ли обновились царь и приз в состоянии игры:
it("Initializes the game correctly", async () => {
// Arrange
await fundWallet(gameStatePDA, 1 * web3.LAMPORTS_PER_SOL);
await fundWallet(prizePoolPDA, 1 * web3.LAMPORTS_PER_SOL);
let initialPrize = new anchor.BN(1 * web3.LAMPORTS_PER_SOL);
// Act
const tx = await program.methods
.initialize(initialPrize)
.accounts({
gameState: gameStatePDA,
initialKing: initialKing.publicKey,
prizePool: prizePoolPDA,
systemProgram: web3.SystemProgram.programId,
})
.signers([initialKing])
.rpc();
// Assert
let gameState: any = await program.account.gameState.fetch(gameStatePDA);
assert.equal(gameState.king.toBase58(), initialKing.publicKey.toBase58());
assert.equal(
gameState.prize.toString(),
new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toString()
);
});Следующий тестовый случай проверяет, может ли другой пользователь стать царем. На этапе подготовки получаем начальный баланс царя и устанавливаем новый приз в размере 2 SOL. Затем вызываем функцию becomeKing и передаем ей последнюю сумму приза. В качестве аккаунтов передаем PDA состояния игры, открытый ключ текущего царя, нового царя в качестве плательщика, PDA призового пула и системную программу. Новый царь назначается подписантом. Затем проверяем, получил ли предыдущий царь обратно SOL, внесенные им для получения статуса царя, и правильно ли обновилось состояние игры:
it("Changes the king correctly", async () => {
// Arrange
const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);
// Act
const becomeKingTx = await program.methods.becomeKing(newPrize)
.accounts({
gameState: gameStatePDA,
king: initialKing.publicKey, // Correct usage of current king
payer: newKing.publicKey, // New king who pays and becomes the king
prizePool: prizePoolPDA,
systemProgram: web3.SystemProgram.programId,
})
.signers([newKing]) // Signing by newKing
.rpc();
// Assert
const initialKingBalanceAfter = await provider.connection.getBalance(initialKing.publicKey);
const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
assert.ok(initialKingBalanceAfter >= expectedBalance, "Old king did not receive the funds back correctly");
// Fetch the updated game state.
const updatedGameState = await program.account.gameState.fetch(gameStatePDA);
// Assertions to confirm the state has updated as expected.
assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "King should be updated to newKing.");
assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "Prize should be updated to newPrize.");
})В этом сценарии тестирования мы проверили основные функции программы «Царь горы». Модульные тесты подтвердили корректность логики программы на детальном уровне. Проверив правильность смены царя, мы также убедились, что программа работает ожидаемым образом в условиях, имитирующих реальную среду. Эти тесты показывают, насколько комплексная стратегия тестирования важна для качества и функциональности программы «Царь горы».
Заключение
Тестирование — основа разработки безопасных, надежных и эффективных программ Solana. В этой статье мы рассмотрели, почему важно сочетать модульное, интеграционное и E2E-тестирование на всех этапах жизненного цикла разработки программы. Объединяя эти методики и используя мощные фреймворки для тестирования, такие как Bankrun, solana-program-test и solana-test-framework, разработчики могут значительно повысить качество своих программ Solana. Продолжая свой путь разработчика Solana, опирайтесь на рассмотренные здесь принципы, практики и примеры, чтобы создавать надежные, эффективные и безопасные программы.
Если вы дочитали до этого места, спасибо, анон! Укажите свой адрес электронной почты ниже, чтобы не пропустить новости Solana. Хотите узнать больше? Читайте новые статьи в блоге Helius и продолжайте знакомство с Solana уже сегодня.
Дополнительные ресурсы
Похожие статьи
Подпишитесь на Helius
Следите за новостями разработки на Solana и получайте уведомления о наших публикациях


