Я хочу поблагодарить коллег: Сергея Немеша, Михаила Попсуева, Евгения Бабича и Игоря Титаренко за консультации, отзывы и тестирование. Я также хочу сказать спасибо команде PolySwarm за разработку оригинальной версии Perigord.
Это перевод моей статьи, опубликованной впервые на английском на Medium
Тестирование всегда было неотъемлемой частью разработки программного обеспечения, хотя и не самой приятной. Когда речь идет о смарт-контрактах, необходимо тщательное тестирование с исключительным вниманием к деталям, т.к. ошибки будет невозможно исправить после развертывания в блокчейн сети. За последние годы, сообщество Ethereum создало множество инструментов для разработки смарт-контрактов. Некоторые из них не стали популярными, например, Vyper — диалект Python для написания смарт-контрактов. Другие, такие как Solidity, стали признанным стандартом. Наиболее обширную документацию по тестированию смарт-контрактов на сегодняшний день предоставляет связка Truffle&Ganache. Оба этих инструмента имеют хорошую документацию, многие кейсы уже решались на Stack Overflow и подобных ресурсах. Однако, у этого подхода есть один важный недостаток: для написания тестов нужно использовать Node.js.
Ловушки JavaScript
Даже если вы не поклонник языков программирования со статической типизацией и любите JavaScript, подумайте о том, что можно сделать опечатку и начать сравнивать результат выполнения функции, которая возвращает строку с булевым значением, используя устаревший метод equal вместо strictEqual.
let proposalExists = await voting.checkProposal();
assert.equal(proposalExists, true, 'Proposal should exist');
Если checkProposal возвращает строки “yes” или “no”, вы всегда преобразуете их в true. Динамическая типизация скрывает множество таких ловушек, и даже опытные программисты могут совершать подобные промахи, работая на большом проекте или в команде с другими разработчиками, которые могут вносить изменения в код и не сообщать об этом.
Статическая типизация в Go позволяет предотвратить подобные ошибки. Кроме того, использование языка Go вместо Node.js для тестирования — мечта любого Go-разработчика, начинающего работу со смарт-контрактами.
Моя команда занималась разработкой инвестиционной системы на основе смарт-контрактов с очень сложной архитектурой. Система смарт-контрактов содержала более 2000 строк кода. Поскольку основную часть команды составляли Go-разработчики, тестирование на Go было предпочтительнее, чем на Node.js.
Первая среда для тестирования смарт-контрактов на Go
В 2017 PolySwarm разработали Perigord — инструмент схожий с Truffle, использующий Go вместо JavaScript. К сожалению, этот проект больше не поддерживается, у него есть всего один туториал с очень простыми примерами. К тому же, он не поддерживает интеграцию с Ganache (приватный блокчейн для разработки Ethereum с очень удобным GUI). Мы улучшили Perigord путем устранения багов и внедрения двух новых функций: генерации кошельков из мнемонического кода и их использования для тестирования и подключения к блокчейну Ganache. Вы можете ознакомиться с исходным кодом по ссылке.
Оригинальный туториал Perigord содержит только простейший пример вызова контракта для изменения одного значения. Однако в реальном мире вам также нужно будет вызывать контракт с разных кошельков, отправлять и получать Ether и т.д. Теперь вы можете делать все это, используя усовершенствованный Perigord и старый добрый Ganache. Ниже вы найдете подробное руководство по разработке и тестированию смарт-контрактов с помощью Perigord&Ganache.
Использование улучшенного Perigord: полное руководство
Для использования Perigord вам нужно установить Go 1.7+, solc, abigen и Ganache. Пожалуйста, ознакомьтесь с документацией для вашей операционной системы.
Установите Perigord следующим образом:
$ go get gitlab.com/go-truffle/enhanced-perigord
$ go build
После этого вы сможете использовать команду perigord:
$ perigord
A golang development environment for Ethereum
Usage:
perigord [command]
Available Commands:
add Add a new contract or test to the project
build (alias for compile)
compile Compile contract source files
deploy (alias for migrate)
generate (alias for compile)
help Help about any command
init Initialize new Ethereum project with example contracts and tests
migrate Run migrations to deploy contracts
test Run go and solidity tests
Flags:
-h, --help help for perigord
Use "perigord [command] --help" for more information about a command.
Сейчас мы создадим простой смарт-контракт Market, чтобы продемонстрировать доступные варианты тестирования.
Чтобы начать проект, введите в терминал следующее:
$ perigord init market
Проект появится в папке src/ в GOPATH. Переместите проект в другую папку и обновите пути импортирования, если хотите изменить его расположение. Посмотрим, что находится в папке market/.
$ tree
.
+-- contracts
¦ L-- Foo.sol
+-- generate.go
+-- main.go
+-- migrations
¦ L-- 1_Migrations.go
+-- perigord.yaml
+-- stub
¦ +-- README.md
¦ L-- main.go
+-- stub_test.go
L-- tests
L-- Foo.go
Очень похоже на проект созданный в Truffle, не правда ли? Но это все на Go! Посмотрим, что в файле конфигурации perigord.yaml.
networks:
dev:
url: /tmp/geth_private_testnet/geth.ipc
keystore: /tmp/geth_private_testnet/keystore
passphrase: blah
mnemonic: candy maple cake sugar pudding cream honey rich smooth crumble sweet treat
num_accounts: 10
Для тестирования вы можете использовать как приватную сеть geth и файлы кошельков, так и подключиться к Ganache. Эти варианты взаимоисключающие. Мы возьмем мнемонику, которая используется по умолчанию, сгенерируем 10 аккаунтов и подключимся к Ganache. Замените код в perigord.yaml на:
networks:
dev:
url: HTTP://127.0.0.1:7545
mnemonic: candy maple cake sugar pudding cream honey rich smooth crumble sweet treat
num_accounts: 10
HTTP://127.0.0.1:7545 — стандартный адрес сервера Ganache RPC. Обратите внимание, что вы можете создать сколько угодно аккаунтов для тестирования, но только аккаунты, сгенерированные в Ganache (GUI), будут содержать средства.
Мы создадим контракт под названием Market.sol. Он может хранить запись пар адресов, один из которых отправляет средства на счет контракта, а другой имеет право получать средства, когда владелец контракта дает разрешение на такую транзакцию. Например, два участника не доверяют друг другу, но доверяют владельцу контракта, который решает, выполнено ли определенное условие. В примере реализовано несколько основных функций в целях демонстрации.
Добавим контакт в проект:
$ perigord add contract Market
Постфикс .sol будет добавлен автоматически. Вы также можете добавить другие контракты или удалить контракт-пример Foo.sol. Пока вы работаете в GOPATH, вы можете использовать импорт контрактов для создания сложных конструкций. У нас будет три файла Solidity: основной контракт Market, вспомогательные контракты Ownable и Migrations и библиотека SafeMath. Вы можете найти исходный код здесь.
Теперь проект имеет следующую структуру:
.
+-- contracts
¦ +-- Market.sol
¦ +-- Ownable.sol
¦ L-- SafeMath.sol
+-- generate.go
+-- main.go
+-- migrations
¦ L-- 1_Migrations.go
+-- perigord.yaml
+-- stub
¦ +-- README.md
¦ L-- main.go
+-- stub_test.go
L-- tests
L-- Foo.go
Генерируем байт-код EVM, биндинги ABI и Go:
$ perigord build
Добавляем миграции всех контрактов, которые вы будете деплоить. Т.к. мы деплоим только Market.sol, нам понадобиться всего одна новая миграция:
$ perigord add migration Market
Наш контракт не содержит конструктор, принимающий параметры. Если вам нужно передать параметры в конструктор, добавьте их в функцию Deploy{NewContract} в файле миграций:
address, transaction, contract, err := bindings.Deploy{NewContract}(auth, network.Client(),
“FOO”, “BAR”)
Удалите файл-пример Foo.go и добавьте тестовый файл для нашего контракта:
$ perigord add test Market
Чтобы использовать детерминированные кошельки, нам нужно прочитать мнемонику из файла конфигурации:
func getMnemonic() string {
viper.SetConfigFile("perigord.yaml")
if err := viper.ReadInConfig(); err != nil {
log.Fatal()
}
mnemonic := viper.GetStringMapString("networks.dev")["mnemonic"]
return mnemonic
}
Следующая вспомогательная функция используется для получения адреса сети:
func getNetworkAddress() string {
viper.SetConfigFile("perigord.yaml")
if err := viper.ReadInConfig(); err != nil {
log.Fatal()
}
networkAddr := viper.GetStringMapString("networks.dev")["url"]
return networkAddr
}
Еще одна вспомогательная функция, которая нам понадобится, — sendETH, мы будем использовать ее для передачи Ether с одного из сгенерированных кошельков (обозначенный индексом) на любой Ethereum адрес:
func sendETH(s *MarketSuite, c *ethclient.Client, sender int, receiver common.Address, value *big.Int) {
senderAcc := s.network.Accounts()[sender].Address
nonce, err := c.PendingNonceAt(context.Background(), senderAcc)
if err != nil {
log.Fatal(err)
}
gasLimit := uint64(6721975) // in units
gasPrice := big.NewInt(3700000000)
wallet, err := hdwallet.NewFromMnemonic(getMnemonic())
toAddress := receiver
var data []byte
tx := types.NewTransaction(nonce, toAddress, value, gasLimit, gasPrice, data)
chainID, err := c.NetworkID(context.Background())
if err != nil {
log.Fatal(err)
}
privateKey, err := wallet.PrivateKey(s.network.Accounts()[sender])
signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), privateKey)
if err != nil {
log.Fatal(err)
}
ts := types.Transactions{signedTx}
rawTx := hex.EncodeToString(ts.GetRlp(0))
var trx *types.Transaction
rawTxBytes, err := hex.DecodeString(rawTx)
err = rlp.DecodeBytes(rawTxBytes, &trx)
err = c.SendTransaction(context.Background(), trx)
if err != nil {
log.Fatal(err)
}
}
Следующие две функции используются для изменения вызова контракта:
func ensureAuth(auth bind.TransactOpts) *bind.TransactOpts {
return &bind.TransactOpts{
auth.From,
auth.Nonce,
auth.Signer,
auth.Value,
auth.GasPrice,
auth.GasLimit,
auth.Context}
}
func changeAuth(s MarketSuite, account int) bind.TransactOpts {
return *s.network.NewTransactor(s.network.Accounts()[account])
}
Процедура тестирования
Для вызова мы создаем contractSessionActual для определенного контракта. Т.к. у контракта есть владелец, мы можем получить его адрес и проверить, соответствует ли он дефолтному нулевому аккаунту Ganache. Мы сделаем это следующим образом (опустим обработку ошибок, чтобы сэкономить место):
contractSession := contract.Session("Market")
c.Assert(contractSession, NotNil)
contractSessionActual, ok := contractSession.(*bindings.MarketSession)
c.Assert(ok, Equals, true)
c.Assert(contractSessionActual, NotNil)
owner, _ := contractSessionActual.Owner()
account0 := s.network.Accounts()[0]
c.Assert(owner.Hex(), Equals, account0.Address.Hex()) //Owner account is account 0
Следующая полезная функция — изменение кошелька, вызывающего контракт:
ownerInd := 0
sender := 5
receiver := 6
senderAcc := s.network.Accounts()[sender].Address
receiverAcc := s.network.Accounts()[receiver].Address
//Call contract on behalf of its owner
auth := changeAuth(*s, ownerInd)
_, err = contractSessionActual.Contract.SetSenderReceiverPair(ensureAuth(auth),
senderAcc, receiverAcc)
Т.к. одна из основных функций, используемых в тестировании, — изменение вызывающего контракт, давайте сделаем платеж от имени отправителя:
auth = changeAuth(*s, sender) //Change auth fo senderAcc to make a deposit on behalf of the sender
client, _ := ethclient.Dial(getNetworkAddress())
//Let's check the current balance
balance, _ := client.BalanceAt(context.Background(), contract.AddressOf("Market"), nil)
c.Assert(balance.Int64(), Equals, big.NewInt(0).Int64()) //Balance should be 0
//Let's transfer 3 ETH to the contract on behalf of the sender
value := big.NewInt(3000000000000000000) // in wei (3 eth)
contractReceiver := contract.AddressOf("Market")
sendETH(s, client, sender, contractReceiver, value)
balance2, _ := client.BalanceAt(context.Background(), contract.AddressOf("Market"), nil)
c.Assert(balance2.Int64(), Equals, value.Int64()) //Balance should be 3 ETH
Полный код тестов приведен здесь.
Теперь откроем stub_test.go и убедимся, что все импорты указывают на ваш текущий проект. В нашем случае это:
import (
_ "market/migrations"
_ "market/tests"
"testing"
. "gopkg.in/check.v1"
)
Запустим тесты:
$ perigord test
Если все сделано правильно, то после окончания тестирования будет похожий результат:
Running migration 2
Running migration 3
OK: 1 passed
PASS
ok market 0.657s
Если у вас возникли проблемы, скачайте исходные файлы и повторите шаги, описанные в этом руководстве.
В заключение
Perigord — это надежный инструмент для тестирования, написанный на вашем любимом языке. Он создает такую же структуру проекта, как Truffle, и имеет такие же команды, поэтому вам не нужно будет переучиваться. Статическая типизация и однозначная сигнатура функций позволяют быстро разрабатывать и выполнять отладку, а также в значительной мере защищают от опечаток в аргументах. В Perigord можно легко мигрировать существующий проект на Truffle (все что вам нужно — скопировать и вставить файлы контрактов в соответствующую папку и добавить тесты), а также начать абсолютно новый проект с тестами, написанными на Go.
Я надеюсь, что работа, начатая командой PolySwarm и продолженная Inn4Science, будет полезна для Go-сообщества и освободит от часов тестирования и отладки с помощью менее удобных инструментов.
Комментарии (7)
i360u
26.03.2019 10:05Если вы работаете с людьми, которые вносят изменения в API и интерфейсы и не говорят об этом — у вас на любом языке будут проблемы.
Olena_Stoliarova Автор
27.03.2019 10:34Тут не поспоришь, это всегда так. Я описала способ делать часть работы, используя Go.
i360u
27.03.2019 11:06+1К чему тогда эти нападки на JS? И Go и JS — прекрасные языки на своих местах, не думаю что корректно писать об одном принижая другой.
dukei
28.03.2019 16:32Спасибо, конечно, лишний инструмент не помешает. Но скрипты всё же лучше писать на скриптовых языках. А если вы их не знаете — выучите. Знать только один Go и переделывать мир под это — плохая затея. Тем более, Go не очень-то хорош для того, чтобы быть единственным языком.
jehy
Очень хочется порекомендовать автору попробовать программировать вместо хейтинга. По статье складывается ощущение, что продуктивность может вырасти раза в 4.
Olena_Stoliarova Автор
Статья рассчитана на конкретную целевую аудиторию: программистов, для которых Go — основной или единственный язык. Поэтому она в теме Go, а не JS. Здесь нет ненависти.
jehy
Ну так бы и написали сразу, что чтение для не Go разработчиков строго воспрещено.
А хейтерство начинается прямо с бессмысленной сопроводительной картинки и вступления:
Дальше ещё целый бессмысленный параграф про «ловушки Javascript». Даже комментировать не буду.
Нормально и без хейтерства — это заменить всё это на честное и вполне понятное
Ну и в заголовке убрать «прощай, JavaScript». Статья бы ничего не потеряла для смысла ни для какой аудитории. Но выглядела бы адекватно.
Ну и из смешного
Откуда вы знаете любимый язык читателя? Хотя да, я уже забыл — статью разрешается читать только Go разработчикам…