Perl Hackers Hub

第51回Test2で変わるモダンなテスト―拡張性を持ったテスティングフレームワークとTest2::V0の使い方(1)

本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回のハッカーはakiymこと秋山卓巳さんで、テーマは「Test2で変わるモダンなテスト」です。新たなテスティングフレームワークであるTest2を使ったテストの書き方や活用方法について紹介します。

本稿のサンプルコードは、本誌「WEB+DB PRESS Vol.106」サポートサイトから入手できます。

Perlでのテスト

Perlではテストを書く際に、Test::Moreというモジュールが広く使われています。これは、プログラムの実行結果が期待しているものと一致しているかをチェックするためのシンプルなモジュールで、テスト結果をTAPTest Anything Protocol形式で出力します[1]⁠。

本稿のテーマであるTest2は、Test::Moreなどのバックエンドとして使われているTest::Builderを置き換えるべく開発されているテスティングフレームワークです。2015年ごろよりChad Granum氏によって作られています。

Test::Builderの問題点

Test::Builderは、Perlにおけるテストモジュールが行う基本的な動作を扱い、それをTAP形式で出力するモジュールです。

Test::Builderの問題として、2001年ごろに作られたコードがメンテナンスされ使われ続けているため、拡張して使うのが難しいことがあります。テスト結果はTAP形式で出力されるため、1つのテストの結果が行単位で表され構造化されていないことから、テストモジュールのテストが困難になります。また、テスト出力のカスタマイズは、Test::Builderの関数を直接上書きするモンキーパッチによる対応でしか行えない状況でした。

この問題を解決するため、拡張性に重点を置いた新たなテスティングフレームワークとしてTest2が開発されました。

ちなみに、Test::Builderは今後どうなるのでしょうか。メンテナンスされないまま、古いコードが残っていくのでしょうか。実は、Test::Builderの内部ではTest2のAPIを呼び出すように書きなおされており、すでに全面的な新規コードでの置き換えがされています。そのため、古いインタフェースを使っている既存のテストモジュールはそのままで、新たに書き始めるテストモジュールは柔軟なインタフェースを持つTest2を使えます。

Test2にすると変わること

Test2は、Test::BuilderTest::Moreが含まれるTest-Simpleディストリビューションのバージョン1.3以降から使われるようになっています。また、Perl 5.26.0から標準モジュールとして同梱されるようになりました。

気になるのは、今動いている既存のテストをTest2にしたときにどうなるかでしょう。基本的には、Test::MoreをはじめとするTest::Builderを使った既存のテストモジュールが壊れることがないように、後方互換性を維持しつつ開発されています。ただし例外として、Test::Builderをモンキーパッチによって拡張していた場合には正しく動作しないことがあります。また、いくつかのテストモジュールは、バージョンアップが必要になったり使えなくなったりします。詳しくは、Test2::Transionを参照してください。

Test2::V0を使ったテスト

ここからは、Test2の拡張性をもとにして作られたテストモジュールTest2::V0を使ったテストを紹介します。Test2::V0はTest2の作者によって作られた、Test2-Suiteディストリビューションに含まれるモジュールです。Test2はフレームワークであるため基本的な機能しか提供されていませんが、Test2::V0では一歩踏み込んだ機能が実装されています。Test::Moreで提供されるテスト関数が使えるのはもちろんのこと、複雑なデータ構造の比較やオブジェクトのモックといったテストにおけるさまざまな操作を行えます。

まずはcpanmコマンドにより必要なモジュールをインストールします。Test2::V0をインストールすると、Test-SimpleTest2-Suiteディストリビューションに含まれるモジュールも一緒にインストールされます。

$ cpanm Test2::V0

なお、本稿ではPerl 5.26.2、Test-Simple 1.302136、Test2-Suite 0.000114を前提として説明します。

use Test2::V0と書くことで、テストを行うための関数のエクスポートや、テストでの共通した処理の実行などをしてくれます。これはいわゆるボイラープレート(お決まりのテンプレート)の役割を果たします。

実際にビルトイン関数の動作を確かめる単純なテストを書いてみます。ここでは、Test2::V0ok関数を使って、あるコードによる動作が正しい条件かどうかをテストします。

ucfirst.t
use Test2::V0;
ok ucfirst('foo') eq 'Foo',
  '先頭の文字がuppercaseになっている';
ok uc('foo') ne 'foo', 'fooとは一致しない';
done_testing;

これ以降に登場するコードでは、先頭のuse Test2::V0;は省略します

テストを実行すると次の結果が出力され、2つのテストが成功し、コードが意図したとおりに動作していることがわかります。

ucfirst.tの実行結果
$ perl ucfirst.t
# Seeded srand with seed '20180625' from local date.
ok 1 - 先頭の文字がuppercaseになっている
ok 2 - fooとは一致しない
1..2

比較の基本

あるコードが正しく動作しているかをテストするために、得られた値が期待された値と一致しているかどうか比較したいことがあります。ここまでの例では、文字列比較の結果を真偽値としてok関数に渡していました。is関数を使うと、文字列や数値、配列、ハッシュといったデータ構造まで、比較演算子を指定することなく直感的に2つの値を比較できます。

is ucfirst('foo'), 'Foo';
is [1..3], [1, 2, 3], '配列の比較';
is {map { ($_ => 1) } qw(a b)},
  {a => 1, b => 1}, 'ハッシュの比較';

もしコードが意図したとおりに動作していない場合、テストは失敗します。その場合に必要となるのは、どのテストがどのような状態で失敗したかという情報です。上記のテストをわざと失敗するように書き換えて実行します。

compare-fail.t
is ucfirst('foo'), 'bar';
is [1..3], [1, 2, 0], '配列の比較';
is {map { ($_ => 1) } qw(a b)},
  {a => 1, c => 1}, 'ハッシュの比較';

図1の実行結果を見ると、失敗したテストについて実際の値(GOT)と期待された値(CHECK)が出力されています。配列の何番目の要素が間違っていたのか、期待されたハッシュには存在しないキーがあるのかが、Test::Moreに比べると読みやすい形式になっています。

図1 compare-fail.tの実行結果
$ perl compare-fail.t
# Seeded srand with seed '20180625' from local date.
not ok 1
# Failed test at compare-fail.t line 3.
# +-----+----+-------+
# | GOT | OP | CHECK |
# +-----+----+-------+
# | Foo | eq | bar   |
# +-----+----+-------+
not ok 2 - 配列の比較
# Failed test '配列の比較'
# at compare-fail.t line 4.
# +------+-----+----+-------+
# | PATH | GOT | OP | CHECK |
# +------+-----+----+-------+
# | [2]  | 3   | eq | 0     |
# +------+-----+----+-------+
not ok 3 - ハッシュの比較
# Failed test 'ハッシュの比較'
# at compare-fail.t line 5.
# +------+------------------+---------+------------------+
# | PATH | GOT              | OP      | CHECK            |
# +------+------------------+---------+------------------+
# | {c}  | <DOES NOT EXIST> |         | 1                |
# | {b}  | 1                | !exists | <DOES NOT EXIST> |
# +------+------------------+---------+------------------+
1..3

ここまで、2つの値を比較する例を示しました。テストが複雑になってくると、値がundefではないことやハッシュのキーが存在するかどうかといったチェックをしたくなるかもしれません。Test2::V0では、期待される値の条件を指定するしくみが用意されています。以降でそれらについて解説していきます。

値の比較

is関数に渡した値はデフォルトで文字列による比較がされます(リファレンスを除く⁠⁠。明示的に文字列、数値、真偽値による比較を行いたい場合には、stringnumberbool関数を使います表1⁠。

表1 値の比較関数
関数名説明比較時の演算子
string文字列による比較$input eq $got
number数値による比較$input == $got
bool真偽値による比較($input xor $got) ? 0 : 1

また、次に示すコードにあるように、数値を文字列として比較した場合や、演算子を使って否定した場合の真偽値の扱われ方については注意が必要です。必要に応じて、numberbool関数を使い分けるとよいでしょう。

is lc('FOO'), string 'foo';
is '1.0', number 1;
isnt '1.0', 1; # 文字列による比較になるため両者は異なる
is !!0, bool 0;
is !!0, ''; # ここで期待している真偽値は空文字列になる

正規表現と条件式による比較

正規表現にマッチする文字列であることをテストする場合は、match関数を使います。また、条件式による比較を行いたい場合は、validator関数を使います。コードリファレンスを渡し、比較時に与えられた値は$_で参照できます。

is 'abc', match qr/^[a-z]+$/, 'すべてが小文字';
is 1, validator(sub { $_ > 0 }), '正の整数';

比較のショートカット

比較するデータ構造の要素が増えてくると、すべて列挙して比較するテストを書くのに時間がかかるため、もう少し省力的にテストをしたいことがあります。Test2::V0には、要素に対して真偽値による比較や、ハッシュのキーが存在するかどうかだけを確認するといった、頻出する値の比較を行うためのショートカットが用意されています表2⁠。

表2 値の比較ショートカット
関数名説明データ例
T真偽値によるチェックの結果、真である1、'a'
F真偽値によるチェックの結果、偽である0、'0'、''、undef
D値がundefではない1、0、'0'、''
U値がundefであるundef
DF値がdefinedであるが真偽値による評価では偽である0、'0'、''
E配列、ハッシュの要素が存在している{a => 1}は{a => E}にマッチ
DNE配列、ハッシュの要素が存在していない{a => 1}は{b => DNE}にマッチ
FDNE配列、ハッシュの要素が存在していない。またはその値が真偽値による評価では偽である{a => 0}は{a => FDNE, b => FDNE}にマッチ

データ構造の簡潔な比較

これまでは、配列やハッシュのリファレンスを渡すことでデータ構造の比較を行いました。データ構造が大きくなってくると見通しが悪くなり、さらには配列やハッシュ以外にもオブジェクトの比較がしたいということもあります。そこでTest2::V0には、宣言的な比較をできるようにした、ビルダと呼ばれるデータ構造を比較する際の記述方法が用意されています。

ビルダによるデータ構造の比較

ビルダでは、コードブロック内に対象となる要素の列挙や条件を書けます。次のコードにあるように、配列の比較はarrayビルダ、ハッシュの比較はhashビルダを使って行います。

is [1, 2], array {
  item 1; ┓(1)
  item 2; 
  end;  ――(2)
};
is [3, 4, 5], array {
  item 1 => 4;  ――(3)
  etc;  ――(4)
};
is {a => 1, b => 2}, hash {
  field a => 1;  ――(5)
  etc;
};

(1)itemでは、配列に含まれる要素を宣言した順に列挙しています。(3)のようにインデックスを指定することもできます。

(2)enditemで宣言された要素以外にないこと、(4)etcは宣言された要素以外にも存在し得ることを表します。これらを省略した場合、is関数のときにはendを呼び出したときと同じ挙動となりますが、わかりやすさのためどちらかを明示的に呼び出しておくことをお勧めします。

(5)では、ハッシュの比較をするためにfieldにより対応するキーとバリューを列挙しています。

また、オブジェクトもobjectビルダによって比較できます。

package Foo {
  sub a { $_[0]->{a} }
}
my $foo = bless { a => 1 }, 'Foo';
is $foo, object {
  prop blessed => 'Foo';  ――(1)
  call a => 1;  ――(2)
  call b => DNE;  ――(3)
};

(1)では、blessされているパッケージ名の比較を行います。

(2)では、aメソッドの戻り値の比較を行います。(3)では、DNEを渡し、bメソッドが存在しないことをチェックしています。

ちなみに、直接リファレンスを渡すのとビルダを渡すのとではどちらが良いのでしょうか。前者は記述量が少ないというメリットがありますが、後者にはarrayビルダであればすべての要素についてチェックを行うall_items関数を使うことができ、順不同による比較をbagビルダで行うといった柔軟な対応ができます。また、リファレンスを扱う場合、前者では中身の要素それぞれが宣言された行番号を取得することはできません。後者ではitemが関数の呼び出しとして実装されているため、テストの失敗時には対象となる行番号が詳細に出力されるといった良い点もあります。

isとlikeの違い

Test::Moreではlike関数は正規表現による比較を行うものでしたが、Test2::V0ではそれに加えデータ構造の比較も行えるようになっています。is関数では厳格な(strict)比較、like関数では緩やかな(relaxed)比較がされます。

この緩やかな比較とは、具体的にどのような動作をするのでしょうか。

まずはis関数、like関数での比較時のオプションについて知る必要があります。オプションには次の3種類あります。

implicit_end

比較対象の配列、ハッシュについて、余分なインデックス、キーが存在しないことをチェックする

use_regex

正規表現リテラルを直接渡すことを許可する。use_regexが無効の場合は、match関数を経由しなければならない

use_code

コードリファレンスを直接渡すことを許可する。use_codeが無効の場合は、validator関数を経由しなければならない

これらのオプションは、islike関数では表3のように対応されます。

表3 比較ルールと実装との対応
ルール(対応する関数)implicit_enduse_regexuse_code
strict(is)××
relaxed(like)×

like関数を使い、正規表現やコードリファレンスを渡すテストの例を書いてみます。

like 'foo', qr/f/;
like 2, sub { $_ % 2 == 0 };
like {a => 1, b => 2}, {b => 2};  ――(1)
is {a => 1, b => 2}, hash {  
  field b => 2;              
  etc;                       |(2)
};                           

(1)にあるハッシュの比較は、(2)hashetcを使った組み合わせと同様の動作をするため、このようなケースではlike関数を使うことで簡潔に記述できます。

否定するテスト

テストを書いていると、ある条件を満たさないことといった否定した条件をチェックしたいときがあります。

このような場合のテストを表現するには、次のようなコードが考えられます。

ok $x != 100;
isnt $x, 100;

1行目では、真偽値による比較で条件の判断を行っています。しかし、これではテストが失敗したときには$xの値がテスト関数側に渡っていないため、出力結果に含めることができず、わかりにくいテストになります。

2行目では、is関数の代わりにisnt関数を使っています。テストが失敗したときには$xの値が出力されますが、ネストしたデータ構造の一部分について条件を満たさない場合を表現できません。

そのため、is関数とlike関数では比較条件の否定が表現できるようになっています。比較を行う関数では否定演算子!がオーバーロードされており、これを使うことで条件を否定するテストが簡単に書けるようになっています。

is {a => 'foo'},
   {a => !string 'bar'}, '文字列barではない';

ここまで、値の比較について代表的な機能に絞って解説しました。ほかの比較機能について知りたい場合は、Test2::Tools::Compareを参照してください。

<続きの(2)こちら。>

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧