tsucchi’s diary(元はてなダイアリー)

はてなダイアリー(d.hatena.ne.jp/tsucchi1022)から移行したものです

Perl のテストについて(2011年改訂版1)

0. 前提

0.1 はじめに

以前、Perl のテストについてという記事を書きました。2008年の記事なのですが、現在もそこそこのアクセスがあるようです。

ただ、Test::More がアップグレードされて新しい書き方ができるようになったり、そもそも僕自身のプログラムの書き方が少しずつ変わってきているので、いつまでも古い記事を見てもらうのもどうかな、と思い、2011年バージョンをつくってみました。(ちょっとしか改定してませんが。。。)DB まわりのテストに関しては、また次回。

0.2 対象

Perl は書けるが、Perl のテストコードを書いたことが無い人。

0.3 動作確認環境
% uname -a
FreeBSD over.tsucchi.mydns.jp 8.2-RELEASE FreeBSD 8.2-RELEASE #1: Sun Feb 27 23:51:01 JST 2011     root@over.tsucchi.bne.jp:/usr/obj/usr/src/sys/GENERIC  i386

% perl -v

This is perl 5, version 12, subversion 2 (v5.12.2) built for i386-freebsd

Copyright 1987-2010, Larry Wall

Perl may be copied only under the terms of either the Artistic License or the
GNU General Public License, which may be found in the Perl 5 source kit.

Complete documentation for Perl, including FAQ lists, should be found on
this system using "man perl" or "perldoc perl".  If you have access to the
Internet, point your browser at http://www.perl.org/, the Perl Home Page.

僕が仕事で使っているのは Perl 5.8.8 なので、5.8 以上なら問題ないように書いているつもりです。

1. Test::More の使い方

以下は最も単純な例です。
テストスクリプトには、.t という拡張子をつけることが一般的です。また、テストスクリプトは t というディレクトリに入れることが一般的です。

#!/usr/bin/perl -w
#== 1-1.t ==

use strict;
use warnings;

use Test::More;
# 昔はこんな感じでテスト数を書いていた。また、テスト数を書くのが面倒な場合 "no_plan" と書いていました。
# use Test::More tests => 4; 


# ok メソッド。真理値をテストする
ok( 1 == 1 );

# is メソッド。文字列の比較。診断メッセージが ok よりも多いので、なるべくこちらを使う
is( "abc", "ab" . "c");

# isnt メソッド。is の反対。一致しない場合
isnt("abc", "ab" . "d");

# like メソッド。正規表現の比較
like("abcde", qr/^a/);

done_testing();

テストプログラムは perl コマンドでも実行できますし、prove コマンドを使っても構いません。

% perl 1-1.t
1..4
ok 1
ok 2
ok 3
ok 4
% prove 1-1.t
1-1....ok
All tests successful.
Files=1, Tests=4,  1 wallclock secs ( 0.00 usr  0.03 sys +  0.16 cusr  0.06 csys =  0.25 CPU)
Result: PASS

テストが通らない場合、失敗したテストの場所と、エラーが表示されます。エラーの内容としては、返ってきた値(got)と返ってきてほしかった値(expected)が書いてあります。

#!/usr/bin/perl -w
# 1-2.t
use strict;
use warnings;

use Test::More;

is(1, 2); #エラーになるはず

done_testing();
% prove 1-2.t
1-2.t .. 1/?
#   Failed test at 1-2.t line 8.
#          got: '1'
#     expected: '2'
# Looks like you failed 1 test of 1.
1-2.t .. Dubious, test returned 1 (wstat 256, 0x100)
Failed 1/1 subtests

Test Summary Report
-------------------
1-2.t (Wstat: 256 Tests: 1 Failed: 1)
  Failed test:  1
  Non-zero exit status: 1
Files=1, Tests=1,  0 wallclock secs ( 0.01 usr  0.09 sys +  0.03 cusr  0.05 csys =  0.19 CPU)
Result: FAIL

2. Test::More のもう少し現実的な例

サブルーチンをテストします。

テストするサブルーチンを含むファイルを testsub.pm とします

# == testsub.pm ==
use strict;
use warnings;

# 引数の最大値を返す
sub max {
    my $max = undef;
    for my $value (@_) {
        if ( !defined $max ) {
            $max = $value;
        }
        if ( defined $max && $max < $value ) {
            $max = $value;
        }
    }
    return $max;
}
1;

testsub.pm には、配列の中から最大値を求める関数 max があります。これをテストします。

#!/usr/bin/perl -w
# == 01-testsub.t ==

use strict;
use warnings;
use Test::More;

# use 出来るかどうかをテストする use_ok('testsub'); を書くことも良く行われています
use testsub; # 利用するサブルーチンを含むファイルを use します

is( max((1, 2, 3)), 3);
is( max((3, 2, 1)), 3);
is( max((1, 1)), 1);
is( max((-1, -2, -3)), -1);
ok( !defined max(undef) );
done_testing();

テストします。

% prove 01-testsub.t
01-testsub.t .. ok
All tests successful.
Files=1, Tests=5,  0 wallclock secs ( 0.00 usr  0.10 sys +  0.00 cusr  0.09 csys =  0.19 CPU)
Result: PASS

テストが通ったので、(とりあえずは)問題がないことが分かります。

3. テストの利点

先ほど実装したサブルーチン max が冗長な書き方なので、これを改善します。

sub max {
    my $max = undef;
    for my $value (@_) {
        $max = $value if ( !defined $max );
        $max = $value if ( defined $max && $max < $value );
    }
    return $max;
}

テストします。

% prove 01-testsub.t
01-testsub.t .. ok
All tests successful.
Files=1, Tests=5,  0 wallclock secs ( 0.01 usr  0.09 sys +  0.00 cusr  0.09 csys =  0.20 CPU)
Result: PASS

テストがあれば、安心して修正ができます

さらに、$max = $value の代入が重なっているのが気になるので、
修正します。

sub max {
    my $max = shift;
    for my $value (@_) {
        $max = $value if ( $max < $value );
    }
    return $max;
}
% prove 01-testsub.t
01-testsub.t .. ok
All tests successful.
Files=1, Tests=5,  0 wallclock secs ( 0.05 usr  0.05 sys +  0.03 cusr  0.05 csys =  0.19 CPU)
Result: PASS

サブルーチンの実装をよりシンプルにすることができました。

テストがあると、より実装をシンプルにすることができます

4. バグ対応

max に文字列が渡る場合があるとします。

現行の実装では、文字列が渡ると警告メッセージが出てしまいます。また、("aaa", "bbb", "ccc") を渡すと、"aaa" が帰ってしまいます。

これに対応します。

まず、テストを修正します。

# == 01-testsub.t  ==
# 中略
# 以下を追加 done_testing() の前に
is( max("aaa", 1, 2, 3), 3);
ok( !defined max(("aaa", "bbb", "ccc")) );
done_testing();

実行します。

% prove 01-testsub.t
01-testsub.t .. 1/? Argument "aaa" isn't numeric in numeric lt (<) at testsub.pm line 4.
Argument "bbb" isn't numeric in numeric lt (<) at testsub.pm line 4.
Argument "aaa" isn't numeric in numeric lt (<) at testsub.pm line 4.
Argument "ccc" isn't numeric in numeric lt (<) at testsub.pm line 4.

#   Failed test at 01-testsub.t line 17.
# Looks like you failed 1 test of 7.
01-testsub.t .. Dubious, test returned 1 (wstat 256, 0x100)
Failed 1/7 subtests

Test Summary Report
-------------------
01-testsub.t (Wstat: 256 Tests: 7 Failed: 1)
  Failed test:  7
  Non-zero exit status: 1
Files=1, Tests=7,  0 wallclock secs ( 0.02 usr  0.09 sys +  0.02 cusr  0.08 csys =  0.20 CPU)
Result: FAIL

警告が出ている上に、テストが失敗したので、これを通るように修正します

# == testsub.pm ==
sub max {
    my $max = undef;

    for my $value (@_) {
        next if ( defined $value && $value !~ /^[+-]?\d+$/ );
        $max = $value if ( !defined $max || $max < $value );
    }
    return $max;
}
% prove 01-testsub.t
01-testsub.t .. ok
All tests successful.
Files=1, Tests=7,  0 wallclock secs ( 0.01 usr  0.09 sys +  0.01 cusr  0.08 csys =  0.19 CPU)
Result: PASS

警告も出ず、テストが通ったので、修正完了です。(が、max の実装はやや複雑になってしまいました。個人的には引数のチェックは呼び出し側にやらせるほうが良いのでは、と思います。)

おまけ

今回、説明用に max() というサブルーチンを定義しましたが、List::Util というモジュールに max() という同等のサブルーチンがあります。List::Util は有名なモジュールですので、しっかりデバッグもされています。本来であれば、List::Util の max() を使うべきです。

5. クラス(モジュール)のテスト

まず、下記のようなディレクトリを用意します。

% mkdir lib t
% ls
lib/  t/

lib ディレクトリにモジュールが、t ディレクトリにモジュールのテストが入ります。

まず、下記のような lib ディレクトリに Food クラス(Food.pm)があるとします。フィールド price とメソッド price_with_tax(税込みの金額)を持つだけの単純なクラスです。

# lib/Food.pm
use strict;
use warnings;

package Food;
sub new {
    my $class = shift;
    my $self = { price => shift };
    bless $self, $class;
}

sub price {
    return shift->{price};
}

sub price_with_tax {
    return shift->price() * 1.05;
}

1;

このモジュールをテストします。 t ディレクトリに、下記のような 01_price_with_tax.t を作成します

#!/usr/bin/perl -w
# t/01_price_with_tax.t

use strict;
use warnings;

use Test::More;

use_ok("Food");
use Food;

my $tested = Food->new(100);
is(105, $tested->price_with_tax());
done_testing();

prove コマンドでテストします。

% prove -l
t/01_price_with_tax.t .. ok
All tests successful.
Files=1, Tests=2,  0 wallclock secs ( 0.02 usr  0.08 sys +  0.00 cusr  0.09 csys =  0.18 CPU)
Result: PASS

prove コマンドを引数なしで実行すると、t ディレクトリのテストスクリプト(拡張子.t)をすべて実行します。-l オプションは lib を参照させるために必要です。-l を指定しないと、モジュールを見つけられないエラーとなります。

% prove
t/01_price_with_tax.t .. Can't locate Food.pm in @INC (@INC contains: /usr/local/lib/perl5/5.12.3/BSDPAN /usr/local/lib/perl5/site_perl/5.12.3/mach /usr/local/lib/perl5/site_perl/5.12.3 /usr/local/lib/perl5/5.12.3/mach /usr/local/lib/perl5/5.12.3 .) at t/01_price_with_tax.t line 10.
BEGIN failed--compilation aborted at t/01_price_with_tax.t line 10.
# Looks like your test exited with 2 before it could output anything.
t/01_price_with_tax.t .. Dubious, test returned 2 (wstat 512, 0x200)
No subtests run

Test Summary Report
-------------------
t/01_price_with_tax.t (Wstat: 512 Tests: 0 Failed: 0)
  Non-zero exit status: 2
  Parse errors: No plan found in TAP output
Files=1, Tests=0,  0 wallclock secs ( 0.04 usr  0.05 sys +  0.02 cusr  0.05 csys =  0.17 CPU)
Result: FAIL

6. subtest

Test::More のバージョン 0.92 ごろから subtest という機能が使えるようになっています。subtest を使うと、subtest 単位で done_testing() を書くことが出来ます(外部コードで exit した場合などに、テストの失敗箇所を特定しやすくなる)。また、テストに名前をつけることが出来ます(テストが見やすくなる)。

subtest を使って先ほどのテストを書くと下記のようになります。

#!/usr/bin/perl -w
# t/02_use_subtest.t

use strict;
use warnings;

use Test::More;

use Food;

subtest 'price_with_tax', sub {
    my $tested = Food->new(100);
    is(105, $tested->price_with_tax());
    done_testing();
};
done_testing();

この程度のテストだとメリットはあまり見えませんが、テスト数が増えたときの見通しのよさは subtest を使ったときのほうがよいのではないか、と思います。

7. Test::Class

Test::Class を使うと、xUnit 風の記述が可能です。なお、Test::Class は cpan モジュールなので、別途インストールが必要です。

先ほどと同じ例を Test::Class を使って書いてみます。

t ディレクトリに下記のような、03_price_with_tax2.t を作成します。

#!/usr/bin/perl
# t/03_price_with_tax2.t

use strict;
use warnings;

PriceTestWithTax->runtests; # テストクラス名->runtests でテストを実行


package PriceTestWithTax; # テストするパッケージ名(テストクラス名を定義する必要がある)
use parent qw(Test::Class); # Test::Class を継承する(parent が無い場合は base でも良い)
use Test::More;
use Food;

my $tested;

sub setup : Test(setup) { # 初期化。テスト開始前毎に呼ばれる
    $tested = Food->new(100);
}

sub tax_test : Test(1) { # テスト数を記載。no_plan 風の Tests という書き方も可能
    is(105, $tested->price_with_tax());
}

sub teardown : Test(teardown) { # 後始末。テスト終了毎に呼ばれる
    $tested = undef;
}

1;
% prove -l t/03_price_with_tax2.t
t/03_price_with_tax2.t .. ok
All tests successful.
Files=1, Tests=1,  1 wallclock secs ( 0.02 usr  0.08 sys +  0.04 cusr  0.14 csys =  0.28 CPU)
Result: PASS

テストも無事通りました。

8. どのテストツールを使うべきか

Perl においてユニットテストを行うツールとしては、今回紹介した Test::More, Test::Class, Test::Base などが有名です。

昔は、(サブルーチン名として)テストに名前をつけられることと、setup, teardown などが使えるなどの理由で、大きなテストを書くときは Test::Class を使うメリットがある、と考えていました。しかし最近では Test::More で subtest が使えること、また Perl には BEGIN, END ブロックがあるので、他の言語とくらべて setup, teardown が使えるメリットが少ない(BEGIN/END は startup/shutdown 相当なので厳密にはちょっと違いますが)事もあって、Test::Class はあまり使っていません。

また、Test::Class が有名になる前は、xUnit 系として Test::Unit というモジュールがありましたが、こちらは現在ほとんどメンテナンスされていないため、使うべきではありません。

Test::Base というモジュールもあって、出力が diff 形式になっていて見やすいとか、テストメソッドに対して沢山データを用意するようなテストが得意、といった特徴があるみたいです。ただ、私自身は長い文字列を比較することはあまりないので、diff のありがたみが良く分からないこと、「そもそもメソッドに対して沢山データを容易するようなテストは何か(設計か実装かあるいはテストコードそれ自身か)が間違っているだろう」と思うので、必要だと思ったことがありません。

長々と書きましたが、「Test::More の0.9X(最新版)を入れて、subtest を使ってテストを書くのが良いのではないか」というのが現状の僕の結論です。