Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

型安全に TypeScript からスマートコントラクトを扱う

この記事は モバイルファクトリー Advent Calendar 2019の4日目の記事です。

こんにちは、ブロックチェーンチームでエンジニアをしている id:odan3240 です。

今回は Ethereum のスマートコントラクト (以下コントラクト) を TypeScript から型安全に扱う方法について紹介します。

前提

この記事は以下のバージョンを元に執筆されています。

  • NodeJS: 10.16.3
  • TypeScript: 3.7.2
  • web3: 1.2.4
  • typechain: 1.0.3
  • typechain-target-web3-v1: 1.0.3

JavaScript からコントラクトを扱う方法

JavaScript からコントラクトを扱う場合に用いるライブラリとして web3.js がよく知られています。 web3.js を用いてコントラクトのメソッドを呼び出すコードは以下の通りです。

import Web3 from "web3";

async function main() {
  // abi は設定されている前提
  const abi = {};
  const web3 = new Web3();
  const contract = new web3.eth.Contract(abi);

  await contract.methods.isOwner().call();
}

main();

このコードにおいて contract.methods.isOwner().call() の部分は実行時まで正常に実行されるかがわかりません。なぜなら、コントラクトの実装の内容に応じて生えているメソッドは異なるためです。

実際に VSCode でマウスオーバーして型を表示すると any 型になってしまっています。 f:id:odan3240:20191204135920p:plain

型安全に扱う方法

コントラクトの abi ファイルには、そのコントラクトにはどんな引数のどんなメソッドが実装されているかの情報が格納されています。この abi ファイルの情報から TypeScript の型定義ファイルを生成すれば、型安全にコントラクトを扱うことができます。

これを行うモジュールとして、@0x/abi-genTypeChain があります。 今回は実際にプロダクトで採用している TypeChain の使い方を紹介します。

TypeChain の使い方

TypeChain はコアパッケージの typechain と、コントラクトを扱う各ライブラリ向けのモジュールから構成されています。今回は web3.js 向けのコードを出力してほしいので typechain-target-web3-v1 を導入します。

$ yarn add -D typechain typechain-target-web3-v1

コントラクトのコンパイル結果を build ディレクトリに格納している場合、以下のコマンドを実行すると generated-abi/typechain/web3 ディレクトリに型定義ファイルが生成されます。

$ yarn typechain --target=web3-v1 'build/contracts/**/*.json' --outDir generated-abi/typechain/web3

使い方は簡単で、型を import して Contract クラスを new するところで as するだけです。

import Web3 from "web3";
import { AbiItem } from "web3-utils";
import { MyContract } from "../generated-abi/typechain/web3/MyContract";

async function main(): Promise<void> {
  const abi = ({} as any) as AbiItem;
  const web3 = new Web3();
  const contract = new web3.eth.Contract(abi) as MyContract;

  await contract.methods.isOwner().call();
}

main();

再びマウスオーバーしてみると型が表示されているのがわかります。 f:id:odan3240:20191204135951p:plain

注意点

web3.js と TypeChain を使うときには web3typechain-target-web3-v1 のバージョンの組み合わせに注意する必要があります。

具体的には以下の通りです。

  • 1.2.1 以前の web3 を使用している場合
    • 1.0.2 以前typechain-target-web3-v1 を使用する必要があります
  • 1.2.2 以降の web3 を使用している場合
    • 1.0.3 以降typechain-target-web3-v1 を使用する必要があります

この問題が発生している理由は、1.2.1 以前の web3 は型定義ファイルがモジュールに含まれていないため @types/web3 を利用する必要があったのに対して、1.2.2 からモジュール自体に型定義ファイルが含まれるようになり、これらの型定義ファイルに互換性がないためです。

github.com

サンプルリポジトリ

今回紹介したコードの断片が含まれているサンプルリポジトリです。 github.com

終わりに

型安全に TypeScript からコントラクトを扱う方法として、TypeChain を用いて abi ファイルから型定義ファイルを生成するアプローチを紹介しました。 エディタの補完機能によってコントラクトの実装を参照することなく、コントラクト周りのコードを実装できる体験を是非お試しください。

Perlでモックを多用したテストを書いてわかったこと

この記事はモバイルファクトリー Advent Calendar 2019 3日目です。

こんにちは、エンジニアのid:yumlonneです!
昨年のモバイルファクトリーAdvent Calendar 2018では、Perlのテストモジュールの紹介という記事を書きました。

今回は単体テストでモック*1を多用した結果、設計時にテストの視点を持つことが大事だなぁと思ったのでその学びを書くことにしました。

モックを使いたい!

私が所属するチームでは、モジュールを作成したときに単体テストを書く習慣があります。
それに倣って単体テストを書いてきましたが、以下の理由でテストが大変なことがありました。

  1. パブリックなメソッドではData::Validatorによる引数のバリデーションを行っている
  2. 生成するのが大変な巨大なオブジェクト*2を引数とするメソッドがある
  3. DBなどの状態を変更する作用(副作用)を持つメソッドをいろいろな箇所から呼んでいる

テスト用のユーティリティモジュールに巨大なオブジェクトを生成してくれる機能はありましたが、テスト用DBに問い合わせてデータを生成するため速度が犠牲になっていました。

そこで、巨大なオブジェクトをモックオブジェクトに差し替えることを考えました。Test::MockObjectを使おうと思ったのですが、モックしつつバリデーションを通過するオブジェクトを作ることができませんでした。 (追記: すみません、Test::MockObject#set_isaでisaを偽装できることを見落としていました。 ) また、Test::MockModuleTest::Mock::Guardを使えば一応バリデーションを通過するモックオブジェクトを作れたのですが、同じクラス*3のモックオブジェクトを複数個作成しそれぞれに別の振る舞いをさせることがでませんでした。
容易にできないのなら作ってしまえということで、バリデーションを通過するモックオブジェクトを作成するモジュールを書きました。
このモジュールは、@ISAに親クラスを設定した匿名クラスを作成します。これによりバリデーションを通過するモックオブジェクトを生成でき、1と2をクリアします。
3についてはTest::MockModuleTest::Mock::Guardでモックすることで、テスト内での変更作用を抑えることができます(チーム内ではTest::Specを使っているため、付随するTest::Spec::Mocksでモックすることが多いです)。

大変だと感じたところをモックによって解消できたので、これを使って実際にテストを書いてみました。

微妙なテストができる

例えば、以下のようなユーザ登録のコードがあったとします。

package Repository::User {
    sub register {
        # DBにユーザを登録してUserオブジェクトを返す
    }
};

package Model::User {
    use Data::Validator;
    use Repository::User;
    sub register {
        # 本当はData::Validatorでチェックしてるけど長いので省略
        my ($class, $user_info) = @_;

        # XXX: 失敗は適当に表現(reasonとかを含めるべき)
        return -1 unless $class->nickname_is_valid($user_info->nickname);

        return Repository::User->register($user_info);
    }

    sub nickname_is_valid {
        # ニックネームに使えない文字が無いか検査する
    }
}

このコードに対してこんなテストを書きました。

use Test::Spec;
use Test::MockObject::AnonClass qw/create_mock_object/;

use Model::User;
use Repository::User;

describe 'Model::User#registerについて' => sub {
    my ($user_info, @repository_args, $res);
    before all => sub {
        $user_info = create_mock_object(+{                  # user_infoのモックオブジェクトを作成
            parent => 'UserInfo',
            methods => +{
                nickname => 'piyo',
            },
        });
        Repository::User->stubs(register => sub {           # Repository::User#registerをモック
            @repository_args = @_;
            return 'mocked';
        });
    };
    context '登録できるnicknameのとき' => sub {
        my ($res);
        before all => sub {
            Model::User->stubs(nickname_is_valid => sub { 1 });
            $res = Model::User->register(user_info => $user_info);
        };
        it 'Repository::User#registerを正しい引数で呼ぶ' => sub {
            # @repository_argsが期待通りか
        };
        it '戻り値はRepository::User#registerの戻り値' => sub {
            # $resが期待通りか
        };
    };
    context '登録できないnicknameのとき' => sub {
        # 省略
    }
};

runtests unless caller;     # テスト実行

書いてあることをテストにしただけなのでこのテストは通ります。しかし、リファクタリングなどで実装が変わった場合、振る舞いが変わっていなくても失敗する可能性があります。

このテストを改善するために

この問題は、テストが実装を知りすぎているために発生しています。
なので、入出力などの振る舞いのみをチェックするブラックボックステストを書くことによってこの問題を解決または軽減できます。

ブラックボックステストの難しさ

振る舞いをテストすると口で言うのは簡単ですが、実際にはとても難しいことだと考えています。
値を受け取って値を返すだけの純粋なメソッドであれば入出力をチェックすれば良いのですが、DBなど外部状態の更新を伴うメソッドだとDBに問い合わせなければならないので場合によってはスローテストになってしまいますし、多数のモジュールに依存しているメソッドだと振る舞いが複雑になります。

ブラックボックステストをしやすいようにする

このような状況を避けるため、設計段階でテストのしやすさを考慮することが重要だと考えました。

一例ですが、上に挙げたユーザ登録の「DBへの副作用」をオブジェクトに包んで返すことでModel::Userから副作用を排除することができます。
これにより、Model::Userに関して実装に依らないテストを書くことができます。

package Repository::User {
    sub register {
        # DBにユーザを登録してUserオブジェクトを返す
    }
};

package Command::RegisterUser {
    sub new {}      # user_infoを保持
    sub exec {}     # ユーザを登録してUserを返す
}

package Command::None {
    sub new {}
    sub exec {}     # 何もしない
}

package Model::User {
    sub register_command {
        # 本当はData::Validatorでチェックしてるけど長いので省略
        my ($class, $user_info) = @_;

        # 失敗は適当に表現(reasonとかを含めるべき)
        return Command::None->new
            unless $class->nickname_is_valid($user_info->nickname);

        return Command::RegisterUser->new($user_info);
    }
    sub nickname_is_valid {
        # ニックネームに使えない文字が無いか検査する
    }
}

副作用がないメソッドのため、引数と戻り値だけの対応で考えることができます。

use Test::Spec;

use UserInfo;
use Model::User;
use Repository::User;

describe 'Model::User#register_commandについて' => sub {
    context '登録できるnicknameのとき' => sub {
        my ($user_info, $res);
        before all => sub {
            $user_info = UserInfo->new(
                nickname => 'piyo',
                age      => 22,
                comment  => 'hello!',
            );
        };
        it 'ユーザ登録コマンドを返す' => sub {
            $res = Model::User->register_command(user_info => $user_info);
            ok $res->isa('Command::RegisterUser');
        };
    };
    context '登録できないnicknameのとき' => sub {
        # 省略
    }
};

runtests unless caller;     # テスト実行

この他にコマンドのテストを書く必要がありますが、コマンドの処理自体はシンプルなので簡単に書くことができます。

副作用を発生させる箇所を考慮することで、テストしやすい部分を増やすことができました。

まとめ

モック多用したテストを書くことにより、ブラックボックステストの観点も重要だということがわかりました。
設計段階からテストを考慮して進めていくと、テストしにくい副作用を持つモジュールや依存関係の都合上ブラックボックスではテストしにくいモジュールを減らすことができ、より安定したプロダクトにすることができるのではないかと思います。

*1:この記事では、厳密にはスタブであるものもモックと呼んでいます

*2:Aniki::Rowオブジェクトや、それを複数個持った状態データなど

*3:Perlの記事ですがパッケージをクラスと表現しています