Perl テストパターン
Test2::V0、Test::More、prove、およびTDD手法を使用したPerlアプリケーションの包括的なテスト戦略。
アクティベートすべき状況
- 新しいPerlコードを書いている(TDDに従う:レッド、グリーン、リファクタリング)
- Perlモジュールやアプリケーションのテストスイートを設計する
- Perlのテストカバレッジをレビューする
- Perlのテストインフラストラクチャをセットアップする
- Test::MoreからTest2::V0へテストを移行する
- 失敗しているPerlテストのデバッグ
TDDワークフロー
常にRED-GREEN-REFACTORサイクルに従ってください。
# ステップ 1: RED — 失敗するテストを書く
# t/unit/calculator.t
use v5.36;
use Test2::V0;
use lib 'lib';
use Calculator;
subtest 'addition' => sub {
my $calc = Calculator->new;
is($calc->add(2, 3), 5, '2つの数値を加算する');
is($calc->add(-1, 1), 0, '負の数を処理する');
};
done_testing;
# ステップ 2: GREEN — 最小限の実装を書く
# lib/Calculator.pm
package Calculator;
use v5.36;
use Moo;
sub add($self, $a, $b) {
return $a + $b;
}
1;
# ステップ 3: REFACTOR — テストが通るまま改善する
# 実行: prove -lv t/unit/calculator.t
Test::More の基礎
標準的なPerlテストモジュール — 広く使用され、コアに同梱されています。
基本的なアサーション
use v5.36;
use Test::More;
# 事前に計画を立てるか、done_testingを使用
# plan tests => 5; # 固定計画(オプション)
# 等価性
is($result, 42, '正しい値を返す');
isnt($result, 0, 'ゼロではない');
# 真偽値
ok($user->is_active, 'ユーザーはアクティブ');
ok(!$user->is_banned, 'ユーザーはBANされていない');
# 深い比較
is_deeply(
$got,
{ name => 'Alice', roles => ['admin'] },
'期待される構造を返す'
);
# パターンマッチング
like($error, qr/not found/i, 'エラーにnot foundとある');
unlike($output, qr/password/, '出力にパスワードが表示されない');
# 型チェック
isa_ok($obj, 'MyApp::User');
can_ok($obj, 'save', 'delete');
done_testing;
SKIP と TODO
use v5.36;
use Test::More;
# 条件付きでテストをスキップ
SKIP: {
skip 'データベースが設定されていません', 2 unless $ENV{TEST_DB};
my $db = connect_db();
ok($db->ping, 'データベースに到達可能');
is($db->version, '15', '正しいPostgreSQLバージョン');
}
# 予想される失敗をマーク
TODO: {
local $TODO = 'キャッシュがまだ実装されていません';
is($cache->get('key'), 'value', 'キャッシュが値を返す');
}
done_testing;
Test2::V0 モダンフレームワーク
Test2::V0はTest::Moreのモダンな後継です — より豊富なアサーション、優れた診断、拡張性を備えています。
なぜTest2なのか?
- hash/arrayビルダーによる優れた深い比較
- 失敗時により良い診断出力
- よりクリーンなスコープを持つサブテスト
- Test2::Tools::* プラグインで拡張可能
- Test::Moreテストとの後方互換性
ビルダーによる深い比較
use v5.36;
use Test2::V0;
# ハッシュビルダー — 部分構造をチェック
is(
$user->to_hash,
hash {
field name => 'Alice';
field email => match(qr/\@example\.com$/);
field age => validator(sub { $_ >= 18 });
# その他のフィールドは無視
etc();
},
'ユーザーは期待されるフィールドを持つ'
);
# 配列ビルダー
is(
$result,
array {
item 'first';
item match(qr/^second/);
item DNE(); # 存在しない — 余分なアイテムがないことを確認
},
'結果は期待されるリストと一致する'
);
# バッグ — 順序に依存しない比較
is(
$tags,
bag {
item 'perl';
item 'testing';
item 'tdd';
},
'順序に関係なく必要なタグをすべて持つ'
);
サブテスト
use v5.36;
use Test2::V0;
subtest 'ユーザー作成' => sub {
my $user = User->new(name => 'Alice', email => '[email protected]');
ok($user, 'ユーザーオブジェクトが作成された');
is($user->name, 'Alice', '名前が設定された');
is($user->email, '[email protected]', 'メールが設定された');
};
subtest 'ユーザー検証' => sub {
my $warnings = warns {
User->new(name => '', email => 'bad');
};
ok($warnings, '無効なデータで警告が出る');
};
done_testing;
Test2 での例外テスト
use v5.36;
use Test2::V0;
# コードがdieすることをテスト
like(
dies { divide(10, 0) },
qr/Division by zero/,
'ゼロ除算でdieする'
);
# コードが生存することをテスト
ok(lives { divide(10, 2) }, '除算が成功する') or note($@);
# 組み合わせパターン
subtest 'エラーハンドリング' => sub {
ok(lives { parse_config('valid.json') }, '有効なconfigはパースできる');
like(
dies { parse_config('missing.json') },
qr/Cannot open/,
'存在しないファイルはメッセージとともにdieする'
);
};
done_testing;
テスト構成とprove
ディレクトリ構成
t/
├── 00-load.t # モジュールがコンパイルできるか確認
├── 01-basic.t # コア機能
├── unit/
│ ├── config.t # モジュールごとのユニットテスト
│ ├── user.t
│ └── util.t
├── integration/
│ ├── database.t
│ └── api.t
├── lib/
│ └── TestHelper.pm # 共有テストユーティリティ
└── fixtures/
├── config.json # テストデータファイル
└── users.csv
proveコマンド
# すべてのテストを実行
prove -l t/
# 詳細出力
prove -lv t/
# 特定のテストを実行
prove -lv t/unit/user.t
# 再帰的に検索
prove -lr t/
# 並列実行(8ジョブ)
prove -lr -j8 t/
# 前回の実行から失敗したテストのみ実行
prove -l --state=failed t/
# カラー出力とタイマー
prove -l --color --timer t/
# CI用のTAP出力
prove -l --formatter TAP::Formatter::JUnit t/ > results.xml
.proverc 設定
-l
--color
--timer
-r
-j4
--state=save
フィクスチャとセットアップ/ティアダウン
サブテストの分離
use v5.36;
use Test2::V0;
use File::Temp qw(tempdir);
use Path::Tiny;
subtest 'ファイル処理' => sub {
# セットアップ
my $dir = tempdir(CLEANUP => 1);
my $file = path($dir, 'input.txt');
$file->spew_utf8("line1\nline2\nline3\n");
# テスト
my $result = process_file("$file");
is($result->{line_count}, 3, '行数をカウント');
# ティアダウンは自動的に行われる (CLEANUP => 1)
};
共有テストヘルパー
再利用可能なヘルパーは t/lib/TestHelper.pm に配置し、use lib 't/lib' で読み込みます。Exporter を使用して create_test_db()、create_temp_dir()、fixture_path() などのファクトリ関数をエクスポートします。
モッキング
Test::MockModule
use v5.36;
use Test2::V0;
use Test::MockModule;
subtest '外部APIのモック' => sub {
my $mock = Test::MockModule->new('MyApp::API');
# Good: モックは制御されたデータを返す
$mock->mock(fetch_user => sub ($self, $id) {
return { id => $id, name => 'Mock User', email => '[email protected]' };
});
my $api = MyApp::API->new;
my $user = $api->fetch_user(42);
is($user->{name}, 'Mock User', 'モックユーザーを返す');
# 呼び出し回数の確認
my $call_count = 0;
$mock->mock(fetch_user => sub { $call_count++; return {} });
$api->fetch_user(1);
$api->fetch_user(2);
is($call_count, 2, 'fetch_userは2回呼ばれた');
# $mockがスコープ外になると自動的に復元される
};
# Bad: 復元のないモンキーパッチ
# *MyApp::API::fetch_user = sub { ... }; # 絶対にやらない — テスト間でリークする
軽量モックオブジェクトには、Test::MockObjectを使用して、->mock()で注入可能なテストダブルを作成し、->called_ok()で呼び出しを確認します。
Devel::Cover によるカバレッジ
カバレッジの実行
# 基本的なカバレッジレポート
cover -test
# またはステップバイステップ
perl -MDevel::Cover -Ilib t/unit/user.t
cover
# HTMLレポート
cover -report html
open cover_db/coverage.html
# 特定のしきい値
cover -test -report text | grep 'Total'
# CI対応: しきい値を下回ったら失敗
cover -test && cover -report text -select '^lib/' \
| perl -ne 'if (/Total.*?(\d+\.\d+)/) { exit 1 if $1 < 80 }'
インテグレーションテスト
データベーステストにはインメモリSQLiteを、APIテストにはHTTP::Tinyのモックを使用します。
use v5.36;
use Test2::V0;
use DBI;
subtest 'データベースインテグレーション' => sub {
my $dbh = DBI->connect('dbi:SQLite:dbname=:memory:', '', '', {
RaiseError => 1,
});
$dbh->do('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)');
$dbh->prepare('INSERT INTO users (name) VALUES (?)')->execute('Alice');
my $row = $dbh->selectrow_hashref('SELECT * FROM users WHERE name = ?', undef, 'Alice');
is($row->{name}, 'Alice', 'ユーザーが挿入され取得された');
};
done_testing;
ベストプラクティス
すべきこと
- TDDに従う: 実装の前にテストを書く(レッド・グリーン・リファクタリング)
- Test2::V0を使用する: モダンなアサーション、優れた診断
- サブテストを使用する: 関連するアサーションをグループ化し、状態を分離
- 外部依存関係をモックする: ネットワーク、データベース、ファイルシステム
prove -lを使用する: 常にlib/を@INCに含める- テストに明確な名前をつける:
'無効なパスワードでのユーザーログインは失敗する' - エッジケースをテストする: 空文字、undef、ゼロ、境界値
- 80%以上のカバレッジを目指す: ビジネスロジックのパスに集中
- テストを高速に保つ: I/Oをモックし、インメモリデータベースを使用
避けるべきこと
- 実装をテストしない: 動作と出力をテストし、内部構造はテストしない
- サブテスト間で状態を共有しない: 各サブテストは独立しているべき
done_testingを省略しない: 計画されたすべてのテストが実行されたことを保証- 過剰にモックしない: テスト対象のコードではなく、境界のみをモック
- 新しいプロジェクトに
Test::Moreを使用しない: Test2::V0を推奨 - テストの失敗を無視しない: マージ前にすべてのテストが成功しなければならない
- CPANモジュールをテストしない: ライブラリは正しく動作すると信頼する
- 脆いテストを書かない: 過度に具体的な文字列マッチングを避ける
クイックリファレンス
| タスク | コマンド / パターン |
|---|---|
| すべてのテストを実行 | prove -lr t/ |
| 1つのテストを詳細に実行 | prove -lv t/unit/user.t |
| 並列テスト実行 | prove -lr -j8 t/ |
| カバレッジレポート | cover -test && cover -report html |
| 等価性テスト | is($got, $expected, 'ラベル') |
| 深い比較 | is($got, hash { field k => 'v'; etc() }, 'ラベル') |
| 例外テスト | like(dies { ... }, qr/msg/, 'ラベル') |
| 例外なしテスト | ok(lives { ... }, 'ラベル') |
| メソッドのモック | Test::MockModule->new('Pkg')->mock(m => sub { ... }) |
| テストのスキップ | SKIP: { skip '理由', $count unless $cond; ... } |
| TODOテスト | TODO: { local $TODO = '理由'; ... } |
よくある落とし穴
done_testing の忘れ
# Bad: テストファイルは実行されるが、すべてのテストが実行されたか確認できない
use Test2::V0;
is(1, 1, '動作する');
# done_testing がない — テストコードがスキップされた場合に気づかないバグ
# Good: 常にdone_testingで終わる
use Test2::V0;
is(1, 1, '動作する');
done_testing;
-l フラグの欠如
# Bad: lib/内のモジュールが見つからない
prove t/unit/user.t
# Can't locate MyApp/User.pm in @INC
# Good: @INCにlib/を含める
prove -l t/unit/user.t
過剰モッキング
依存関係をモックし、テスト対象のコードはモックしないでください。テストがモックから返された値を検証するだけなら、それは何もテストしていません。
テストの汚染
サブテスト内では my 変数を使用し、決して our を使用しないでください。状態がテスト間でリークするのを防ぎます。
忘れないでください: テストはあなたのセーフティネットです。テストを高速、集中、独立に保ちましょう。新しいプロジェクトにはTest2::V0、実行にはprove、説明責任にはDevel::Coverを使用しましょう。