TruffleによるDapp開発のテストについて

はじめに

  • EthereumによるDapp開発では、TruffleGanacheといった開発ツールを利用できる。
  • スマートコントラクトは、一度ブロックチェーンに取り込まれると変更できないため、テストを事前に実施すべきである。
  • とは言っても、ETHの支払いや時間の進め方など、スマートコントラクトならではのテストをどのように書くべきかわからなかった。
  • そこで今回は、Truffle Ganache環境における、スマートコントラクトのテスト方法について調べた内容を記載する。
  • 動作確認バージョンは、以下の通り。
    • Truffle : 4.1.14
    • Ganache : 1.1.0

Solidityのテストコードで利用可能な言語

Truffleのドキュメントには、テストコードで利用可能な言語として以下を挙げている。

  • Solidity
    • 特徴は、依存ライブラリが必要なく、コントラクトのテストが可能なこと。
  • Javascript
    • 特徴は、依存ライブラリが必要だが、実際のクライアントを想定した接続テストが可能なこと。
    • Truffleは、以下に依存している。

以降では、Javascriptによるテストコードの書き方について説明する。

テストコードの書き方

テスト対象のコード

ブロックチェーンの入門書でよくあるクラウドファンディングコントラクトをテスト対象とする。 このコントラクトでは、以下の処理が行える。(セキュアでない処理があるがサンプルということで...)

  • 資金の調達者(Owner)が、「目標金額(通貨:ETH)」と、「締め切り期日」を設定できる。
  • Ownerが、期日までに援助者(Investor)から目標金額を獲得できたら、Ownerに全額資金を送付できる。
  • Ownerが、期日までに援助者(Investor)から目標金額を獲得できなかったら、Investorに資金を返済できる。

CrowdFunding.sol

pragma solidity ^0.4.23;

contract CrowdFunding {

  struct Investor {
    address addr;
    uint amount;
  }

  address public owner;
  uint public numInvestors;
  uint public deadline;
  string public status;
  bool public ended;
  uint public goalAmount;
  uint public totalAmount;
  mapping (uint => Investor) public investors;

  event Fund(address indexed investor, uint amount);
  event CheckGoalReached(address indexed owner);

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

  constructor(uint _duration, uint _goalAmount) public {
    owner = msg.sender;
    deadline = now + _duration;
    goalAmount = _goalAmount;
    status = "Funding";
    ended = false;
    numInvestors = 0;
    totalAmount = 0;
  }

  function fund() public payable {
    require(!ended);

    Investor storage inv = investors[numInvestors++];
    inv.addr = msg.sender;
    inv.amount = msg.value;
    totalAmount += inv.amount;

    emit Fund(msg.sender, msg.value);
  }

  function checkGoalReached() public onlyOwner {
    require(!ended);

    require(now >= deadline);
    if(totalAmount >= goalAmount) {
      status = "Campaign Succeeded";
      ended = true;
      if(!owner.send(address(this).balance)) {
        revert("Failed to send the balance to the owner");
      }
    } else {
      uint i = 0;
      status = "Campaign Failed";
      ended = true;

      while(i <= numInvestors) {
        if(!investors[i].addr.send(investors[i].amount)) {
          revert("Failed to send the balance to investors");
        }
        i++;
      }
    }
    emit CheckGoalReached(msg.sender);
  }

  function kill() public onlyOwner {
    selfdestruct(owner);
  }
}

1. 基本的なテスト

  • mochaで一般的に使われるdescribe()の代わりにcontract()を使う。
  • contractでは、addressの配列がパラメータとして与えられる。
    • [owner, investor1, investor2]のようにテスト実行時に使われる各アドレスの役割を変数名にするとわかりやすい。
  • artifacts.require()でテスト対象のコントラクトの型を宣言する。
  • web3が利用可能なので、web3.eth.getBalanceといった処理が書ける。
  • テストコードは非同期処理として記載するので async/awaitを利用するとネストが少なくすっきり書ける。
const BigNumber = web3.BigNumber

require('chai')
  .use(require('chai-as-promised'))
  .use(require('chai-bignumber')(BigNumber))
  .should();

var CrowdFunding = artifacts.require("CrowdFunding");

contract('CrowdFunding', ([owner, investor1, investor2]) => {
  const DURATION = 1800; // 30 minutes
  const GOAL_AMOUNT = new web3.BigNumber(web3.toWei(1, 'ether'));

  let instance;

  beforeEach(async () => {
    instance = await CrowdFunding.new(DURATION, GOAL_AMOUNT, { from: owner });
  });

  it('should be Funding state initially', async () => {
    (await instance.status()).should.equal('Funding');
    (await instance.ended()).should.be.false;
  });
});

2. 例外処理のテスト

  • chai-as-promisedのrejectedWithを利用してerrorメッセージ文字列にrevertが含まれているかをチェックする。
  it('should fail if checkGoalReaced is called before campaign end', async() => {
    instance.checkGoalReached({ from: owner }).should.be.rejectedWith('revert');
  });

3. 時間を進めたい場合のテスト

  • Ganacheに対して、jsonrpc経由で非標準メソッド evm_increaseTime を呼び出す。
  • その他の非標準メソッドとして以下がある。
    • evm_snapshot, evm_revert, evm_increaseTime, evm_mine
  it('should fail if fund is called after the campaign end', async () => {
    const amount1 = new web3.BigNumber(web3.toWei(1, 'ether'));
    await instance.fund({ from: investor1, value: amount1 });
    await increaseTime(duration.hours(1));
    await instance.checkGoalReached({ from: owner });
    instance.fund({ from: investor1, value: amount1 }).should.be.rejectedWith('revert');
  });

increaseTime.js

export const duration = {
  seconds: function (val) { return val; },
  minutes: function (val) { return val * this.seconds(60); },
  hours: function (val) { return val * this.minutes(60); },
  days: function (val) { return val * this.hours(24); },
  weeks: function (val) { return val * this.days(7); },
  years: function (val) { return val * this.days(365); },
};

export function increaseTime(duration) {
  return new Promise((resolve, reject) => {
    web3.currentProvider.sendAsync({
      jsonrpc: '2.0',
      method: 'evm_increaseTime',
      params: [duration],
      id: Date.now(),
    }, err => {
      if (err) return reject(err)
        resolve()
    })
  })
};

4. Eventのテスト

  • 原因不明であるが、私の環境では、初回呼び出しのみイベントが2回発火されてしまう。
  • event.watchの結果を確認するよりも、event.getで呼ばれた回数を確認する方法をとっている。
  it('should fire an event after calling fund', async () => {
    const amount1 = new web3.BigNumber(web3.toWei(0.1, 'ether'));
    const event = instance.Fund({}, {fromBlock: 0, toBlock: 'latest'});

    // event.watch(function (error, result) {
      // if (!error) {
        // console.log(result.args); // sometimes an event is called twice
        // result.args['investor'].should.equal(investor1);
        // result.args['amount'].should.be.bignumber.equal(amount1);
      // } else {
        // assert.fail('error occured');
      // }
    // });

    await instance.fund({ from: investor1, value: amount1 });
    await instance.fund({ from: investor2, value: amount1 });
    await instance.fund({ from: investor1, value: amount1 });
    assert.equal(3, event.get().length)
  });

5. ETH支払のテスト

  • web3.ethを利用してトランザクション前後で消費したETHを確認する。
  • ETH支払いにはガスがかかるため、トランザクションから消費したガスを取得し、そのガスを考慮した計算が必要。
  it('should success the campaign if totalAmount is reached by deadline, then send balance to owner', async () => {
    const amount1 = new web3.BigNumber(web3.toWei(1, 'ether'));
    const pre = web3.eth.getBalance(owner);
    await instance.fund({ from: investor1, value: amount1 });
    await increaseTime(duration.hours(1));
    const res = await instance.checkGoalReached({ from: owner });
    const gasCost = getTransactionGasCost(res['tx']);
    const post = web3.eth.getBalance(owner);
    post.minus(pre).plus(gasCost).should.be.bignumber.equal(amount1);
    (await instance.status()).should.be.equal("Campaign Succeeded");
    (await instance.ended()).should.be.true;
    (await web3.eth.getBalance(instance.address)).should.bignumber.equal(0);
  });
export function getTransactionGasCost(tx) {
  const transaction = web3.eth.getTransactionReceipt(tx);
  const amount = transaction.gasUsed;
  const price = web3.eth.getTransaction(tx).gasPrice;

  return new web3.BigNumber(price * amount);
}

ソースコード

今回のサンプルコードは、Githubに格納している。

参考

//