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オプションで使用すると便利。

  • lオプションは、カレントディレクトリのlibディレクトリをモジュールの検索パス@INCに追加するオプション。これをやらないと現在開発中のモジュールを検索出来ずテスト出来ない。
  • インストール
$ 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;
}