chike0905の日記

何者かになりたい

BrownieでSmart Contractのテストを書く

概要

Ethereum上にデプロイされたSmart Contractはデプロイ後にアップデートすることができない。そのため、デプロイ前に十分な動作検証を行う必要がある。動作検証を行うにあたり、pythonでテストを書くことができるpopulusが一般的なフレームワークであったが、開発が停止している。現在pythonでテストを書くことが可能なフレームワークとしてBrownieがある。本稿ではBrownieを用いてSmart Contractのテストを実施する手順を記す。

環境

  • macOS mojave 10.14.5
  • Homebrew 2.1.4
    • npm 6.9.0
  • pyenv 1.2.11
  • solc 0.5.9

Brownieのインストール

Brownieの依存ツールとしては、python3、pip、ganache-cliが挙げられている。 ganache-cliはSolidityのフレームワークtruffleのチームが開発している開発しているEthereum RPCクライアントだ。今回はCLIツールを用いる。npmでインストールするので、以下のコマンドでインストールする。

npm install -g ganache-cli

Brownie自体はpipでインストールすることが可能だ。

pip install eth-brownie

インストールされたことを確認する。

chike[~]brownie
Using solc version v0.5.9
Brownie v1.0.0b7 - Python development framework for Ethereum

Usage:  brownie <command> [<args>...] [options <args>]

また、コンパイルにsoldityコンパイラであるsolcが必要なため、brewでインストールする。

brew install solidity

Browineプロジェクトの作成とディレクトリ構成

brownie init projectnameのコマンドでプロジェクトを新規作成できる。

chike[~]brownie init test
Using solc version v0.5.9
Brownie v1.0.0b7 - Python development framework for Ethereum

Brownie environment has been initiated at /Users/chike/test

プロジェクトディレクトリ以下に新規に作成されるディレクトリは以下の構成となる。

chike[~]tree test
test
├── brownie-config.json    // brownieプロジェクトの設定ファイル
├── contracts              // solidity/viperのソースファイル
├── reports                // JSON形式のBrownie GUIでのログファイル
├── scripts                // デプロイや実行に関わるスクリプト
└── tests                  // テストコード

4 directories, 1 file

そのほかにもbrownieでコンパイルを行うとbuildディレクトリが作成され、その中にバイトコードコントラクトやテストデータが入る。

また、brownieにはテンプレートがいくつか用意されており、以下のコマンドでテンプレートを使った新規作成が行える。

brownie bake token // tokenを扱うテンプレートで新規作成

コンパイルとテストの実行

brownieを使ってコンパイルする際のsolcのバージョンをbrownie-config.jsonの中で指定している。指定されたバージョンのsolcがないとそのバージョンのsolcをインストールしようとする。1

今回は、solcのバージョンは0.5.9を利用しているので、brownie-config.jsonの当該部分を書き換える。以下は書き換え部分の抜粋。

    "solc":{
        "optimize": true,
        "runs": 200,
        "version": "0.5.9"
    },

プロジェクトディレクトリ直下でbrownie complieのコマンドでcontractsディレクトリにあるソースコードコンパイルする。今回はbakeしたtokenをコンパイルしてみる。

chike[token]brownie compile
Using solc version v0.5.9
Brownie v1.0.0b7 - Python development framework for Ethereum

Using solc version v0.5.9
Compiling contracts...
Optimizer: Enabled  Runs: 200
 - Token.sol...
 - SafeMath.sol...
Brownie project has been compiled at /Users/chike/Documents/workspace/brownietest/token/build/contracts

次に、brownie testtestsディレクトリにあるテストが実行される。ひとまずtokenのそのまま実行してみる。

chike[token]brownie test
Using solc version v0.5.9
Brownie v1.0.0b7 - Python development framework for Ethereum

Using solc version v0.5.9
Running 6 tests across 2 modules.

Running /Users/chike/Documents/workspace/brownietest/token/tests/transfer.py - 1 test (1/2)
 ✓ 0 - setup (0.1654s)  
 ✓ 1 - Transfer tokens (0.1567s)  
Completed /Users/chike/Documents/workspace/brownietest/token/tests/transfer.py (0.4863s)

Running /Users/chike/Documents/workspace/brownietest/token/tests/approve_transferFrom.py - 5 tests (2/2)
 ✓ 0 - setup (0.1373s)  
 ⊝ 1 - balance (skipped)
 ✓ 2 - Set approval (0.2117s)  
 ✓ 3 - Transfer tokens with transferFrom (0.2492s)  
 ✓ 4 - transerFrom should revert (0.1418s)  
 ‼ 5 - This test is expected to fail (AttributeError)
Completed /Users/chike/Documents/workspace/brownietest/token/tests/approve_transferFrom.py (1.5656s)

Total runtime: 2.2292s
SUCCESS: All tests passed.

また、特定のテストファイルのテストのみを実行したい場合はbrownie test testfileで実行できる。

テストスクリプトの記述法

brownieはそもそもコントラクトのフレームワークなので、CLIツールとしてインタラクティブシェルがあり、デプロイやトランザクションの発行などの操作が可能になっている。具体的な各操作は公式ドキュメントのCLIの操作を見るのが一番手っ取り早い。

テストスクリプトは全てtestsディレクトリ以下に設置する。設置されたファイルはbrownieにテスト用スクリプトとして見なされるので、何か使い回しの処理をしたいときはscriptsディレクトリに設置し、importするようにする。

まずはbrownieのメソッドを利用可能にするためにfrom brownie import *する。その後、テストを開始する際はsetup関数を最初に実行する仕組みになっているので、コントラクトの初期セットアップなどはsetup関数の中に記述する。その後は各関数がそれぞれ上から順に1ユニットテストとして扱われる。

また、関数名直後にコメントを書き込むことで、テスト実行時にテストユニット名として表示される。下記サンプルと先ほどのテスト結果の項目を見比べれば理解できるだろうか。

以下はbrownieでbakeしたtokenのtestサンプルスクリプトである。ここでは、scriptsディレクトリ内にTokenコントラクトをデプロイするスクリプトが記述されており、それを用いてコントラクトデプロイを行なっている。

#!/usr/bin/python3

from brownie import *
import scripts.token


def setup():
    scripts.token.main()
    global token
    token = Token[0]


def balance(skip=True):
    check.equal(
        token.balanceOf(accounts[0], "1000 ether"),
        "Accounts 0 balance is wrong"
    )


def approve():
    '''Set approval'''
    token.approve(accounts[1], "10 ether", {'from': accounts[0]})
    check.equal(
        token.allowance(accounts[0], accounts[1]),
        "10 ether",
        "Allowance is wrong"
    )
    check.equal(
        token.allowance(accounts[0], accounts[2]),
        0,
        "Allowance is wrong"
    )
    token.approve(accounts[1], "6 ether", {'from': accounts[0]})
    check.equal(
        token.allowance(accounts[0], accounts[1]),
        "6 ether",
        "Allowance is wrong"
    )


def transfer():
    '''Transfer tokens with transferFrom'''
    token.approve(accounts[1], "6 ether", {'from': accounts[0]})
    token.transferFrom(
        accounts[0],
        accounts[2],
        "5 ether",
        {'from': accounts[1]}
    )
    check.equal(
        token.balanceOf(accounts[2]),
        "5 ether",
        "Accounts 2 balance is wrong"
    )
    check.equal(
        token.balanceOf(accounts[1]),
        0,
        "Accounts 1 balance is wrong"
    )
    check.equal(
        token.balanceOf(accounts[0]),
        "995 ether",
        "Accounts 0 balance is wrong"
    )
    check.equal(
        token.allowance(accounts[0], accounts[1]),
        "1 ether",
        "Allowance is wrong"
    )


def revert():
    '''transerFrom should revert'''
    check.reverts(
        token.transferFrom,
        (accounts[0], accounts[3], "10 ether", {'from': accounts[1]})
    )
    check.reverts(
        token.transferFrom,
        (accounts[0], accounts[2], "1 ether", {'from': accounts[0]})
    )


def unfinished(pending=True):
    '''This test is expected to fail'''
    token.secretFunction(accounts[1], "10 ether")

skipとpending

各関数で引数skipを設定し、skipがTrueの時はそのテストはスキップされる。また、引数pendingを設定し、Trueにした場合、そのテストは実行されるが、失敗してもエラーの詳細は表示されない。

アカウント操作

テスト実行のたびに、それぞれ100Ether持ったアカウントが10個初期値として生成される。各accountオブジェクトは変数accounts内にリストとして保持されている。

accountsオブジェクトのメソッドとしては、保有Ether量を出力するbalance()コントラクトのデプロイを行うdeploy()、送金を行うtransfer()などが用意されている。

コントラクトのデプロイ・操作

contract以下に記述された各コントラクトファイルは、テスト実行時にコンパイルされ、正常にコンパイルされればコントラクト名でコントラクトのオブジェクトを参照できる。このコントラクトオブジェクトをアカウントオブジェクトのdeploy()メソッドを用いることで、ローカルテスト環境にデプロイできる。deploy()メソッドでは第1引数にコントラクトオブジェクト、第2引数以降にコントラクトコンストラクタの引数を指定する。

t = accounts[0].deploy(Token, "Test Token", "TST", 18, "1000 ether")

また、ここでは用いていないが、最終引数には、トランザクションの付与情報をdict形式で記述することができる。ここで指定できる付与情報は、web3のeth.send.Transactionのパラメータを指定することができる。例えばコントラクトデプロイ時に一定金額デポジットを要求するような場合、ここでvalueを指定することで送金しながらのデプロイトランザクションの発行をテストできる。

デプロイされたコントラクトは、ここで指定したコントラクトオブジェクトにリスト的に格納されている。そのため、テスト中に最初にデプロイしたコントラクトはToken[0]の形でそのオブジェクトにアクセスできる。

コントラクトの各関数は、デプロイされたコントラクトオブジェクトのメソッドとしてアクセスすることができる。

まとめ

自分は普段ずっとpythonを主な使用言語にしているので、pythonでこうしたテストがかけるのは非常にありがたい。また、アップデート困難なブロックチェーン上のアプリケーションをテストするのは非常に重要である。一方で、開発者の想定外/テスト外のバグは完全に潰すことはほぼ不可能なので、そもそも根本的にブロックチェーンを改善する必要があるが、それはまた別の話だろう。


  1. 自分の環境では別バージョンsolcをmakeする際にエラーが発生したのだが、本稿の主眼ではないので追求しなかった。