Perlで単体テスト
Perlの単体テスト、特にMockObjectを使ったテストについての情報が少ない気がするのでまとめてみる。
前提
モジュールは、CPAN形式であると前提。雛形は、Module::Starterで作成すると良い。
テストは、Test::Perl::Criticを入れる。
- インストール
$ sudo cpan Module::Starter $ sudo cpan Module::Starter::PBP $ sudo cpan Test::Perl::Critic
- 初期セットアップ
$ perl -MModule::Starter::PBP=setup
- モジュールの作成
$ module-starter --module=Ysm::Example $ cd Ysm-Example $ ls Build.PL Changes MANIFEST Makefile.PL README ignore.txt lib/ t/
- versionの修正
自分の環境では、作成された雛形の$VERSIONが以下のようになってて、テストが通らなかった。
よって、ourを付けて対応した。
... use version; $VERSION = qv('0.0.3'); ...
... use version; our $VERSION = qv('0.0.3'); ...
proveコマンド
開発中のテストは、Test::Harnessに含まれるproveコマンドを-lオプションで使用すると便利。
- インストール
$ sudo cpan Test::Harness
$ prove -l t t/00.load.........ok 1/1# Testing Ysm::Example 0.0.3 t/00.load.........ok t/perlcritic......ok t/pod-coverage....skipped all skipped: Test::Pod::Coverage 1.04 required for testing POD coverage t/pod.............skipped all skipped: Test::Pod 1.14 required for testing POD All tests successful, 2 tests skipped. Files=4, Tests=2, 0 wallclock secs ( 0.86 cusr + 0.06 csys = 0.92 CPU)
Test::Moreでの返り値のチェック
Test::Moreで使う主な返り値のチェックは以下。
ok ( <式>, test_name ) | <式>が成功したときOK、失敗時はNOT OK | ||
is ( this, that, test_name | thisがthatと等しいかチェック | ||
like ( this, qr/that/, test_name) | 正規表現 qr/that/にマッチするかチェック | ||
comp_ok ( this, op, that, test_name) | Perlの比較演算子を使って2つの引数を比較してチェック | ||
can_ok ( module, @methods ) | モジュールorオブジェクトがメソッド(複数指定可)を実行出来るかをチェック |
モック
モックを使う理由
単体テストは、以下のような理由で外部に依存しないように行わなければならない。
- 他の環境でテストしたら、テストが通らない可能性がある。
- DBの操作やファイルの読み書きなど、テストの時には実際には実行したくない操作もある
また、まだ開発中のパッケージを使用する場合に、そのパッケージが完成するまでテストが行えない。
そこで、これらの操作はモックを作成し、決まった挙動をさせて現在テストしてるパッケージのテストに専念する。
主に使うモックは以下。
- Test::MockObject
- Test::MockObject::Extends
- DBD::Mock
入ってない場合は、インストール。
$ sudo cpan Test::MockObject $ sudo cpan DBD::Mock
Test::MockObject
モックを作成し、set_*でメソッドをセットしたり、fake_*で未作成のモジュールに成りすますことが出来る。
use Test::MockObject; #モック作成 my $mock = Test::MockObject->new(); #trueを返すsomemethodをセット $mock->set_true( 'somemethod' ); ok( $mock->somemethod() ); #こんな感じでもセット出来る $mock->set_true( 'veritas') ->set_false( 'ficta' ) ->set_series( 'amicae', 'Sunny', 'Kylie', 'Bella' );
- モックの設定をするメソッド
mock(name, coderef) | nameメソッドにcoderefを設定する | ||
fake_module(module_name) | module_nameモジュールに成りすます | ||
fake_new(module_name) | module_nameモジュールをクラス化 | ||
set_always(name, value) | nameメソッドの返り値をvalueに設定 | ||
set_true(name_1, name_2, ... name_n) | name_*メソッドの返り値をtrueに設定 | ||
set_false(name_1, name_2, ... name_n) | name_*メソッドの返り値をfalseに設定 | ||
set_list(name, [ item1, item2, ... ]) | nameメソッドの返り値をリストに設定 | ||
set_series(name, [ item1, item2, ... ]) | nameメソッドの返り値をリストから順番に返す | ||
set_bound(name, reference) | nameメソッドの返り値にリファレンスを設定する | ||
set_isa( name1, name2, ... namen ) | isaを設定 | ||
remove(name) | nameメソッドを削除 |
Test::MockObjectを使ったテスト
チームで開発してる場合などでは、他の人が担当しているパッケージを使用する前提で開発を進めることがある。
そのような場合、fake機能を使用することで、そのパッケージの完成を待つことなく開発/テストを進めることが出来る。
例えば、以下のようなショッピングカートを開発していて、自分がカートクラスの方を担当している場合に、商品クラスをモックで作成して開発を行う。
use Test::More 'no_plan'; use strict; use warnings; use Test::MockObject; BEGIN { use_ok('Ysm::Example::Cart'); } # 商品クラスのモック作成 my $mock_item = Test::MockObject->new; $mock_item->fake_module('Ysm::Example::Item'); $mock_item->fake_new('Ysm::Example::Item'); $mock_item->set_always('get_id', 100); $mock_item->set_always('get_name', '商品A'); $mock_item->set_always('get_price', 150); $mock_item->set_always('get_pr', '商品紹介'); use_ok('Ysm::Example::Item'); # new test { my $t = Ysm::Example::Cart->new; ok $t; } # add 正常系 { my $t = Ysm::Example::Cart->new; my $item = Ysm::Example::Item->new(100, '商品A', 150, '商品紹介'); $t->add($item, 10); is $t->get_total, 1500; } # add 商品の数省略 { my $t = Ysm::Example::Cart->new; my $item = Ysm::Example::Item->new(100, '商品A', 150, '商品紹介'); $t->add($item); is $t->get_total, 150; }
Test::MockObject::Extends
既存のクラスからモックオブジェクトを生成し、モックにしたいメソッドのみ変更する。
use Some::Class; use Test::MockObject::Extends; # モックオブジェクトにするオブジェクト作成 my $object = Some::Class->new(); # オブジェクトをモックオブジェクト化 $object = Test::MockObject::Extends->new( $object ); # モックにしたいメソッドだけ設定する $object->set_true( 'parent_method' );
以下、Test::MockObjectと異なるもの、Test::MockObject::Extendsにしかないモックを設定するメソッド
new( $object or $class ) | オブジェクトorクラス名からモックオブジェクトを生成 | ||
unmock( $methodname ) | メソッドの削除 |
Test::MockObject::Extendsを使ったテスト
一個のメソッドから、複数のメソッドを呼びすことは、良くあることだと思う。
その場合、そのままやると、呼び出してるメソッドについてもテストを行わなければならないため、テストケースが膨大になり非常に面倒くさい。
また、テストが通らない場合に、現在のメソッドの問題なのか呼び出してるメソッドの問題なのかが分かりにくい。
そこで、呼び出すメソッドについては、モックにして、仕様通りの挙動をするものとして現在のメソッドのテストに専念する。
例えば、以下のような変なコードがあったとして (ぱっと思いつかなかったので適当;;)
package Ysm::Example::Controller; use strict; use warnings; ... sub execute{ my $self = shift; my ($a, $b, $c) = @_; eval{ my $d = $self->method1($a, $b); my $e = $self->method2($d); $self->method3($c, $e); }; if($@){ $self->logger->fatal("Cannot execute. $@") return; } 1; } ...
このexeuteというメソッドでテストしたいのは、method1、method2、method3のどれかが例外が発生した場合にちゃんと補足されるかということであって、method*が仕様通り動くかどうかは本質ではない。method*は、別で単体テストする。
よって、テストでは、method*はモックにして正常動作と例外発生させるようにする。
use Test::More 'no_plan'; use strict;use warnings; use Test::MockObject; use Test::MockObject::Extends; BEGIN { use_ok('Ysm::Example::Controller'); } ... # execute method1が例外 { my $t = Test::MockObject::Extends->new(Ysm::Example::Controller->new); $t->mock('method1', sub{ die('例外テスト')}); is $t->execute(123, 456, 789), undef; } ...
DBD::Mock
DBD::Mockは、DB用のモックで、接続の有無、結果のセット、auto_incrementの初期値などを行う。
use DBI; # モックDBへ接続 my $dbh = DBI->connect( 'DBI:Mock:', '', '' ) || die "Cannot create handle: $DBI::errstr\n"; # ステートメントハンドラの作成 my $sth = $dbh->prepare( 'SELECT this, that FROM foo WHERE id = ?' ); $sth->execute( 15 ); # 今実行したステートメントとプレースホルダーの値は以下のように取得出来る print "Used statement: ", $sth->{mock_statement}, "\n", "Bound parameters: ", join( ', ', @{ $sth->{mock_params} } ), "\n";
- プロパティ
DBD::Mockでは、プロパティを変更することで、DBの挙動を操作するものが多い。
全部書くと多すぎるので、良く使いそうなものをピックアップ。
-
- DBドライバのプロパティ
mock_connect_fail | DBへの接続失敗のフラグ。1の時失敗。 |
-
- DBハンドラのプロパティ
mock_all_history | ハンドラで行った処理の履歴。DBD::Mock::StatementTrackの配列のリファレンス | ||
mock_clear_history | 履歴のクリア。1にセットするとクリアされる | ||
mock_can_connect | DB接続可能かのフラグ。0の時接続出来ない | ||
mock_add_resultset | 結果のセット( \@resultset or \%sql_and_resultset ) | ||
mock_last_insert_id | last_insert_id | ||
mock_start_insert_id | auto_incrementの最初の値 | ||
mock_can_prepare | DBI::sth->prepareが出来るかのフラグ。0の時失敗 | ||
mock_can_execute | DBI::sth->executeが出来るかのフラグ。0の時失敗 |
-
- statementハンドラのプロパティ
mock_statement | 実行したステートメント | ||
mock_params | プレースホルダーの値の配列 |
DBD::Mockを使ったテスト
DBへの接続は、一個のクラスで行い隠蔽し、そのクラスから接続ハンドラを取得するように構成すると良い。
DBの切り替えや、DB接続に失敗した際のリトライ処理、リトライにも失敗した時の挙動を一元管理出来る。
例えば、下図において、DB接続クラスをモックにして、dbhメソッドでモックDBへの接続ハンドラを返すようにする。
use Test::More 'no_plan'; use strict; use warnings; use DBI; use Test::MockObject; use Test::MockObject::Extends; BEGIN { use_ok('Ysm::Example::Dao'); } use Ysm::Example::Dao; use Ysm::Example::Item; # Mock DBへ接続 my $dbh = DBI->connect('DBI:Mock:', '', '', { RaiseError => 1}); # DB接続クラスのモック作成 my $mock_dbcon = Test::MockObject->new; $mock_dbcon->fake_module('Ysm::Example::Dbconnection'); $mock_dbcon->fake_new('Ysm::Example::Dbconnection'); $mock_dbcon->set_always('dbh', $dbh); # 結果作成 sub create_item_list_result { my @res = ( ['id', 'name', 'price'], [100, '商品A', 150], [101, '商品B', 200], [102, '商品C', 250] ); \@res; } # select_all 正常系 { #結果のセット my $result_set = create_item_list_result; $dbh->{mock_add_resultset} = $result_set; my $t = Ysm::Example::Dao->new(Ysm::Example::Dbconnection->new); my $result = $t->select_all; # 結果の件数 is @{ $result }, 3; # 結果のチェック for(my $i = 0; $i < 3; $i++){ isa_ok $result->[0], 'Ysm::Example::Item'; is $result->[$i]->get_id, $result_set->[$i+1][0]; is $result->[$i]->get_name, $result_set->[$i+1][1]; is $result->[$i]->get_price, $result_set->[$i+1][2]; } #実行されたクエリをチェック my $history = $dbh->{mock_all_history}; is @{ $history }, 1, 'クエリを実行したのは一回だけ'; is $history->[0]->statement, 'SELECT * FROM item', '実行したクエリは、SELECT * \ FROM item'; is @{ $history->[0]->bound_params }, 0, 'プレースホルダーの数は0'; #モックDBハンドラの履歴をクリア $dbh->{mock_clear_history} = 1; }
参考
サイト
- Module::Starter - a simple starter kit for any module - metacpan.org
- Module::Starter::PBP - Create a module as recommended in "Perl Best Practices" - metacpan.org
- Test::More - yet another framework for writing test scripts - metacpan.org
- Test::Perl::Critic - Use Perl::Critic in test programs - metacpan.org
- Test::MockObject - Perl extension for emulating troublesome interfaces - metacpan.org
- Test::MockObject::Extends - mock part of an object or class - metacpan.org
- DBD::Mock - Mock database driver for testing - metacpan.org
- http://d.hatena.ne.jp/perlcodesample/20090214/1233423290
- Module::Starter - 配布形式のモジュールを作成 - Perl入門ゼミ
- DBD::Mock を使ったテスト - Yet Another Hackadelic