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.