mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-07 17:53:32 +08:00
476 lines
11 KiB
Markdown
476 lines
11 KiB
Markdown
---
|
||
name: perl-testing
|
||
description: 使用Test2::V0、Test::More、prove runner、模拟、Devel::Cover覆盖率和TDD方法的Perl测试模式。
|
||
origin: ECC
|
||
---
|
||
|
||
# Perl 测试模式
|
||
|
||
使用 Test2::V0、Test::More、prove 和 TDD 方法论为 Perl 应用程序提供全面的测试策略。
|
||
|
||
## 何时激活
|
||
|
||
* 编写新的 Perl 代码(遵循 TDD:红、绿、重构)
|
||
* 为 Perl 模块或应用程序设计测试套件
|
||
* 审查 Perl 测试覆盖率
|
||
* 设置 Perl 测试基础设施
|
||
* 将测试从 Test::More 迁移到 Test2::V0
|
||
* 调试失败的 Perl 测试
|
||
|
||
## TDD 工作流程
|
||
|
||
始终遵循 RED-GREEN-REFACTOR 循环。
|
||
|
||
```perl
|
||
# Step 1: RED — Write a failing test
|
||
# 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;
|
||
|
||
# Step 2: GREEN — Write minimal implementation
|
||
# lib/Calculator.pm
|
||
package Calculator;
|
||
use v5.36;
|
||
use Moo;
|
||
|
||
sub add($self, $a, $b) {
|
||
return $a + $b;
|
||
}
|
||
|
||
1;
|
||
|
||
# Step 3: REFACTOR — Improve while tests stay green
|
||
# Run: prove -lv t/unit/calculator.t
|
||
```
|
||
|
||
## Test::More 基础
|
||
|
||
标准的 Perl 测试模块 —— 广泛使用,随核心发行。
|
||
|
||
### 基本断言
|
||
|
||
```perl
|
||
use v5.36;
|
||
use Test::More;
|
||
|
||
# Plan upfront or use done_testing
|
||
# plan tests => 5; # Fixed plan (optional)
|
||
|
||
# Equality
|
||
is($result, 42, 'returns correct value');
|
||
isnt($result, 0, 'not zero');
|
||
|
||
# Boolean
|
||
ok($user->is_active, 'user is active');
|
||
ok(!$user->is_banned, 'user is not banned');
|
||
|
||
# Deep comparison
|
||
is_deeply(
|
||
$got,
|
||
{ name => 'Alice', roles => ['admin'] },
|
||
'returns expected structure'
|
||
);
|
||
|
||
# Pattern matching
|
||
like($error, qr/not found/i, 'error mentions not found');
|
||
unlike($output, qr/password/, 'output hides password');
|
||
|
||
# Type check
|
||
isa_ok($obj, 'MyApp::User');
|
||
can_ok($obj, 'save', 'delete');
|
||
|
||
done_testing;
|
||
```
|
||
|
||
### SKIP 和 TODO
|
||
|
||
```perl
|
||
use v5.36;
|
||
use Test::More;
|
||
|
||
# Skip tests conditionally
|
||
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');
|
||
}
|
||
|
||
# Mark expected failures
|
||
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 测试向后兼容
|
||
|
||
### 使用构建器进行深层比较
|
||
|
||
```perl
|
||
use v5.36;
|
||
use Test2::V0;
|
||
|
||
# Hash builder — check partial structure
|
||
is(
|
||
$user->to_hash,
|
||
hash {
|
||
field name => 'Alice';
|
||
field email => match(qr/\@example\.com$/);
|
||
field age => validator(sub { $_ >= 18 });
|
||
# Ignore other fields
|
||
etc();
|
||
},
|
||
'user has expected fields'
|
||
);
|
||
|
||
# Array builder
|
||
is(
|
||
$result,
|
||
array {
|
||
item 'first';
|
||
item match(qr/^second/);
|
||
item DNE(); # Does Not Exist — verify no extra items
|
||
},
|
||
'result matches expected list'
|
||
);
|
||
|
||
# Bag — order-independent comparison
|
||
is(
|
||
$tags,
|
||
bag {
|
||
item 'perl';
|
||
item 'testing';
|
||
item 'tdd';
|
||
},
|
||
'has all required tags regardless of order'
|
||
);
|
||
```
|
||
|
||
### 子测试
|
||
|
||
```perl
|
||
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 进行异常测试
|
||
|
||
```perl
|
||
use v5.36;
|
||
use Test2::V0;
|
||
|
||
# Test that code dies
|
||
like(
|
||
dies { divide(10, 0) },
|
||
qr/Division by zero/,
|
||
'dies on division by zero'
|
||
);
|
||
|
||
# Test that code lives
|
||
ok(lives { divide(10, 2) }, 'division succeeds') or note($@);
|
||
|
||
# Combined pattern
|
||
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
|
||
|
||
### 目录结构
|
||
|
||
```text
|
||
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 命令
|
||
|
||
```bash
|
||
# Run all tests
|
||
prove -l t/
|
||
|
||
# Verbose output
|
||
prove -lv t/
|
||
|
||
# Run specific test
|
||
prove -lv t/unit/user.t
|
||
|
||
# Recursive search
|
||
prove -lr t/
|
||
|
||
# Parallel execution (8 jobs)
|
||
prove -lr -j8 t/
|
||
|
||
# Run only failing tests from last run
|
||
prove -l --state=failed t/
|
||
|
||
# Colored output with timer
|
||
prove -l --color --timer t/
|
||
|
||
# TAP output for CI
|
||
prove -l --formatter TAP::Formatter::JUnit t/ > results.xml
|
||
```
|
||
|
||
### .proverc 配置
|
||
|
||
```text
|
||
-l
|
||
--color
|
||
--timer
|
||
-r
|
||
-j4
|
||
--state=save
|
||
```
|
||
|
||
## 夹具与设置/拆卸
|
||
|
||
### 子测试隔离
|
||
|
||
```perl
|
||
use v5.36;
|
||
use Test2::V0;
|
||
use File::Temp qw(tempdir);
|
||
use Path::Tiny;
|
||
|
||
subtest 'file processing' => sub {
|
||
# Setup
|
||
my $dir = tempdir(CLEANUP => 1);
|
||
my $file = path($dir, 'input.txt');
|
||
$file->spew_utf8("line1\nline2\nline3\n");
|
||
|
||
# Test
|
||
my $result = process_file("$file");
|
||
is($result->{line_count}, 3, 'counts lines');
|
||
|
||
# Teardown happens automatically (CLEANUP => 1)
|
||
};
|
||
```
|
||
|
||
### 共享测试助手
|
||
|
||
将可重用的助手放在 `t/lib/TestHelper.pm` 中,并通过 `use lib 't/lib'` 加载。通过 `Exporter` 导出工厂函数,例如 `create_test_db()`、`create_temp_dir()` 和 `fixture_path()`。
|
||
|
||
## 模拟
|
||
|
||
### Test::MockModule
|
||
|
||
```perl
|
||
use v5.36;
|
||
use Test2::V0;
|
||
use Test::MockModule;
|
||
|
||
subtest 'mock external API' => sub {
|
||
my $mock = Test::MockModule->new('MyApp::API');
|
||
|
||
# Good: Mock returns controlled data
|
||
$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');
|
||
|
||
# Verify call count
|
||
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 is automatically restored when $mock goes out of scope
|
||
};
|
||
|
||
# Bad: Monkey-patching without restoration
|
||
# *MyApp::API::fetch_user = sub { ... }; # NEVER — leaks across tests
|
||
```
|
||
|
||
对于轻量级的模拟对象,使用 `Test::MockObject` 创建可注入的测试替身,使用 `->mock()` 并验证调用 `->called_ok()`。
|
||
|
||
## 使用 Devel::Cover 进行覆盖率分析
|
||
|
||
### 运行覆盖率分析
|
||
|
||
```bash
|
||
# Basic coverage report
|
||
cover -test
|
||
|
||
# Or step by step
|
||
perl -MDevel::Cover -Ilib t/unit/user.t
|
||
cover
|
||
|
||
# HTML report
|
||
cover -report html
|
||
open cover_db/coverage.html
|
||
|
||
# Specific thresholds
|
||
cover -test -report text | grep 'Total'
|
||
|
||
# CI-friendly: fail under threshold
|
||
cover -test && cover -report text -select '^lib/' \
|
||
| perl -ne 'if (/Total.*?(\d+\.\d+)/) { exit 1 if $1 < 80 }'
|
||
```
|
||
|
||
### 集成测试
|
||
|
||
对数据库测试使用内存中的 SQLite,对 API 测试模拟 HTTP::Tiny。
|
||
|
||
```perl
|
||
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`
|
||
|
||
```perl
|
||
# Bad: Test file runs but doesn't verify all tests executed
|
||
use Test2::V0;
|
||
is(1, 1, 'works');
|
||
# Missing done_testing — silent bugs if test code is skipped
|
||
|
||
# Good: Always end with done_testing
|
||
use Test2::V0;
|
||
is(1, 1, 'works');
|
||
done_testing;
|
||
```
|
||
|
||
### 缺少 `-l` 标志
|
||
|
||
```bash
|
||
# Bad: Modules in lib/ not found
|
||
prove t/unit/user.t
|
||
# Can't locate MyApp/User.pm in @INC
|
||
|
||
# Good: Include lib/ in @INC
|
||
prove -l t/unit/user.t
|
||
```
|
||
|
||
### 过度模拟
|
||
|
||
模拟*依赖项*,而非被测试的代码。如果你的测试只验证模拟返回了你告诉它的内容,那么它什么也没测试。
|
||
|
||
### 测试污染
|
||
|
||
在子测试内部使用 `my` 变量 —— 永远不要用 `our` —— 以防止状态在测试之间泄漏。
|
||
|
||
**记住**:测试是你的安全网。保持它们快速、专注和独立。新项目使用 Test2::V0,运行使用 prove,问责使用 Devel::Cover。
|