perl-testing

perl-testing

热门

使用 Test2::V0、Test::More、prove 运行器、模拟、Devel::Cover 覆盖率以及 TDD 方法论的 Perl 测试模式。

23万Star
3.5万Fork
更新于 2026/7/17
SKILL.md
readonly只读
name
perl-testing
description

使用 Test2::V0、Test::More、prove 运行器、模拟、Devel::Cover 覆盖率以及 TDD 方法论的 Perl 测试模式。

Perl 测试模式

使用 Test2::V0、Test::More、prove 和 TDD 方法论的 Perl 应用综合测试策略。

何时激活

  • 编写新的 Perl 代码(遵循 TDD:红、绿、重构)
  • 为 Perl 模块或应用设计测试套件
  • 审查 Perl 测试覆盖率
  • 设置 Perl 测试基础设施
  • 从 Test::More 迁移测试到 Test2::V0
  • 调试失败的 Perl 测试

TDD 工作流

始终遵循红-绿-重构循环。

# 步骤 1:红 — 编写一个失败的测试
# 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, 'adds two numbers');
    is($calc->add(-1, 1), 0, 'handles negatives');
};

done_testing;

# 步骤 2:绿 — 编写最小实现
# lib/Calculator.pm
package Calculator;
use v5.36;
use Moo;

sub add($self, $a, $b) {
    return $a + $b;
}

1;

# 步骤 3:重构 — 在测试保持绿色的情况下改进
# 运行:prove -lv t/unit/calculator.t

Test::More 基础

标准的 Perl 测试模块 — 广泛使用,随核心发布。

基本断言

use v5.36;
use Test::More;

# 预先计划或使用 done_testing
# plan tests => 5;  # 固定计划(可选)

# 相等性
is($result, 42, 'returns correct value');
isnt($result, 0, 'not zero');

# 布尔值
ok($user->is_active, 'user is active');
ok(!$user->is_banned, 'user is not banned');

# 深度比较
is_deeply(
    $got,
    { name => 'Alice', roles => ['admin'] },
    'returns expected structure'
);

# 模式匹配
like($error, qr/not found/i, 'error mentions not found');
unlike($output, qr/password/, 'output hides password');

# 类型检查
isa_ok($obj, 'MyApp::User');
can_ok($obj, 'save', 'delete');

done_testing;

SKIP 和 TODO

use v5.36;
use Test::More;

# 条件性跳过测试
SKIP: {
    skip 'No database configured', 2 unless $ENV{TEST_DB};

    my $db = connect_db();
    ok($db->ping, 'database is reachable');
    is($db->version, '15', 'correct PostgreSQL version');
}

# 标记预期失败
TODO: {
    local $TODO = 'Caching not yet implemented';
    is($cache->get('key'), 'value', 'cache returns 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();
    },
    'user has expected fields'
);

# 数组构建器
is(
    $result,
    array {
        item 'first';
        item match(qr/^second/);
        item DNE();  # 不存在 — 验证没有多余项
    },
    'result matches expected list'
);

# Bag — 顺序无关的比较
is(
    $tags,
    bag {
        item 'perl';
        item 'testing';
        item 'tdd';
    },
    'has all required tags regardless of order'
);

子测试

use v5.36;
use Test2::V0;

subtest 'User creation' => sub {
    my $user = User->new(name => 'Alice', email => 'alice@example.com');
    ok($user, 'user object created');
    is($user->name, 'Alice', 'name is set');
    is($user->email, 'alice@example.com', 'email is set');
};

subtest 'User validation' => sub {
    my $warnings = warns {
        User->new(name => '', email => 'bad');
    };
    ok($warnings, 'warns on invalid data');
};

done_testing;

使用 Test2 进行异常测试

use v5.36;
use Test2::V0;

# 测试代码是否抛出异常
like(
    dies { divide(10, 0) },
    qr/Division by zero/,
    'dies on division by zero'
);

# 测试代码是否正常执行
ok(lives { divide(10, 2) }, 'division succeeds') or note($@);

# 组合模式
subtest 'error handling' => sub {
    ok(lives { parse_config('valid.json') }, 'valid config parses');
    like(
        dies { parse_config('missing.json') },
        qr/Cannot open/,
        'missing file dies with message'
    );
};

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 'file processing' => 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, 'counts lines');

    # 清理自动进行(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 'mock external API' => sub {
    my $mock = Test::MockModule->new('MyApp::API');

    # 好:模拟返回受控数据
    $mock->mock(fetch_user => sub ($self, $id) {
        return { id => $id, name => 'Mock User', email => 'mock@test.com' };
    });

    my $api = MyApp::API->new;
    my $user = $api->fetch_user(42);
    is($user->{name}, 'Mock User', 'returns mocked 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 called twice');

    # 当 $mock 超出作用域时,模拟自动恢复
};

# 坏:猴子补丁而不恢复
# *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 'database integration' => 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', 'inserted and retrieved user');
};

done_testing;

最佳实践

应该做

  • 遵循 TDD:在实现之前编写测试(红-绿-重构)
  • 使用 Test2::V0:现代断言,更好的诊断
  • 使用子测试:分组相关断言,隔离状态
  • 模拟外部依赖:网络、数据库、文件系统
  • 使用 prove -l:始终将 lib/ 包含在 @INC
  • 清晰命名测试'user login with invalid password fails'
  • 测试边界情况:空字符串、undef、零、边界值
  • 目标 80%+ 覆盖率:专注于业务逻辑路径
  • 保持测试快速:模拟 I/O,使用内存数据库

不应该做

  • 不要测试实现:测试行为和输出,而非内部细节
  • 不要在子测试间共享状态:每个子测试应独立
  • 不要跳过 done_testing:确保所有计划的测试都已运行
  • 不要过度模拟:仅模拟边界,而非被测代码
  • 不要在新项目中使用 Test::More:优先使用 Test2::V0
  • 不要忽略测试失败:所有测试必须在合并前通过
  • 不要测试 CPAN 模块:信任库能正确工作
  • 不要编写脆弱的测试:避免过于具体的字符串匹配

快速参考

任务 命令 / 模式
运行所有测试 prove -lr t/
详细运行一个测试 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

# 坏:测试文件运行但未验证所有测试已执行
use Test2::V0;
is(1, 1, 'works');
# 缺少 done_testing — 如果测试代码被跳过,会有静默错误

# 好:始终以 done_testing 结尾
use Test2::V0;
is(1, 1, 'works');
done_testing;

缺少 -l 标志

# 坏:找不到 lib/ 中的模块
prove t/unit/user.t
# Can't locate MyApp/User.pm in @INC

# 好:将 lib/ 包含在 @INC 中
prove -l t/unit/user.t

过度模拟

模拟依赖,而非被测代码。如果你的测试只验证模拟返回了你告诉它的内容,那它什么也没测试。

测试污染

在子测试中使用 my 变量 — 绝不用 our — 以防止状态在测试间泄漏。

记住:测试是你的安全网。保持它们快速、专注和独立。新项目使用 Test2::V0,使用 prove 运行,使用 Devel::Cover 进行问责。