Perl testing patterns using Test2::V0, Test::More, prove runner, mocking, coverage with Devel::Cover, and TDD methodology.
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 && 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.don't have the plugin yet? install it then click "run inline in claude" again.
use test2::v0, test::more, and the prove test runner to build fast, maintainable perl test suites. follow red-green-refactor tdd workflow: write a failing test first, implement minimal code to pass it, then refactor while keeping tests green. this skill covers unit testing, integration testing, mocking external dependencies, measuring coverage with devel::cover, and organizing test files for ci/cd pipelines. activate this when starting a new perl module, building a test suite, migrating from test::more to test2::v0, debugging test failures, or reviewing coverage gaps.
TEST_DB: set to enable database integration tests (default: skip)PROVE_RC: path to .proverc config file (default: ~/.proverc or ./.proverc)COVERAGE_THRESHOLD: minimum coverage % to enforce (default: 80)inputs: empty or existing perl project
outputs: t/, t/lib/, t/fixtures/ directories with placeholder files
create directory layout:
create .proverc in project root with sensible defaults:
-l
--color
--timer
-r
-j4
--state=save
verify prove finds tests: run prove -l --dry-run t/
inputs: feature requirement, module name (e.g., Calculator)
outputs: t/unit/calculator.t with failing assertions
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;
prove -lv t/unit/calculator.tinputs: failing test file, requirement spec
outputs: lib/Calculator.pm with minimal passing code
package Calculator;
use v5.36;
use Moo;
sub add($self, $a, $b) {
return $a + $b;
}
1;
prove -lv t/unit/calculator.tinputs: passing test, code review checklist (naming, duplication, edge cases)
outputs: improved implementation, all tests still pass
prove -lv t/unit/calculator.tprove -l t/inputs: test file, data structure to verify
outputs: enriched assertions with hash/array/bag builders
for object hashes, use hash builder with partial field matching:
is(
$user->to_hash,
hash {
field name => 'Alice';
field email => match(qr/\@example\.com$/);
field age => validator(sub { $_ >= 18 });
etc(); # ignore other fields
},
'user has expected fields'
);
for lists, use array builder:
is(
$result,
array {
item 'first';
item match(qr/^second/);
item DNE(); # does not exist, verify no extra items
},
'result matches expected list'
);
for unordered collections, use bag builder:
is(
$tags,
bag {
item 'perl';
item 'testing';
item 'tdd';
},
'has all required tags regardless of order'
);
for exceptions, use dies/lives:
like(dies { divide(10, 0) }, qr/Division by zero/, 'dies on zero denominator');
ok(lives { divide(10, 2) }, 'division succeeds');
inputs: test file with multiple related assertions
outputs: grouped assertions in subtest blocks with isolated state
wrap related assertions in subtest:
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');
};
declare setup/teardown variables with my inside subtest to prevent state leakage:
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');
};
ensure each subtest is independent: changes in one subtest must not affect another
inputs: test file with external API calls or database access
outputs: mocked methods that return controlled test data
use test::mockmodule to mock classes:
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');
for method call verification, track call count inside the mock:
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 scope is automatic: restore happens when $mock goes out of scope (no manual cleanup needed)
use test::mockobject for lightweight test doubles:
my $user_double = Test::MockObject->new;
$user_double->mock('is_active', sub { 1 });
never monkey-patch with *Package::method = sub { ... } outside of test::mockmodule: it leaks across tests
inputs: test file, database schema
outputs: in-memory sqlite database populated with test data
use in-memory sqlite for fast isolation:
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');
};
wrap database setup in subtest with my variables to auto-cleanup on scope exit
for external database tests (TEST_DB env var), use dedicated test database, never production
inputs: test file, skip/todo conditions
outputs: conditionally skipped or marked-failing tests
skip tests conditionally based on environment:
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 (features not yet implemented):
TODO: {
local $TODO = 'Caching not yet implemented';
is($cache->get('key'), 'value', 'cache returns value');
}
always provide the skip count (2 in example above): ensures prove knows how many tests to expect
inputs: t/ directory with .t test files
outputs: pass/fail report with timing and diagnostics
prove -l t/prove -lv t/prove -lv t/unit/user.tprove -lr -j8 t/prove -l --state=failed t/prove -l --timer t/prove -l --formatter TAP::Formatter::JUnit t/ > results.xmlinputs: test suite, lib/ directory
outputs: coverage report (text, html, or json)
run coverage instrumentation:
cover -test
(this runs all tests via prove under devel::cover instrumentation)
or step by step:
perl -MDevel::Cover -Ilib t/unit/user.t
cover
generate html report:
cover -report html
open cover_db/coverage.html
view text summary:
cover -report text
check coverage threshold in ci:
cover -test -report text -select '^lib/' \
| perl -ne 'if (/Total.*?(\d+\.\d+)/) { exit 1 if $1 < 80 }'
clean up coverage data between runs: cover -delete
inputs: t/lib/TestHelper.pm
outputs: reusable fixtures and factory functions
create t/lib/TestHelper.pm with exporter:
package TestHelper;
use v5.36;
use Exporter 'import';
our @EXPORT = qw(create_test_db create_temp_dir fixture_path);
sub create_test_db {
my $dbh = DBI->connect('dbi:SQLite:dbname=:memory:', '', '', { RaiseError => 1 });
# setup schema
return $dbh;
}
sub create_temp_dir {
return File::Temp::tempdir(CLEANUP => 1);
}
sub fixture_path {
my ($name) = @_;
return "t/fixtures/$name";
}
1;
in test files, use lib 't/lib' and import:
use lib 't/lib';
use TestHelper;
my $db = create_test_db();
if using test::more (legacy codebase)
else (new project)
if external api or network dependency exists
if database tests required
DBI->connect('dbi:SQLite:dbname=:memory:')if test_db environment variable set
if devel::cover coverage < 80%
if test failure in ci but passes locally
if test runs slowly
test files must exist in t/ directory, named *.t:
prove output format (when run with -l t/):
coverage report (when run with cover -test):
test names and diagnostics in tap output:
shared test helpers (t/lib/TestHelper.pm):
you know perl-testing worked when:
test file runs without error: prove -lv t/unit/calculator.t outputs "ok 1 - adds two numbers", "ok 2 - handles negatives", "1..2 ok"
test fails first (red): before implementation, prove outputs "not ok 1 - adds two numbers" with diagnostic showing expected 5 got undef
test passes after implementation (green): after writing Calculator::add(), prove outputs "ok 1", "ok 2", "1..2 ok"
refactoring keeps tests green: after improving Calculator code, prove still outputs "1..2 ok"
coverage report generated: cover -test produces cover_db/ directory and cover -report text shows "Total 80.5%"
subtests isolate state: two subtests with conflicting setup (both