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地獄を抜け出す
非同期処理のための Promise
と then
の処理が地獄チェーンされています。このリファクタでは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
内で冗長な箇所は before
やbeforeEach
で事前に処理しておきます。このテストでは以下の箇所が冗長だと感じました。
- インスタンスを毎回デプロイして変数へ投入している
- アカウントを毎回変数へ投入している
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 failing1) 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をサポートするフレームワークのサポートが欲しい