При создании смарт контрактов на платформе Ethereum разработчик закладывает определенную логику работы, определяющую как методы должны изменять состояние контракта, какие должны эмитироваться события, когда и кому нужно произвести перевод средств, а когда бросить исключение. Инструменты отладки смарт контрактов еще не очень развиты, поэтому тесты зачастую становятся необходимым инструментом разработки, т.к. запускать контракты после каждого изменения может быть достаточно долгой процедурой. Также, в случае обнаружения ошибок, изменить код развернутого в сети контракта уже невозможно, можно только уничтожить контракт и создать новый, поэтому тестирование стоит проводить максимально подробно, особенно методы связанные с платежами. В статье будут показаны некоторые приемы тестирования, с которыми сталкиваются разработчики при создании и отладке смарт контрактов на Solidity.

Децентрализованная автономная организации (DAO)


Летом 2016 нашумела история с THE DAO, из которого злоумышленник увел значительные средства. DAO — это смарт контракт, который позиционирует себя как организацию, все процессы которой описаны кодом, работающим в блокчейн среде, при этом не является юридическим лицом и управляется коллективно всеми ее инвесторам. Еще в марте разработчики DAO подчеркнули важность тестирования и даже покрыли свой смарт контракт тестами, используя свой фреймворк на смеси Python и Javascript, но к сожалению тесты не закрыли использованную позднее уязвимость.

Код смарт контракта The DAO слишком большой для примера, поэтому в качестве объекта тестирования возьмем смарт контракт Congress, реализующий принципы DAO, который приводится в статье How to build a democracy on the blockchain на сайте http://ethereum.org. В дальнейшем предполагается знакомство с основными принципами разработки смарт контрактов.

Как происходит тестирование смарт контрактов


Общий принцип схож с тестированием любого другого кода — создается набор эталонных вызовов методов в предопределенном окружении, для результатов которых прописываются утверждения. Для тестирования удобно использовать практики BDD – Behavior Driven Development, которые наряду с тестами позволяют создать документацию и примеры использования.

Инструменты тестирования


В настоящее время разработаны ряд фреймворков и библиотек тестирования смарт контрактов Ethereum:

Truffle


В Truffle v.2 тесты разрабатываются на JavaScript, используются фреймворк Mocha и библиотека Chai. В версии 3 добавилась возможность писать тесты на Solidity.

DApple


В DApple тесты реализуются на Solidity, с использованием методов специально разработанных базовых смарт контрактов.

EmbarkJS


В EmbarkJS подход похож на Truffle, тесты пишутся на Javascript, используется фреймворк Mocha.

Разработка тестов на Solidity достаточно ограничена возможностями этого языка, поэтому будем использовать Javascript, все примеры будут с использованием Тruffle Framework. Также компоненты Truffle Framework, такие как truffle-contract или truffle-artifactor можно использовать для создания своих кастомных решений для взаимодействия со смарт контрактами.

Тестовый клиент


С учетом того, что блокчейн системы, в частности Ethereum, работают не очень быстро, для тестирования используются “тестовые” клиенты блокчейн, например, TestRPC, который почти полностью эмулируют работу JSON RPC API клиентов Ethereum. Помимо стандартных методов, TestRPC также реализует ряд дополнительных методов, которые удобно использовать при тестировании, такие как evm_increaseTime, evm_mine и др.

Альтернативный вариант — можно использовать один из стандартных клиентов, например Parity, работающий в dev режиме, при котором транзакции подтверждаются моментально. В дальнейших примерах будет использован TestRPC.

Настройка окружения


Тестовый клиент


Инсталляция через npm:

npm install -g ethereumjs-testrpc

TestRPC должен быть запущен в отдельном терминале. При каждом запуске тестовый клиент генерирует 10 новых аккаунтов, на каждом из которых уже размещены средства.

Фреймворк Truffle


Инсталляция через npm:

npm install -g truffle

Для создания структуры проекта нужно выполнить команду truffle init

$ mkdir solidity-test-example
$ cd solidity-test-example/
$ truffle init

Контракты должны быть расположены в директории contracts/, при компиляции контрактов Truffle Framework ожидает, что каждый контракт размещен в отдельном файле, название контракта равно названию файла. Тесты размещаются в директории test/. При выполнении команды truffle init также создаются тестовые контракты Metacoin и др.

В дальнейших примерах будет использоваться проект https://github.com/vitiko/solidity-test-example, в котором размещены код смарт контракта Congress и тесты для него. Тесты выполняются в среде Truffle v.2, в версии v.3, которая недавно вышла, есть небольшие отличия в части подключения сгенерированного Truffle кода и в формате данных транзакций, который возвращаются после вызова методов, изменяющих состояние.

Разработка тестов на базе Truffle framework


Организация тестов


В тестах используются JavaScript-объекты, представляющие собой абстракции для работы с контрактами, производящие маппинг между операциями над обьектами и вызовами JSON RPC методов клиента Ethereum. Данные объекты создаются автоматически при компиляции исходного кода *.sol файлов. Вызовы всех методов асинхронные и возвращают Promise, это позволяет не заботиться об отслеживании подтверждения транзакций, все реализовано “под капотом” компонент Truffle.

В примерах на сайте Truffle Framework используется стиль написания с .then() цепочками. Если описывать большой сценарий, код тестов получается достаточно объемный. Гораздо лаконичее и читаемее получается код тестов с использованием async / await, далее будет использоваться данный стиль написания тестов. Также, в примерах на сайте разработчика используются экземпляры смарт контрактов, разворачивание которых прописывется в миграциях. Чтобы не смешивать миграции и создание тестовых экземпляров, удобнее их явно создавать в тесте, для этого можно использовать код, который создает новый экземпляр контракта перед вызовом каждой тестовой функции. В примере ниже показана функция beforeEach, в которой создается экземпляр объекта Congress.

Конструктор смарт контракта Congress
/* First time setup */
function Congress(
    uint minimumQuorumForProposals,
    uint minutesForDebate,
    int marginOfVotesForMajority, address congressLeader
) payable {
    changeVotingRules(minimumQuorumForProposals, minutesForDebate, marginOfVotesForMajority);
    if (congressLeader != 0) owner = congressLeader;
    // It’s necessary to add an empty first member
    addMember(0, '');
    // and let's add the founder, to save a step later
    addMember(owner, 'founder');
}

const congressInitialParams = {
    minimumQuorumForProposals: 3,
    minutesForDebate: 5,
    marginOfVotesForMajority: 1,
    congressLeader: accounts[0]
};
let congress;
beforeEach(async function() {
    congress = await Congress.new(...Object.values(congressInitialParams));
});

Тестирование изменения состояния смарт контракта


Для начала попробуем протестировать метод addMember изменяющий состояние смарт контракта — метод должен записать информацию об участнике DAO в массив структур members.

Код функции addMember смарт контракта
/*make member*/
function addMember(address targetMember, string memberName) onlyOwner {
    uint id;
    if (memberId[targetMember] == 0) {
       memberId[targetMember] = members.length;
       id = members.length++;
       members[id] = Member({member: targetMember, memberSince: now, name: memberName});
    } else {
        id = memberId[targetMember];
        Member m = members[id];
    }

    MembershipChanged(targetMember, true);
}

В тесте мы, используя массив с тестовыми аккаунтами, добавляем участников в контракт. Затем проверяем что функция members (геттер для массива структур members) возвращает внесенные данные. Нужно заметить, что при каждом вызове метода addMember создается транзакция и изменяется состояние блокчейна, т.е. информация записывается в распределенный реестр.

it("should allow owner to add members", async function() {
    // добавляем 3 участника
    for (let i = 1; i <= 3; i++) {
        let addResult = await congress.addMember(accounts[i], 'Name for account ' + i);

        // позиции в массиве members для добавленных участников начинаются с 2.
        // т.к. members[0] - empty, members[1] - аккаунт, создавший контракт (см. конструктор)
        let memberInfoFromContract = await congress.members(i + 1);
        
        // метод members(pos) возвращает массив с данными из структуры Member
        // где [0] - адрес аккаунта участника, [1] - имя аккаунта участника
        assert.equal(memberInfoFromContract[0], accounts[i]);
        assert.equal(memberInfoFromContract[1], 'Name for account ' + i);
    }
});

Тестирование событий


События в Ethereum имеют достаточно универсальное применение, они могут использоваться:

  • Для возврата значений из методов, изменяющих состояние контракта, в т.ч. в пользовательский интерфейс
  • Для создания истории изменения состояния смарт контрактов
  • В качестве дешевого хранилища информации (стоимость размещения данных в логе событий значительно дешевле чем в состоянии контракта)

В следующем примере будем проверять, что при вызове метода newProposal, который добавляет предложения в контракт Congress, создается запись события Proposal Added

Код функции newProposal смарт контракта
/* Function to create a new proposal */
function newProposal(
    address beneficiary,
    uint etherAmount,
    string JobDescription,
    bytes transactionBytecode
)
    onlyMembers
    returns (uint proposalID)
{
    proposalID = proposals.length++;
    Proposal p = proposals[proposalID];
    p.recipient = beneficiary;
    p.amount = etherAmount;
    p.description = JobDescription;
    p.proposalHash = sha3(beneficiary, etherAmount, transactionBytecode);
    p.votingDeadline = now + debatingPeriodInMinutes * 1 minutes;
    p.executed = false;
    p.proposalPassed = false;
    p.numberOfVotes = 0;
    ProposalAdded(proposalID, beneficiary, etherAmount, JobDescription);
    numProposals = proposalID+1;

    return proposalID;
}

Для этого в тесте сначала создаем участника DAO и от его имени создаем предложение. Затем создаем подписчика на событие ProposalAdded и проверяем, что после вызова метода newProposal событие произошло и его атрибуты соответствуют переданным данным.

it("should fire event 'ProposalAdded' when member add proposal", async function() {
        let proposedAddedEventListener = congress.ProposalAdded();
        const proposalParams = {
          beneficiary :   accounts[9],
          etherAmount: 100,
          JobDescription : 'Some job description',
          transactionBytecode : web3.sha3('some content')
        };

        await congress.addMember(accounts[5], 'Name for account 5');
        await congress.newProposal(...Object.values (proposalParams),  {
                from: accounts[5]
            });

        let proposalAddedLog = await new Promise(
            (resolve, reject) => proposedAddedEventListener.get(
                (error, log) => error ? reject(error) : resolve(log)
            ));

        assert.equal(proposalAddedLog.length, 1, 'should be 1 event');
        let eventArgs = proposalAddedLog[0].args;
        assert.equal(eventArgs.proposalID , 0);
        assert.equal(eventArgs.recipient , proposalParams.beneficiary);
        assert.equal(eventArgs.amount , proposalParams.etherAmount);
        assert.equal(eventArgs.description , proposalParams.JobDescription);
    });
});

Тестирование ошибок и проверка отправителя сообщений


Стандартным методом прерывания работы метода контракта, являются исключения, которые можно создавать с помощью инструкции throw. Исключение может понадобиться, например, если необходимо ограничить доступ к методу. Для этого реализуется модификатор, который проверяет адрес аккаунта, вызвавшего метод, и если он не удовлетворяет условиям создается исключение. Для примера, создадим тест, проверяющий, что если метод addMember вызывает не владелец контракта — создается исключение. В коде ниже контракт Сongress создан от имени accounts[0], затем вызывается метод addMember от имени другой учетной записи.

it("should disallow no owner to add members", async function() {
        let addError;
        try {
            //тут создается исключение, когда accounts[0]  != accounts[9]
            await congress.addMember(accounts[1], 'Name for account 1', {
                from: accounts[9]
            });
        } catch (error) {
            addError = error;
        }
        assert.notEqual(addError, undefined, 'Error must be thrown');
        // подробного сообщения об ошибке не выдается, в нем только точно должна быть 
        // подстрока "invalid JUMP" 
        assert.isAbove(addError.message.search('invalid JUMP'), -1, 
                                 'invalid JUMP error must be returned');
    });

Тестирование изменения баланса смарт контракта, использование текущего времени в смарт контракте


Возможно, наиболее ответственной функцией смарт контракта Congress, реализующего принципы DAO, является функция executeProposal, которая запускает проверку того, что предложение получило требуемое количество голосов и обсуждение предложения продлилось не менее чем минимальное необходимое время, заданное при создании смарт контракта, после чего перечисляет средства бенефициару обсуждаемого предложения.

Код функции executeProposal смарт контракта
function executeProposal(uint proposalNumber, bytes transactionBytecode) {
        Proposal p = proposals[proposalNumber];
        /* Check if the proposal can be executed:
           - Has the voting deadline arrived?
           - Has it been already executed or is it being executed?
           - Does the transaction code match the proposal?
           - Has a minimum quorum?
        */

        if (now < p.votingDeadline
            || p.executed
            || p.proposalHash != sha3(p.recipient, p.amount, transactionBytecode)
            || p.numberOfVotes < minimumQuorum)
            throw;

        /* execute result */
        /* If difference between support and opposition is larger than margin */
        if (p.currentResult > majorityMargin) {
            // Avoid recursive calling

            p.executed = true;
            if (!p.recipient.call.value(p.amount * 1 ether)(transactionBytecode)) {
                throw;
            }

            p.proposalPassed = true;
        } else {
            p.proposalPassed = false;
        }
        // Fire Events
        ProposalTallied(proposalNumber, p.currentResult, p.numberOfVotes, p.proposalPassed);
    }

Для имитации пройденного времени используем метод evm_increaseTime, который реализован в testrpc — c его помощью можно изменить внутреннее время блокчейн клиента.

it("should pay for executed proposal", async function() {
       const proposalParams = {
          beneficiary: accounts[9],
          etherAmount: 1,
          JobDescription: 'Some job description',
          transactionBytecode: web3.sha3('some content')
        };
        // Баланс учетной записи accounts[9] до выполнения executeProposal
        let curAccount9Balance = web3.eth.getBalance(accounts[9]).toNumber();

        // Создаем предложение, в предложении указано что в случае его принятия
        // средства должны быть перечислены accounts[9]
        await congress.newProposal(...Object.values(proposalParams), {
            from: accounts[0] //accounts[0]  уже участник, т.к. от его имени создается контракт
        });

        // правила DAO, заданные в конструкторе тестового контракта 
        // требуют наличия как минимум 3х голосов за предложение
        // поэтому добавляем 3х участников DAO и голосуем за предложение 0 от их имени
        for (let i of[3, 4, 5]) {
            await congress.addMember(accounts[i], 'Name for account ' + i);
            await congress.vote(0, true, 'Some justification text from account ' + i, {
                from: accounts[i]
            });
        }
       // текущее состояние предложения
        let curProposalState = await congress.proposals(0);

       // далее можно проверить, что голосов больше минимально требуемого количества 
       // и др. условия
       //...
  
        // увеличиваем время в testrpc на 10 минут, больше чем минимально 
        // заданное в конструкторе время для обсуждения minutesForDebate (5)
        await new Promise((resolve, reject) =>
            web3.currentProvider.sendAsync({
                jsonrpc: "2.0",
                method: "evm_increaseTime",
                params: [10 * 600],
                id: new Date().getTime()
            }, (error, result) => error ? reject(error) : resolve(result.result))
        );
        // Предложение должно пройти - есть 3 голоса “ЗА”,
        // также прошло минимально требуемое время
        await congress.executeProposal(0, proposalParams.transactionBytecode);

        // Проверяем, что балас учетной записи accounts[9]
        // увеличился на сумму, указанную при создании предложения
        let newAccount9Balance = web3.eth.getBalance(accounts[9]).toNumber();
        assert.equal(web3.fromWei(newAccount9Balance - curAccount9Balance, 'ether'),
                             proposalParams.etherAmount,
                             'balance of acccounts[9] must increase to proposalParams.etherAmount');

        let newProposalState = await congress.proposals(0);
        assert.isOk(newProposalState[PROPOSAL_PASSED_FIELD]);
    });

Вспомогательные функции


В процессе написания тестов пригодились функции, которые удобно использовать для часто используемых операций при тестировании, таких как проверка на исключение, получение лога событий, изменение времени тестового клиента. Функции размещены в виде пакета https://www.npmjs.com/package/solidity-test-util, код размещен на github. Ниже приведен пример использования функции testUtil.assertThrow для проверки исключения:

it("should disallow no owner to add members", async function() {
      await testUtil.assertThrow(() => congress.addMember(accounts[1], 'Name for account 1', {
          from: accounts[9]
      }));
  });

Другие примеры с использованием функций solidity-test-util можно увидеть здесь.

Заключение


В результате разработки тестов мы получаем как автоматизированную проверку корректности работы смарт контрактов, так и спецификацию и примеры использования, в общем все, что можно сказать про тестирования любого другого программного кода.
Поделиться с друзьями
-->

Комментарии (2)


  1. roboter
    08.02.2017 16:38
    +2

    почему

    злоумышленник
    ? он сделал то для чего контракты и предназначались.


    1. rPman
      08.02.2017 23:16

      Злоумышленник — замышляющий зло (т.е. он хотел сделать зло, а как — дело десятое), человек, который воспользовался инструментом не так как 'задумывали' создатели, например убить другого человека вилкой/ложкой.

      И да, я помню, что в определении дао было сказано что то про то что написано в контракте вырезано в камне и верить только ему (а затем хардфорком это отменили — позор).