Happy Testing Perl

第2回Test::Baseの紹介

モバイルファクトリーの伏原です。 私は今回Test::Baseというモジュールを紹介させてもらいます。

Test::Baseとは

Test::Baseは、Kwikiなどの作者として知られるIngy döt Net氏が作成した⁠Data Driven Testing Framework(データ駆動型テストフレームワーク)⁠です。 データ駆動型テストとは何なのか、実際のテストを見て頂くのが一番早いと思うので、早速実際にTest::Baseを使ったテストを書いてみることにします。

今回使うサンプルプログラム

NabeAtzz問題を解くプログラムを作ります。NabeAtzz問題にはいくつかのバリエーションがありますが、今回は以下の条件を満たすもの、と言うことにします。

  • 1からnまでの整数に対して
  • 3の倍数の時は⁠fool⁠と出力する
  • 5の倍数の時は⁠dog⁠と出力する
  • いずれかの桁に3が含まれる整数の時も⁠fool⁠と出力する
  • それ以外の数の場合は整数を⁠[1]⁠のように[]で囲って出力する

これを満たすために、とりあえず以下のプログラムを作ってみました。

nabeatzz.pl
#!/usr/bin/perl -Ilib
use strict;
use warnings;

use NabeAtzz;

for my $n (1..$ARGV[0]) {
    print NabeAtzz->shout($n) . "\n";
}

ファイル作成後、

./nabeatzz.pl 20

のように実行すると、1から20までをNabeAtzz問題のルールに則って出力します。このプログラムを見ると、NabeAtzzモジュールのshoutメソッドが正しく動作すれば、プログラム全体が上手く動くことがわかります。ですので、実際にプログラム全体を動かさなくても、NabeAtzzモジュールのshoutメソッドをテストすれば良いことになります。

Test::Baseを使ったテストの書き方

さて、Test::Baseを使ってテストを書いていくわけですが、Test::BaseはTest::Moreで使えるメソッドと同じものをexportしているので、Test::Moreとまったく同じ書き方でテストを書くこともできます。まずは一番簡単な「3と5に関係しない数の時はそのまま数が返る」ことをテストします。

nabeatzz.1.t
use strict;
use warnings;
use Test::Base;

use NabeAtzz;

plan tests => 3;

is(NabeAtzz->shout(1),  '[1]' );
is(NabeAtzz->shout(4),  '[4]' );
is(NabeAtzz->shout(19), '[19]');

まだNabeAtzz.pmを作っていないので、このテストを実行するともちろんエラーになります。まずは、このテストを通るプログラムを書いてみます。

NabeAtzz.pm
package NabeAtzz;
use strict;
use warnings;

sub shout {
    my ($class, $number) = @_;

    return "[$number]";
}


1;

改めて以下のようにテストを実行すると、テストが正常に実行されると思います。

> perl -Ilib t/nabeatzz.1.t
1..3
ok 1
ok 2
ok 3

Test::Baseを使ったテストのスタイル

nabeatzz.1.tをよく見てみると、実は同じようなテストを繰り返していることがわかります。基本的にテストでよくあるパターンは、⁠関数への一定の入力値に対する出力値を期待した値と比較する」というもので、正常値・異常値・境界値などをチェックするために、入力値と期待値のセットが膨大になり、同じようなテストコードを書くことになります。 必然、コードをコピーアンドペーストなどで複製して修正するような書き方になり、ミスなどで誤ったテストスクリプトを書いてしまう原因になります。 Test::Baseでは、こういったパターンのテストをやりやすくするためのフレームワークです。先ほどのnabeatzz.1.tは、Test::Baseを使った本来のスタイルで書くと以下のように書くことができます。

nabeatzz.2.t
use strict;
use warnings;
use Test::Base;

use NabeAtzz;

plan tests => 1*blocks;

filters {
    input    => [qw/chomp/],
    expected => [qw/chomp/],
};

run {
    my $block = shift;

    is(NabeAtzz->shout($block->input), $block->expected);
};

__END__

=== test 1
--- input
1
--- expected
[1]

=== test 4
--- input
4
--- expected
[4]

=== test 19
--- input
19
--- expected
[19]

スクリプトの行数自体は長くなってしまいましたが、見てのとおり、テストを行うロジックとテストに対して渡す入力値・期待値が完全に分離されています。これであれば単なるテストケースの追加でテストのロジックを壊してしまう心配がなくなります。 では色々と登場した新しい記述について順番に説明していこうと思います。

Test::Baseの記述方法

データ

Test::Baseでは、デフォルトで__END__以下(<DATA>で読み出すことのできる部分ですね)からテストデータを読み込みます。テストデータは

=== テスト名(省略可能)

という行で区切られていて、この単位を「ブロック」と呼びます。ブロックの中には入力値・期待値などを設定していくわけですが、その際

--- フィールド名
値

という形で入力していきます。ここは、

--- フィールド名: 値

という書き方もできますが、値を複数行入力したい場合は先に出た書き方を用いることになります。筆者は

--- フィールド1: 値
--- フィールド2: 値
--- フィールド3: 値

のようになって行が詰まって見辛くなることがあるので、なるべく行を分ける書き方で書くようにしています。 入力値のフィールドをinput、期待値をexpectedという名前にするのはTest::BaseのPodにも載っているいわばお約束のようなものですが、もちろんこれ以外の名前にしても問題はありません。

テストプラン

普通のテストの場合、あらかじめ「10テストをする」等決めておいて、

plan tests => 10;

などと記述します。しかし、これはテストを追加削除するたびに変更しないといけないため面倒です。no_planを指定することで具体的なテスト数をチェックしなくなりますが、エラーを防ぐためにもテストプラン数はちゃんと設定しておきたいところです。

use Test::Base;

plan tests => 1 * blocks;

blocksは、データ部分にいくつのブロックが定義されているか返します。nabeatzz.2.tでは3ブロック定義されているので、この部分は

plan tests => 1 * 3

と同義ということになります。

フィルタ

入力値、期待値に対して、一定の処理をあらかじめ行っておきたいケースは多く存在します。 たとえば、今回のテストでは、入力値として渡すのも、期待値として出力結果と比較するのも数値である必要がありますが、inputフィールドもexpectedフィールドも末尾に改行が含まれてしまっています。 これを解消するために、テストの際にchompで改行を取り除くこともできますが、Test::Baseではフィールドデータに一定の処理を加える際に便利なフィルタという仕組みがあります。

filters {
    input    => [qw/chomp/],
    expected => [qw/chomp/],
};

filters { に続けて「フィールド名 => [フィルタ名,フィルタ名...]」という形式で渡して設定するようになっています。最後に";"(セミコロン)が付いていることに注意してください。この部分の記述は if や for のように、perl組み込みの構文のように見えますが、実は

filters({
    input    => [qw/chomp/],
    expected => [qw/chomp/],
});

このように、filtersという関数に引数を渡して実行しているのを省略して書いているだけなので、最後の⁠;⁠がないとperlの文法上エラーになってしまいます。このようにあたかも言語の構文のような書き方で設定を行っていく手法を言語内DSL(Domain Specific Language)と言ったりします。この書き方は後述するrunブロックでも使われているので注意してください。 フィルタは1つのフィールドに複数定義することができ、その場合順番にフィルタによる処理が実行されていくことになります。chomp以外にも以下のようなフィルタが組み込みで用意されています。

yaml
フィールドデータとして複雑な値(ネストしたハッシュなど)を渡す場合、YAML形式でデータを記述し、このフィルタを通すとperlのデータに変換されます
eval
フィールドデータをperlスクリプトとみなして実行し、結果を値とします
trim
データ前後の空白をカットします

他にも、Test::Base::Filterには様々なフィルタが定義済みです。他の組み込みフィルタを知りたい場合は perldoc Test::Base::Filter を実行して確認してみてください。

run

run { ... } の中で実際のテストの処理を記述することになります。この部分もfiltersの時と同じく実質はrun関数を呼び出しているので、末尾には;が必要です。 冒頭、$block = shift; と書いているのは、このrunブロックに渡ってくるテストデータを$block変数で受け取っています。フィールドを参照するのは簡単で、フィールドと同名の$block変数のメソッドを使うだけです。 また、$block->name を参照すると

=== テスト名

で記述したテスト名(ブロック名)を取得できます。しかし、この仕様のためフィールド名にnameという名前を使うと上手く動かなくなってしまうので注意が必要です。

run {
    my $block = shift;

    is(NabeAtzz->shout($block->input), $block->expected);
};

フィルタの独自定義とrunの簡略化

フィルタは、組み込みのものだけでなく、自分で定義することもできます。 やり方は簡単で、テストプログラム中で「引数を1つとって1つの値を返す」関数を定義するだけです。関数名がそのままフィルタ名として使えます。

sub nabeatzz { NabeAtzz->shout(shift) }

このように書くと、フィールドデータを引数にNabeAtzz問題の変換をかけるフィルタになり、nabeatzz.2.tのfiltersとrunは、以下のように書き換えられることになります。

nabeatzz.3.t
sub nabeatzz { NabeAtzz->shout(shift) }

filters {
    input    => [qw/chomp nabeatzz/],
    expected => [qw/chomp/],
};

run {
    my $block = shift;

    is $block->input, $block->expected;
};

変更したテストも、次のように問題なく実行することができます。

perl -Ilib t/nabeatzz.3.t
1..3
ok 1
ok 2
ok 3

実は、このように run ブロックの中でフィールド同士をisで比較しているだけの場合、以下のように簡潔に書くことができます。

run_is input => 'expected';

run_is 関数に2つの引数(フィールド名)を渡すと、各ブロックについて、フィールド同士をisで比較するということになります。引数を省略した場合、ブロック内の1番目のフィールドと2番目のフィールドを比較するので、

run_is

と単純に書けます。 筆者の場合、Test::Baseを使ったテスコードを書く際はまずrunブロック内で大雑把にテストを書いた後、変換処理をフィルタに追い出して、上のようにrun_isと書くだけで済ませられないか検討します。できる限りそうしておいたほうがスクリプトがシンプルになるのと、どういった処理をテストしようとしているかが明確になると思います。

run_is_deeply

基本的にはrun, run_isで良いのですが、テストしたい関数の返す値が配列やハッシュなどの複雑な構造の場合はrun_is_deeplyを使うと便利です。出力値と期待値の構造も比較して、ネストの先同士まで比較して、まったく同じ構成・同じ値かどうかチェックしてくれます。

run_is_deeply;

__END__
===
--- input
1
--- expected
data:
  foo: bar

テストケースの追加

ここまでしっかりとロジック側を設定すれば、テストケースの追加は__END__以下にブロックを足していくだけになります。⁠5の倍数で⁠dog⁠を返す」テストケースを追加してみましょう。

nabeatzz.4.t 追加したテストケース
=== test 10 ( multiple of five
--- input
10
--- expected
dog

=== test 20 ( multiple of five
--- input
20
--- expected
dog

プログラムを変更せずにテストを実施すると、当然のことながら追加したテストケースはエラーになります。

> perl -Ilib t/nabeatzz.4.t
1..5
ok 1
ok 2
ok 3
not ok 4
#   Failed test at t/nabeatzz.4.t line 21.
#          got: '[10]'
#     expected: 'dog'
not ok 5
#   Failed test at t/nabeatzz.4.t line 21.
#          got: '[20]'
#     expected: 'dog'
# Looks like you failed 2 tests of 5.

このように、Test::Baseを使ったテストコードはテストケースの追加が容易に行えるので、開発のスピードを落とさず必要なテストを漏れなく追加していくことができます。

Test::Baseを使う上でのtips

詳しくはperldoc Test::Baseで直接参照してもらうのが一番良いですが(見やすくまとまったドキュメントだと思います⁠⁠、いくつか開発時に便利なTIPSを紹介します。

テストデータの外部読み込み

spec_file($filepath);

上記を実行すると、$filepathで指定したファイルからデータを読み込みます。__END__以下に書きたくない場合に使います。

テストデータ中でのフィルタの指定

--- input chomp nabeatzz
1

上のように記述すると、指定したブロックのフィールドだけフィルタをかけることができます。あまり使いませんが、一連のテストで特別扱いする必要のあるデータがあった場合に使えます。

SKIP, ONLYフィールド

=== test
--- ONLY
--- input
1

ONLYという名称のフィールドがあった場合、Test::Baseはそのフィールドの存在するブロックしかチェックしません。開発中に、1つのテストケース部分を重点的にチェックしたい場合(該当箇所でバグが発生して修正中など)に便利です。 SKIPは逆に、そのフィールドの存在するブロックがテストされなくなります。一時的にテストから外したい際に使用します。

完成したNabeAtzz.pmとそのテスト

最後に、冒頭で示した条件をすべて満たすNabeAtzz.pmの実装と、その確認のためのテストの完成形を紹介します。NabeAtzz.pmの実装はこれが正解というものはないと思うので、ぜひTest::Baseの練習として、テストを動かして色々とモジュールを変更したり、テストケースを追加したりしてみてください。

NabeAtzz.pm
package NabeAtzz;
use strict;
use warnings;

sub shout {
    my ($class, $number) = @_;

    return "fool" if ($number % 3 == 0) or $number =~ /3/;
    return "dog"  if $number % 5 == 0;
    return "[$number]";
}


1;
nabeatzz.t
use strict;
use warnings;
use Test::Base;

use NabeAtzz;

plan tests => 1*blocks;

filters {
    input    => [qw/chomp nabeatzz/],
    expected => [qw/chomp/],
};

sub nabeatzz { 
    NabeAtzz->shout(shift) 
}

run {
    my $block = shift;

    is_deeply $block->input, $block->expected;
};

__END__

=== test 1
--- input
1
--- expected
[1]

=== test 4
--- input
4
--- expected
[4]

=== test 19
--- input
19
--- expected
[19]

=== test 10 ( multiple of five
--- input
10
--- expected
dog

=== test 20 ( multiple of five
--- input
20
--- expected
dog

=== test 3 ( multiple of three
--- input
3
--- expected
fool

=== test 9 ( multiple of three
--- input
9
--- expected
fool

=== test 43 ( three is contained
--- input
43
--- expected
fool

=== test 301 ( three is contained
--- input
301
--- expected
fool

まとめ

今回はTest::Baseについて、よく使う範囲内での説明を行いました。Test::Baseには他にも色々と便利な機能があるので、是非Podを参照してください。 Test::Baseはすべてのテストで使える万能モジュールと言うわけではありません(細かいロジックで、テストケースの少ないテストでは余り有効ではないと思います)が、かなり広い範囲のテスト用途をカバーできていると思います。はじめは慣れないかもしれませんが「作法」のようなものを覚えれば今までよりも楽にテストを書くことができると思います。

それでは、Happy testing!

おすすめ記事

記事・ニュース一覧