Truffle + Mocha テスティング技法

Yuya Sugano
23 min readMay 23, 2019

--

イミュータブルなスマートコントラクトを記述する上でコードの監査やテストを極めて重要です。TruffleのJavascriptを用いたテストで使用されているMochaのフレームワークやアサーションのChaiはRailsにおいても使用されています。この記事ではTruffleのテスティングのページに掲載されている metacoin.jsのテストをリファクタしてみました。

Truffle Chocolathe, Sweat !!

Mocha + Chaiを使用することでJavascriptでTDD(テスト駆動開発)やBDD(振る舞い駆動開発)を実現することができるようになります。ここでは主にユニットテストを対象にTDD(テスト開発駆動)の手法をテストコードに適用してみます。以下はテスト駆動開発/振る舞い駆動開発についての素晴らしい記事です。[1]

以下の内容を記載していますが、RailsなどでMocha/Chaiを既に触ったことがある方からするとだいぶ退屈な内容となっておりますm(_ _)m

  • describe/it句の分かりやすい書き方
  • async/awaitでPromise地獄を抜け出す
  • eachで効率よく、テスト時間の短縮を
  • utilsとして頻出機能をライブラリ化する
  • EVMのrevertを補足してassert falseする

TDD(テスト駆動開発)では最初に失敗するコードを書きます。テストが失敗することを確認したら、次にそのテストをパスするコードを記述します。テストがパスしたコードをリファクタリングしていき設計改善するサイクルを繰りかえしていきます。特にイミュータブルなコードがデプロイされてしまうブロックチェーンではデバッグおよび手戻りにかかる工数の削減以上にTDDやBDDという開発手法が有効だと考えられます。

ユニットテストのケースはコンポーネントごとに記述しますが、各関数への変数の入力(requireへの事前条件も含む)や出力を出来る限り漏れなくダブりなく(MECEに)洗い出します。

truffleでは最初に『失敗するテスト』を書きにくいと感じています。テスト実行時にEVMが revertしてしまうからです。 revertをテストコードで補足して、結果が falseになることをassertで確認するコードを後半で書いています。前半は専らWriting Tests in Javascriptのリファクタ系の内容です。

Truffle Suiteのドキュメント内容をベースに始めていきます。Truffle BoxのMetacoinを使用するので、unboxおよびコンパイルとテストが実行できることを事前に確認しておきます。[2]

任意のフォルダを作成し、その配下でtruffle unbox metacoin を実行します。truffle compileでコンパイルしてください。

$ truffle unbox metacoin
Downloading...
Unpacking...
Setting up...
Unbox successful. Sweet!

Commands:

Compile contracts: truffle compile
Migrate contracts: truffle migrate
Test contracts: truffle test
$ truffle compile
Compiling ./contracts/ConvertLib.sol...
Compiling ./contracts/MetaCoin.sol...
Compiling ./contracts/Migrations.sol...
Writing artifacts to ./build/contracts

テストコードが動くか確認します。metacoin.js は3つの出力があります。最初のアカウントで1万 MetaCoin が発行されること、ConvertLibというライブラリが適切に動作すること、そして MetaCoin をアカウント間で正しく送金できることです。

$ truffle test
Using network 'test'.
Compiling ./contracts/ConvertLib.sol...
Compiling ./contracts/MetaCoin.sol...
Compiling ./test/TestMetacoin.sol...
Compiling truffle/Assert.sol...
Compiling truffle/DeployedAddresses.sol...
TestMetacoin
? testInitialBalanceUsingDeployedContract (141ms)
? testInitialBalanceWithNewMetaCoin (114ms)
Contract: MetaCoin
? should put 10000 MetaCoin in the first account
? should call a function that depends on a linked library (69ms)
? should send coin correctly (171ms)
5 passing (2s)

describe/it句の分かりやすい書き方

Writing Tests in Javascriptに記載されているとおりtruffleではネイティブのMochaと異なり contract 句を使用します。

contract 句で囲われた処理内ではコントラクトが再デプロイされた上でEthereumクライアント上でテストが実行され、アカウントもデフォルトの状態で再提供されるようになっています。

基本的に1ファイルで1つの contract 句を使いその配下に describe で機能や関数ごとにテストケースをまとめ、 it で1つのアサーションを記述すると分かりやすいと思います。

Before each contract() function is run, your contracts are redeployed to the running Ethereum client so the tests within it run with a clean contract state.

The contract() function provides a list of accounts made available by your Ethereum client which you can use to write tests.

サンプルでは contract 句の直下にいきなりテストケースが記述されていますので、 describe を使用してトークンに関するテストケースとライブラリに関するテストケースを分けてみます。 it の第1引数は期待される処理結果を自然言語で記述すべきで、 describe から連続して読むことで何をテストしているのか文章として分かりやすいように書くと親切です。

リファクタしたコードです(ちょっと長くなってますが orz)。

テストを実行します。 contract 以降が文章のようになっているのが分かると思います。厳密に考えるとContract: MetaCoin Libraryは不自然なのこのライブラリはテストファイルを別として単体でテストした方が良さそうです。

$ truffle test
Using network 'test'.
Compiling ./contracts/ConvertLib.sol...
Compiling ./contracts/MetaCoin.sol...
Compiling ./test/TestMetacoin.sol...
Compiling truffle/Assert.sol...
Compiling truffle/DeployedAddresses.sol...
TestMetacoin
? testInitialBalanceUsingDeployedContract (137ms)
? testInitialBalanceWithNewMetaCoin (122ms)
Contract: MetaCoin
Token
? should put 10000 MetaCoin in the first account (38ms)
? should send coin correctly (236ms)
Library
? should call a function that depends on a linked library (74ms)
5 passing (2s)
  • Contract: MetaCoin Token should put 10000 MetaCoin in the first account
  • Contract: MetaCoin Token sendCoin should send coin correctly
  • Contract: MetaCoin Library should call a function that depends on a linked library

async/awaitでPromise地獄を抜け出す

非同期処理のための Promisethen の処理が地獄チェーンされています。このリファクタではWriting Tests in Javascriptに既に記載されていますが、Promise地獄を非同期処理 async/await で抜け出すこと、および一部のコードをES6の記法で置き換えること、をやっています。

変更したことです。

  • var を let で置き換える
  • accounts は alice や bob などの変数へ投入
  • function() {} をアロー関数で置き換える () => {}
  • Promise/then を async/await で置き換える

ES6の記法やasync/await の使用はほぼマストになっていますので見慣れておく必要があります。以下リファクタしたコードです。

テスト結果が変わらないことを確認します。

eachで効率よく、テスト時間の短縮を

このテストは1つのファイルのみで行っているためさほど気にならないですが、コード量が多くなってくると、それに伴いテストコードの量も肥大化します。truffleではテストの自動化がないので走らせたテストが早く終わるに越したことはありません。 describe 内で冗長な箇所は beforebeforeEach で事前に処理しておきます。このテストでは以下の箇所が冗長だと感じました。

  • インスタンスを毎回デプロイして変数へ投入している
  • アカウントを毎回変数へ投入している

before は最初の1回のみ、 beforeEach は各テストごとに記述した内容を実行してくれます。[3]

describe の中で以下のような事前処理を行うことで効率化します。

describe('Token', () =>  {
let balance;
let instance;
const [alice, bob] = accounts;
before(async () => {
instance = await MetaCoin.deployed();
});
...

リファクタしたコードです。 Token のテストではアカウント初期化とインスタンスの変数への投入の冗長性を取り除きました。

テスト結果が変わらないことを確認します。

utilsとして頻出機能をライブラリ化する

コード行数は短くなり多少効率化もできました。 describe において getBalanceCall 関数を利用したトークン残高の取得を何度も行っています。このような頻出する関数やコード群はテスト用のライブラリとして別ファイルに切り出すことでよりテストの本質部分のみを本体に残すことができます。

この2つの機能は utils.js として別ファイルにコーディングし、テストファイルの本体側で読み込んで使うことにしました。

  • トークン残高を取得して変数へ格納している機能(Token)
  • アカウントからトークン残高とEth残高を返す機能(Library)
/* global artifacts */
const MetaCoin = artifacts.require("./MetaCoin.sol");
const utils = {
// returns the MetaCoin balance for an account
getTokenBalance: async (account, instance) => {
let balance = await instance.getBalance.call(account);
return balance.toNumber();
},
// returns the MetaCoin balance and its Eth balance for an account
getTokenEthBalance: async (account, instance) => {
let outCoinBalance = await instance.getBalance.call(account);
let metaCoinBalance = outCoinBalance.toNumber();
let outCoinBalanceEth = await instance.getBalanceInEth.call(account);
let metaCoinEthBalance = outCoinBalanceEth.toNumber();
return {metaCoinEthBalance, metaCoinBalance};
},
};
module.exports = utils;

このライブラリを使用するとテストは以下のようにリファクタできます。スッキリして何をやっているのかが分かりやすくなりました。コメントを追加するとよりベターです。

テスト結果が変わらないことを確認します。

EVMのrevertを補足してassert falseする

solidityでは変数などバリデートの機能がありません。 require を使用して前提条件を確認することが多いと思います。条件に合致しないときは EVMがrevert してしまうため『失敗するテスト』が書きにくいことがあります。

先に作成したライブラリにEVMの revert を補足する関数を追加して revert が発生したとしてもテストを継続できるようにします。

sendCoin 関数に require で残高を確認する条件を追加してリコンパイルしました。この requireは送金者の残高が十分でない場合には処理が進まないようになっています。

function sendCoin(address receiver, uint amount) public returns(bool sufficient) {
require(receiver != address(0), "You cannot send coins to zero address");
require(balances[msg.sender] > amount, "Your balance is not enough");
balances[msg.sender] -= amount;
balances[receiver] += amount;
emit Transfer(msg.sender, receiver, amount);
return true;
}

送金に失敗するケースのテストを書きます。 accounts[1] の Bob は最初残高が0なので Bob から Alice へ送金してみます。以下のテストでは 10000 MetaCoinを Bob から Alice へ送ろうとするのですが残高が足りないために送ることができません。その結果 Bob のトークン残高は0のままでしたという内容です。

it("should fail if the user does not have enough MetaCoin", async () => {
let amount = 10000;
await instance.sendCoin(alice, amount, { from: bob });
let bob_ending_balance = await utils.getTokenBalance(bob, instance);
assert.equal(bob_ending_balance, 0, "0 wasn't in the second account");
});

テストを実行します。EVMのエラーメッセージが出力されます。EVMが revert してしまい assert 文の『Bob の残高は0のまま変わっていません』というテスト内容が確認できていない状況です。

5 passing (2s)
1 failing
1) Contract: MetaCoin
Token
should fail if the user does not have enough MetaCoin:
Error: VM Exception while processing transaction: revert Your balance is not enough

そこで revert を補足する関数を utils.js へ追加します。例外に revert が含まれている場合は trueを返し、そうでない場合は false を返してくれる関数です。

isEVMEXception(err) {
return err.toString().includes('revert');
},

revert による例外の場合は catch 句内のトークン残高の確認へ進みます。仮に 10000 MetaCoin 送金できてしまった場合は Bob の残高がおかしいので assert(false, '10000 was in the second account') とします。

it("should fail if the user does not have enough MetaCoin", async () => {
let amount = 10000;
try {
await instance.sendCoin(alice, amount, { from: bob });
} catch (err) {
assert(utils.isEVMException(err), err.toString());
let bob_ending_balance = await utils.getTokenBalance(bob, instance);
assert.equal(bob_ending_balance, 0, "0 wasn't in the second account");
return;
}
assert(false, '10000 was in the second account');
});

テストを実行します。

$ truffle test
Using network 'test'.
Compiling ./contracts/ConvertLib.sol...
Compiling ./contracts/MetaCoin.sol...
Compiling ./test/TestMetacoin.sol...
Compiling truffle/Assert.sol...
Compiling truffle/DeployedAddresses.sol...
TestMetacoin
? testInitialBalanceUsingDeployedContract (129ms)
? testInitialBalanceWithNewMetaCoin (113ms)
Contract: MetaCoin
Token
? should put 10000 MetaCoin in the first account
? should fail if the user does not have enough MetaCoin
? should send coin correctly (323ms)
Library
? should call a function that depends on a linked library (62ms)
6 passing (2s)

上記のテスト内容を文章に書き出してみました。いずれも文章として何をテストしているかが理解できると思います。

  • Contract: MetaCoin Token should put 10000 MetaCoin in the first account
  • Contract: MetaCoin Token should fail if the user does not have enough MetaCoin
  • Contract: MetaCoin Token sendCoin should send coin correctly
  • Contract: MetaCoin Library should call a function that depends on a linked library

リファクタしたコードです。

最後に本題のTDD(テスト駆動開発)です。以下の 『MetaCoin の受信者が address(0) でないことを確認するコード』をコメントアウトして、この部分に対応する『失敗するテスト』を書いてみます。

function sendCoin(address receiver, uint amount) public returns(bool sufficient) {
// require(receiver != address(0), "You cannot send coins to zero address");
require(balances[msg.sender] > amount, "Your balance is not enough");
...

この状態ではaddress(0) に対して MetaCoin を送付することができてしまいます。つまり MetaCoin がバーンされます。書いてみたテストコードは以下です。

it("should not send coin to zero address", async () => {
let amount = 10;
let receiver = 0x0000000000000000000000000000000000000000;
let alice_balance = await utils.getTokenBalance(alice, instance);
try {
await instance.sendCoin(receiver, amount, { from: alice });
} catch (err) {
assert(utils.isEVMException(err), err.toString());
let alice_ending_balance = await utils.getTokenBalance(alice, instance);
assert.equal(alice_ending_balance, alice_balance, "Amount wasn't the same for the sender");
return;
}
assert(false, 'sendCoin sent coin to zero address');
});

sendCoin(receiver, amount, { from: alice })revert されないため、最後のアサーションエラーが発生します。このテストを実行すると sendCoin sent coin to zero address が出力されます。

1) Contract: MetaCoin
Token
should not send coin to zero address:
AssertionError: sendCoin sent coin to zero address
at Context.it (test/metacoin.js:60:7)
at processTicksAndRejections (internal/process/next_tick.js:81:5)

次にテストをパスするコードを記述します。ここではコメントアウトした行のコメントを元に戻すだけです。最終テストの結果です。address(0) に MetaCoin が送付できないことが確認できました。

$ truffle test
Using network 'test'.
Compiling ./contracts/ConvertLib.sol...
Compiling ./contracts/MetaCoin.sol...
Compiling ./test/TestMetacoin.sol...
Compiling truffle/Assert.sol...
Compiling truffle/DeployedAddresses.sol...
TestMetacoin
? testInitialBalanceUsingDeployedContract (161ms)
? testInitialBalanceWithNewMetaCoin (126ms)
Contract: MetaCoin
Token
? should put 10000 MetaCoin in the first account
? should fail if the user does not have enough MetaCoin (58ms)
? should send coin correctly (164ms)
? should not send coin to zero address (68ms)
Library
? should call a function that depends on a linked library (61ms)
7 passing (2s)

リファクタしたコードです。

TDD(テスト駆動開発)についてはMocha/Chaiで十分だと考えますが、BDD(振る舞い駆動開発)についてはRSpecやCucumberなどのフレームワークが便利です。truffleのテスティングにもBDDのフレームワークが追加されることを願っています。

まとめ

  • Mocha/Chaiを使用することTDD(テスト駆動開発)やBDD(振る舞い駆動開発)を行うことができる
  • イミュータブルなコードがデプロイされてしまうブロックチェーンではTDDやBDDは工数の削減以上に重要だと考えられる
  • RSpecやCucumberなどのBDDをサポートするフレームワークのサポートが欲しい

--

--

Yuya Sugano
Yuya Sugano

Written by Yuya Sugano

Cloud Architect and Blockchain Enthusiast, techflare.blog, Vinyl DJ, Backpacker. ブロックチェーン・クラウド(AWS/Azure)関連の記事をパブリッシュ。バックパッカーとしてユーラシア大陸を陸路横断するなど旅が趣味。

No responses yet