この記事はモバイルファクトリー Advent Calendar 2019 3日目です。
こんにちは、エンジニアのid:yumlonneです!
昨年のモバイルファクトリーAdvent Calendar 2018では、Perlのテストモジュールの紹介という記事を書きました。
今回は単体テストでモック*1を多用した結果、設計時にテストの視点を持つことが大事だなぁと思ったのでその学びを書くことにしました。
モックを使いたい!
私が所属するチームでは、モジュールを作成したときに単体テストを書く習慣があります。
それに倣って単体テストを書いてきましたが、以下の理由でテストが大変なことがありました。
- パブリックなメソッドではData::Validatorによる引数のバリデーションを行っている
- 生成するのが大変な巨大なオブジェクト*2を引数とするメソッドがある
- DBなどの状態を変更する作用(副作用)を持つメソッドをいろいろな箇所から呼んでいる
テスト用のユーティリティモジュールに巨大なオブジェクトを生成してくれる機能はありましたが、テスト用DBに問い合わせてデータを生成するため速度が犠牲になっていました。
そこで、巨大なオブジェクトをモックオブジェクトに差し替えることを考えました。Test::MockObjectを使おうと思ったのですが、モックしつつバリデーションを通過するオブジェクトを作ることができませんでした。 (追記: すみません、Test::MockObject#set_isaでisaを偽装できることを見落としていました。 ) また、Test::MockModuleやTest::Mock::Guardを使えば一応バリデーションを通過するモックオブジェクトを作れたのですが、同じクラス*3のモックオブジェクトを複数個作成しそれぞれに別の振る舞いをさせることがでませんでした。
容易にできないのなら作ってしまえということで、バリデーションを通過するモックオブジェクトを作成するモジュールを書きました。
このモジュールは、@ISA
に親クラスを設定した匿名クラスを作成します。これによりバリデーションを通過するモックオブジェクトを生成でき、1と2をクリアします。
3についてはTest::MockModuleやTest::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の記事ですがパッケージをクラスと表現しています