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, 'ユーザーは禁止されていない');
# 深層比較
is_deeply(
$got,
{ name => 'Alice', roles => ['admin'] },
'期待される構造を返す'
);
# パターンマッチング
like($error, qr/not found/i, 'エラーに「見つかりません」と表示');
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 なのか?
- ハッシュ/配列ビルダーによる優れた深層比較
- 失敗時のより良い診断出力
- よりクリーンなスコープのサブテスト
- 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(); # 存在しない — 余分なアイテムがないことを確認
},
'結果は期待されるリストと一致する'
);
# Bag — 順序に依存しない比較
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 する'
);
# コードが live することをテスト
ok(lives { divide(10, 2) }, '除算が成功する') or note($@);
# 組み合わせパターン
subtest 'エラーハンドリング' => sub {
ok(lives { parse_config('valid.json') }, '有効な設定はパースできる');
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' で読み込みます。create_test_db()、create_temp_dir()、fixture_path() のようなファクトリ関数を Exporter 経由でエクスポートします。
モッキング
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 => 'モックユーザー', email => '[email protected]' };
});
my $api = MyApp::API->new;
my $user = $api->fetch_user(42);
is($user->{name}, 'モックユーザー', 'モックされたユーザーを返す');
# 呼び出し回数を検証
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;
ベストプラクティス
やるべきこと (DO)
- TDD に従う: 実装の前にテストを書く (レッド-グリーン-リファクタリング)
- Test2::V0 を使う: モダンなアサーション、より良い診断
- サブテストを使う: 関連するアサーションをグループ化し、状態を分離
- 外部依存関係をモックする: ネットワーク、データベース、ファイルシステム
prove -lを使う: 常に lib/ を@INCに含める- テストに明確な名前を付ける:
'無効なパスワードでユーザーログインが失敗する' - エッジケースをテストする: 空文字列、undef、ゼロ、境界値
- 80%以上のカバレッジを目指す: ビジネスロジックのパスに焦点を当てる
- テストを高速に保つ: I/O をモックし、インメモリデータベースを使う
やってはいけないこと (DON'T)
- 実装をテストしない: 動作と出力をテストし、内部はテストしない
- サブテスト間で状態を共有しない: 各サブテストは独立しているべき
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, 'label') |
| 深層比較 | is($got, hash { field k => 'v'; etc() }, 'label') |
| 例外テスト | like(dies { ... }, qr/msg/, 'label') |
| 例外がないテスト | ok(lives { ... }, 'label') |
| メソッドのモック | Test::MockModule->new('Pkg')->mock(m => sub { ... }) |
| テストスキップ | SKIP: { skip 'reason', $count unless $cond; ... } |
| TODO テスト | TODO: { local $TODO = 'reason'; ... } |
よくある落とし穴
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: lib/ を @INC に含める
prove -l t/unit/user.t
過剰なモッキング
テスト対象のコードではなく、依存関係 をモックします。テストがモックが返すように指示した値を検証するだけの場合、何もテストしていないことになります。
テストの汚染
サブテスト内では my 変数を使います — our は決して使わない — これにより状態がテスト間で漏れるのを防ぎます。
覚えておいてください: テストはあなたのセーフティネットです。テストを高速、集中、独立に保ちましょう。新しいプロジェクトには Test2::V0、テスト実行には prove、説明責任には Devel::Cover を使用してください。