Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Perl testing patterns using Test2::V0, Test::More, prove runner, mocking, coverage with Devel::Cover, and TDD methodology.
.claude/skills/loulanyue-perl-testing/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 264% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 207% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 111% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 136% | 0% |
| case-11 | ✓→✓ | = Same ✓ | 152% | 0% |
Comprehensive testing strategies for Perl applications using Test2::V0, Test::More, prove, and TDD methodology.
Always follow the RED-GREEN-REFACTOR cycle.
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
The standard Perl testing module — widely used, ships with core.
perluse 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;
perluse 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 is the modern replacement for Test::More — richer assertions, better diagnostics, and extensible.
perluse 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' );
perluse 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;
perluse 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;
textt/ ├── 00-load.t # Verify modules compile ├── 01-basic.t # Core functionality ├── unit/ │ ├── config.t # Unit tests by module │ ├── user.t │ └── util.t ├── integration/ │ ├── database.t │ └── api.t ├── lib/ │ └── TestHelper.pm # Shared test utilities └── fixtures/ ├── config.json # Test data files └── users.csv
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
text-l --color --timer -r -j4 --state=save
perluse 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) };
Place reusable helpers in t/lib/TestHelper.pm and load with use lib 't/lib'. Export factory functions like create_test_db(), create_temp_dir(), and fixture_path() via Exporter.
perluse 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
For lightweight mock objects, use Test::MockObject to create injectable test doubles with ->mock() and verify calls with ->called_ok().
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 }'
Use in-memory SQLite for database tests, mock HTTP::Tiny for API tests.
perluse 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;
prove -l: Always include lib/ in @INC'user login with invalid password fails'done_testing: Ensures all planned tests ranTest::More for new projects: Prefer Test2::V0| Task | Command / Pattern | |---|---| | Run all tests | prove -lr t/ | | Run one test verbose | prove -lv t/unit/user.t | | Parallel test run | prove -lr -j8 t/ | | Coverage report | cover -test && cover -report html | | Test equality | is($got, $expected, 'label') | | Deep comparison | is($got, hash { field k => 'v'; etc() }, 'label') | | Test exception | like(dies { ... }, qr/msg/, 'label') | | Test no exception | ok(lives { ... }, 'label') | | Mock a method | Test::MockModule->new('Pkg')->mock(m => sub { ... }) | | Skip tests | SKIP: { skip 'reason', $count unless $cond; ... } | | TODO tests | TODO: { local $TODO = 'reason'; ... } |
done_testingperl# 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 Flagbash# 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
Mock the dependency, not the code under test. If your test only verifies that a mock returns what you told it to, it tests nothing.
Use my variables inside subtests — never our — to prevent state leaking between tests.
Remember: Tests are your safety net. Keep them fast, focused, and independent. Use Test2::V0 for new projects, prove for running, and Devel::Cover for accountability.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-11 | pass→pass | 10,063 | 6,613 | -34% | 1 | 1 | 0% | 1,826 | 4,606 | +152% | 0 | 0 | — |
case-09 | pass→pass | 6,118 | 3,353 | -45% | 1 | 1 | 0% | 927 | 3,956 | +327% | 0 | 0 | — |
case-01 | pass→pass | 14,733 | 11,989 | -19% | 1 | 1 | 0% | 2,474 | 5,609 | +127% | 0 | 0 | — |
case-02 | fail→pass | 6,314 | 3,311 | -48% | 1 | 1 | 0% | 1,102 | 4,008 | +264% | 0 | 0 | — |
case-03 | fail→pass | 7,496 | 4,581 | -39% | 1 | 1 | 0% | 1,359 | 4,176 | +207% | 0 | 0 | — |
case-04 | pass→pass | 7,687 | 3,268 | -57% | 1 | 1 | 0% | 1,379 | 4,001 | +190% | 0 | 0 | — |
case-05 | pass→pass | 9,634 | 5,329 | -45% | 1 | 1 | 0% | 1,635 | 4,337 | +165% | 0 | 0 | — |
case-06 | pass→pass | 13,296 | 10,188 | -23% | 1 | 1 | 0% | 2,347 | 5,151 | +119% | 0 | 0 | — |
case-07 | pass→pass | 10,651 | 4,517 | -58% | 1 | 1 | 0% | 1,828 | 4,167 | +128% | 0 | 0 | — |
case-08 | pass→pass | 7,502 | 3,546 | -53% | 1 | 1 | 0% | 1,223 | 3,936 | +222% | 0 | 0 | — |
case-10 | pass→pass | 13,877 | 10,663 | -23% | 1 | 1 | 0% | 2,278 | 5,133 | +125% | 0 | 0 | — |
case-12 | pass→pass | 7,906 | 6,842 | -13% | 1 | 1 | 0% | 1,267 | 4,662 | +268% | 0 | 0 | — |
case-13 | pass→pass | 15,155 | 8,362 | -45% | 1 | 1 | 0% | 2,540 | 4,918 | +94% | 0 | 0 | — |
case-14 | pass→pass | 11,919 | 9,226 | -23% | 1 | 1 | 0% | 1,973 | 5,073 | +157% | 0 | 0 | — |
case-15 | fail→fail | 8,478 | 7,788 | -8% | 1 | 1 | 0% | 1,497 | 4,771 | +219% | 0 | 0 | — |
case-16 | fail→pass | 12,626 | 6,777 | -46% | 1 | 1 | 0% | 2,131 | 4,502 | +111% | 0 | 0 | — |
case-17 | pass→pass | 13,460 | 4,455 | -67% | 1 | 1 | 0% | 2,298 | 4,149 | +81% | 0 | 0 | — |
case-18 | fail→pass | 11,783 | 7,581 | -36% | 1 | 1 | 0% | 2,030 | 4,790 | +136% | 0 | 0 | — |
case-19 | pass→pass | 16,118 | 11,074 | -31% | 1 | 1 | 0% | 2,683 | 5,341 | +99% | 0 | 0 | — |
case-20 | pass→pass | 8,643 | 6,490 | -25% | 1 | 1 | 0% | 1,603 | 4,455 | +178% | 0 | 0 | — |
case-21 | pass→pass | 12,110 | 10,998 | -9% | 1 | 1 | 0% | 2,463 | 5,619 | +128% | 0 | 0 | — |
case-22 | pass→pass | 7,732 | 9,096 | +18% | 1 | 1 | 0% | 1,556 | 5,098 | +228% | 0 | 0 | — |
case-23 | pass→pass | 14,030 | 12,491 | -11% | 1 | 1 | 0% | 2,104 | 5,468 | +160% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +17 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.