Как тестировать смарт-контракты

image

Условия смарт-контракта нельзя изменить. Поэтому всякий раз, когда вы создаёте смарт-контракт, нужно убедиться, что он работает правильно. Тестирование — безопасный способ проверить контракт в разных ситуациях. В этом туториале вы узнаете, какие шаги для этого нужно предпринять.
Я расскажу:

  1. Как подготовить тестовую среду.
  2. Как написать тесты на JavaScript и выполнить их в Truffle.


Туториал рассчитан на новичков, которые только начали углубляться в разработку и тестирование смарт-контрактов на Ethereum. Чтобы разобраться в этом туториале, у вас должен быть хотя бы базовый уровень знаний по JavaScript и Solidity.

1. Как подготовить тестовую среду


Существует много способов тестирования смарт-контрактов, но Truffle — самый популярный инструмент для написания тестов с использованием JavaScript. На Truffle можно как писать юнит-тесты, так и проводить полноценное интеграционное тестирование с реальными параметрами из продакшн среды.

Для начала нужно установить последнюю версию Node.js с официального сайта.
Затем открыть терминал и установить Truffle, используя следующую команду:

npm install -g truffle


После установки Truffle, не закрывая терминал, создайте каталог Funding:

mkdir Funding


Далее, переходим в каталог командой:

cd Funding


Для инициализации каталога выполните команду:

truffle init


После выполнения команды в каталоге Funding будут созданы следующие папки и файлы:

image

Дальше мы поработаем с каждым каталогом. А пока продолжим подготавливать тестовую среду.

Для запуска тестов понадобятся Javascript-библиотеки.

Mocha — библиотека, которая содержит общие функции для тестирования, включая describe и it.

Chai — библиотека, которая поддерживает разнообразные функции для проверок. Есть разные «стили» проверки результатов, и Chai предоставляет нам эту возможность. В туториале будем использовать Should.

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

npm install chai


Также я установил библиотеку chai-bignumber для сравнения чисел с произвольной точностью:

npm install --save-dev chai-bignumber 


На этом тестовая среда готова. Теперь можно приступить к разработке смарт-контракта и тестов для него.

2. Как написать тесты на JavaScript и выполнить их в Truffle


Для разработки тестов понадобится смарт-контракт. Мы разработаем контракт, который позволяет собирать пожертвования, задавать конкретную сумму для достижения сбора и выводить средства. Если кто-то пожертвует больше, то ему будет возвращена разница между суммой, которая набралась, и суммой, которую нужно собрать.

Перейдем в каталог Funding → contracts. В Funding/contracts создадим файл Funding.sol с расширением .sol — это и будет смарт-контракт для тестирования.

В Funding.sol добавим следующий код:

pragma solidity 0.4.24;

contract Funding {
    uint public raised;
    uint public goal;
    address public owner;

    event Donated(uint donation);
    event Withdrew(uint amount);

    modifier onlyOwner() {
        require(owner == msg.sender);
        _;
    }

    modifier isFinished() {
        require(isFunded());
        _;
    }
    
    modifier notFinished() {
        require(!isFunded());
        _;
    }

    constructor (uint _goal) public {
        owner = msg.sender;
        goal = _goal;
    }

    function isFunded() public view returns (bool) {
        return raised >= goal;
    }

    function donate() public payable notFinished {
        uint refund;
        raised += msg.value;
        if (raised > goal) {
            refund = raised - goal;
            raised -= refund;
            msg.sender.transfer(refund);
        }
        emit Donated(msg.value);
    }

    function withdraw() public onlyOwner isFinished {
        uint amount = address(this).balance;
        owner.transfer(amount);
        emit Withdrew(amount);
    }
}


Контракт готов. Развернем смарт-контракт посредством миграции.

Миграции — это файлы JavaScript, которые помогут вам развернуть контракты в сети Ethereum. Это основной способ развертывания контрактов.

Перейдем в каталог Funding → migrations и создадим файл 2_funding.js, добавив в него следующий код:

const Funding = artifacts.require("./Funding.sol");

const ETHERS = 10**18;
const GOAL = 20 * ETHERS;

module.exports = function(deployer) {
    deployer.deploy(Funding, GOAL);
};


Чтобы запустить тесты, нужно использовать команду truffle test. В терминале переходим в корень каталога Funding, который создали при подготовке тестовой среды и вводим:

truffle test


Если в терминале появится следующий вывод, то все сделано верно:

image

Теперь приступим к написанию тестов.

Проверка владельца


Теперь перейдем в каталог Funding → test и создадим файл test_funding.js с расширением .js. Это файл, в котором будут написаны тесты.

В файл test_funding.js нужно добавить следующий код:

const Funding = artifacts.require("Funding");
require("chai").use(require("chai-bignumber")(web3.BigNumber)).should();

contract("Funding", function([account, firstDonator, secondDonator]) {

const ETHERS = 10**18;
const GAS_PRICE = 10**6;

let fundingContract = null;

  it("should check the owner is valid", async () => {
    fundingContract = await Funding.deployed();
    const owner = await fundingContract.owner.call()
    owner.should.be.bignumber.equal(account);
  });


В тесте мы проверяем, что контракт Funding хранит адрес владельца, который развернул контракт. В нашем случае account — это первый элемент массива. Truffle позволяет использовать до десяти адресов, в тестах нам понадобится всего три адреса.

Приём пожертвований и проверка окончания сбора средств


В этом разделе мы проверим:

  1. Приём и сумму пожертвований.
  2. Достигнута ли определенная сумма пожертвования.
  3. Что произойдет, если пожертвовать больше, чем нужно собрать.
  4. Общую сумму пожертвования.
  5. Можно ли продолжить сбор средств, если нужная сумма собрана.


Напишем и разберем тесты:

. . . . . . . . . . . . . . . . . . . . . .

const ETHERS = 10**18;
const GAS_PRICE = 10**6;

let fundingContract = null;
let txEvent;

function findEvent(logs, eventName) {
  let result = null;
  for (let log of logs) {
    if (log.event === eventName) {
      result = log;
      break;
    }
  }
  return result;
};

  it("should accept donations from the donator #1", async () => {
    const bFirstDonator= web3.eth.getBalance(firstDonator);

    const donate = await fundingContract.donate({ 
                                                  from: firstDonator, 
                                                  value: 5 * ETHERS, 
                                                  gasPrice: GAS_PRICE
                                               });
    txEvent = findEvent(donate.logs, "Donated");
    txEvent.args.donation.should.be.bignumber.equal(5 * ETHERS);

    const difference = bFirstDonator.sub(web3.eth.getBalance(firstDonator)).sub(new web3.BigNumber(donate.receipt.gasUsed * GAS_PRICE));
    difference.should.be.bignumber.equal(5 * ETHERS);
  });


Перед разбором теста, хочу отметить 2 момента:

  1. Для поиска событий (events) и проверки его аргументов была написана небольшая функция findEvent.
  2. Для удобства тестирования и расчетов была выставлена собственная стоимость за gas (константа GAS_PRICE).


Теперь разберем тест. В тесте мы проверили:

  • что можем принимать пожертвования, вызвав метод donate ();
  • что правильно указана сумма, которую нам пожертвовали;
  • что у того, кто пожертвовал средства, баланс уменьшился на пожертвованную сумму.
it("should check if donation is not completed", async () => {
  const isFunded = await fundingContract.isFunded();
  isFunded.should.be.equal(false);
});


В этом тесте проверили, что сбор средств ещё не завершился.

it("should not allow to withdraw the fund until the required amount has 
  been collected", async () => {
  let isCaught = false;

  try {
    await fundingContract.withdraw({ gasPrice: GAS_PRICE });
  } catch (err) {
      isCaught = true;
  }
  isCaught.should.be.equal(true);
});


В тесте проверили, что не можем выводить средства, пока нужная нам сумма не будет собрана.

it("should accept donations from the donator #2", async () => {
  const bSecondDonator= web3.eth.getBalance(secondDonator);

  const donate = await fundingContract.donate({ 
                                                from: secondDonator, 
                                                value: 20 * ETHERS, 
                                                gasPrice: GAS_PRICE 
                                             });
  txEvent = findEvent(donate.logs, "Donated");
  txEvent.args.donation.should.be.bignumber.equal(20 * ETHERS);

  const difference = bSecondDonator.sub(web3.eth.getBalance(secondDonator)).sub(new web3.BigNumber(donate.receipt.gasUsed * GAS_PRICE));
 difference.should.be.bignumber.equal(15 * ETHERS);
});


В тесте проверили, что если пожертвовать большую сумму, то метод donate () рассчитывает и возвращает средства тому, кто пожертвовал больше, чем нужно. Эта сумма равна разнице между суммой, которая набралась и суммой, которую требуется собрать.

it("should check if the donation is completed", async () => {
  const notFunded = await fundingContract.isFunded();
  notFunded.should.be.equal(true);
});

it("should check if donated amount of money is correct", async () => {
  const raised = await fundingContract.raised.call();
  raised.should.be.bignumber.equal(20 * ETHERS);
});

it("should not accept donations if the fundraising is completed", async () => {
  let isCaught = false;

  try {
    await fundingContract.donate({ from: firstDonator, value: 10 * ETHERS });
  } catch (err) {
        isCaught = true;
  }
  isCaught.should.be.equal(true);
});


В этих трёх тестах мы проверили:

  • что сбор средств завершен;
  • что сумма пожертвования правильная;
  • что больше никто не может пожертвовать, так как сбор средств завершен.


Вывод средств


В предыдущем разделе туториала, мы собрали нужную нам сумму, теперь её можно вывести:

  . . . . . . . . . . . . . . . . . . . . . .

  it("should allow the owner to withdraw the fund", async () => {
    const bAccount = web3.eth.getBalance(account);

    const withdraw = await fundingContract.withdraw({ gasPrice: GAS_PRICE });
    txEvent = findEvent(withdraw.logs, "Withdrew");
    txEvent.args.amount.should.be.bignumber.equal(20 * ETHERS);

    const difference = web3.eth.getBalance(account).sub(bAccount);
    difference.should.be.bignumber.equal(await fundingContract.raised.call() - withdraw.receipt.gasUsed * GAS_PRICE);
});


Вызвав функцию withdraw (), мы вывели средства владельца смарт-контракта, в нашем случае это account. Потом проверили, что действительно вывели нужную нам сумму. Для этого в константу difference записали разницу баланса до и после вывода средств. Полученный результат сравнили с суммой пожертвований минус плата за транзакцию. Как упоминалось выше, для удобства тестирования и расчетов, я установил собственную цену за gas.

Запустим написанные тесты командой truffle test. Если все сделали правильно, результат должен быть следующим:

image

Результат


Я попытался простым и понятным языком описать шаги тестирования смарт-контрактов: от подготовки тестовой среды до написания самих тестов.

Теперь вы сможете протестировать любой смарт-контракт и убедиться, что он работает правильно.

© Habrahabr.ru