perl-testing

Perl testing patterns using Test2::V0, Test::More, prove runner, mocking, coverage with Devel::Cover, and TDD methodology.

INSTALLATION
npx skills add https://github.com/affaan-m/everything-claude-code --skill perl-testing
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Perl Testing Patterns

Comprehensive testing strategies for Perl applications using Test2::V0, Test::More, prove, and TDD methodology.

When to Activate

  • Writing new Perl code (follow TDD: red, green, refactor)
  • Designing test suites for Perl modules or applications
  • Reviewing Perl test coverage
  • Setting up Perl testing infrastructure
  • Migrating tests from Test::More to Test2::V0
  • Debugging failing Perl tests

TDD Workflow

Always follow the RED-GREEN-REFACTOR cycle.

# 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 Fundamentals

The standard Perl testing module — widely used, ships with core.

Basic Assertions

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 and TODO

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 Modern Framework

Test2::V0 is the modern replacement for Test::More — richer assertions, better diagnostics, and extensible.

Why Test2?

  • Superior deep comparison with hash/array builders
  • Better diagnostic output on failures
  • Subtests with cleaner scoping
  • Extensible via Test2::Tools::* plugins
  • Backward-compatible with Test::More tests

Deep Comparison with Builders

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'

);

Subtests

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;

Exception Testing with Test2

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;

Test Organization and prove

Directory Structure

t/

├── 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

prove Commands

# 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 Configuration

-l

--color

--timer

-r

-j4

--state=save

Fixtures and Setup/Teardown

Subtest Isolation

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)

};

Shared Test Helpers

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.

Mocking

Test::MockModule

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

For lightweight mock objects, use Test::MockObject to create injectable test doubles with ->mock() and verify calls with ->called_ok().

Coverage with Devel::Cover

Running Coverage

# 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 }'

Integration Testing

Use in-memory SQLite for database tests, mock HTTP::Tiny for API tests.

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;

Best Practices

DO

  • Follow TDD: Write tests before implementation (red-green-refactor)
  • Use Test2::V0: Modern assertions, better diagnostics
  • Use subtests: Group related assertions, isolate state
  • Mock external dependencies: Network, database, file system
  • **Use prove -l**: Always include lib/ in @INC
  • Name tests clearly: 'user login with invalid password fails'
  • Test edge cases: Empty strings, undef, zero, boundary values
  • Aim for 80%+ coverage: Focus on business logic paths
  • Keep tests fast: Mock I/O, use in-memory databases

DON'T

  • Don't test implementation: Test behavior and output, not internals
  • Don't share state between subtests: Each subtest should be independent
  • **Don't skip done_testing**: Ensures all planned tests ran
  • Don't over-mock: Mock boundaries only, not the code under test
  • **Don't use Test::More for new projects**: Prefer Test2::V0
  • Don't ignore test failures: All tests must pass before merge
  • Don't test CPAN modules: Trust libraries to work correctly
  • Don't write brittle tests: Avoid over-specific string matching

Quick Reference

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 &#x26;&#x26; 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'; ... }

Common Pitfalls

Forgetting done_testing

# 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;

Missing -l Flag

# 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

Over-Mocking

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.

Test Pollution

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.

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card