NAME
Test::Mockingbird - Advanced mocking library for Perl with support for dependency injection, spies, call ordering, constructor interception, and async Future mocking
VERSION
Version 0.12
SYNOPSIS
use Test::Mockingbird;
# Mocking (shorthand form)
mock 'My::Module::method' => sub { 'mocked' };
# Mocking (longhand form)
mock('My::Module', 'method', sub { 'mocked' });
# Spying
my $spy = spy 'My::Module::method';
My::Module::method('arg1');
my @calls = $spy->(); # ( ['My::Module::method', 'arg1'], ... )
# Dependency injection
inject 'My::Module::Dependency' => $mock_object;
# Batch dependency injection
inject_all('My::Module', {
DB => $mock_db,
Logger => $mock_logger,
});
# Constructor interception
intercept_new 'My::Service' => $stub_obj;
intercept_new 'My::Service' => sub { My::Double->new(@_[1..$#_]) };
# Unmock one layer
unmock 'My::Module::method';
# Restore everything
restore_all();
# Call ordering
spy 'A::fetch';
spy 'B::process';
A::fetch();
B::process();
assert_call_order('A::fetch', 'B::process');
clear_call_log();
DESCRIPTION
Test::Mockingbird provides mocking, spying, dependency injection, call-order verification, and constructor interception for Perl test suites.
DIAGNOSTICS
diagnose_mocks
Returns a structured hashref of all active mock layers.
diagnose_mocks_pretty
Returns a human-readable multi-line string of all active mock layers.
Diagnostic Metadata
Each installed layer records:
type -- category (mock, spy, inject, mock_return, ...)
installed_at -- file and line number of the outermost user call site
LIMITATIONS
-
->can()may return truthy after unmocking a never-existed methodPerl's typeglob (GV) system auto-vivifies a GV entry the first time
\&{$full_method}is called internally (inmock(),spy(), orinject()). After unmocking, this GV entry remains in the stash with an "undefined sub" placeholder in the CODE slot.Package->can('method')tests the GV's existence in the stash, not whether the CODE slot is defined, so it may still return a truthy value.To test whether a sub is callable, use
defined(&Package::method)rather thanPackage->can('method').defined(&...)correctly returns false for the placeholder stub. Calling the stub dies with"Undefined subroutine".Deleting the GV from the stash (via
delete $stash{method}) would make->can()return false but would break subsequent mock/inject stacking: compiled direct calls (Package::method()) cache the GV at compile time, so a new GV installed after a delete is invisible to those compiled calls. -
Prototype mismatch warning from
spy()spy()installs its wrapper directly without going throughmock(), soScalar::Util::set_prototypeis not applied. Wrapping a prototyped function withspy()still emits aPrototype mismatchwarning. Usemock()with a delegating wrapper if warning-free wrapping is required. -
No nested deep_mock scopes
Test::Mockingbird::DeepMock calls
restore_all()at scope exit, which removes every active mock. Nesteddeep_mockblocks cause the inner exit to also tear down the outer mocks. Do not nestdeep_mockcalls. -
Thread safety
The internal state (
%mocked,%mock_meta,@call_log) is per-process lexical state. Concurrent threads that install and restore mocks will race. Do not use this module in threaded test harnesses without external locking. -
Spy return value is a flat list
spy()andasync_spy()return a coderef that yields a flat list of call records. A future version may return an arrayref to reduce stack pressure; the API is not yet changed to avoid breaking callers. -
Private-function encapsulation
Functions prefixed with
_are private by convention but are not enforced at runtime (Sub::Privateis not activated). White-box tests int/unit.tcall private functions directly. IfSub::Privateenforcement is added, a testing-interface export mechanism will be required.
METHODS
mock
Replace a method with a coderef.
mock('My::Module', 'method', sub { 'mocked' });
mock 'My::Module::method' => sub { 'mocked' };
Mocks stack in LIFO order. Each mock() call saves the current CODE slot
(or the auto-vivified undef stub if the method does not exist) and installs
the replacement. unmock() pops one layer; restore_all() drains all.
If the original carries a Perl prototype, the same prototype is stamped onto
the replacement coderef before installation, suppressing Prototype mismatch
warnings.
API SPECIFICATION
Input
target -- Str, 'Pkg::method' or ('Pkg', 'method')
replacement -- CodeRef
Output
returns: undef
MESSAGES
"Package, method and replacement are required" -- target or coderef missing
unmock
Restore the previous implementation of a mocked method (one layer).
unmock('My::Module', 'method');
unmock 'My::Module::method';
If the method did not exist before it was mocked, the original undef-stub
is restored so that calling the method dies with "Undefined subroutine".
Note: ->can() may still return truthy; use defined(&...) to test
whether a method is callable. See "LIMITATIONS".
API SPECIFICATION
Input
target -- Str, 'Pkg::method' or ('Pkg', 'method')
Output
returns: undef
MESSAGES
"Package and method are required for unmocking" -- target missing
before
Run a hook before a method, then call the original and return its value.
before 'My::Module::method' => sub { my @args = @_; ... };
before('My::Module', 'method', sub { ... });
The hook receives the same @_ that the original would have received. Its
return value is discarded. The original is always called and its return value
is passed to the caller unchanged. Context (list / scalar / void) is
preserved.
Uses the same LIFO mock stack as mock(): unmock() peels one layer,
restore_all() drains all. diagnose_mocks() records the layer type as
'before'.
API SPECIFICATION
Input
target -- Str, 'Pkg::method' or ('Pkg', 'method')
hook -- CodeRef; receives (@original_args), return value discarded
Output
returns: undef
MESSAGES
"Package, method and hook are required for before()" -- target or hook missing or non-CODE
after
Run a hook after a method and return the original's value.
after 'My::Module::method' => sub { my @args = @_; ... };
after('My::Module', 'method', sub { ... });
The original is called first. Its return value is captured, then the hook is
called with the same @_ that the original received. The hook's return
value is discarded and the original's return value is passed to the caller
unchanged. Context (list / scalar / void) is preserved.
If the original throws, the exception propagates immediately and the hook is
not called. Use around() if you need to run code unconditionally after
the original.
Uses the same LIFO mock stack as mock(): unmock() peels one layer,
restore_all() drains all. diagnose_mocks() records the layer type as
'after'.
API SPECIFICATION
Input
target -- Str, 'Pkg::method' or ('Pkg', 'method')
hook -- CodeRef; receives (@original_args), return value discarded
Output
returns: undef
MESSAGES
"Package, method and hook are required for after()" -- target or hook missing or non-CODE
around
Replace a method with a hook that receives the original coderef as its first argument.
around 'My::Module::method' => sub {
my ($orig, @args) = @_;
my $result = $orig->(@args); # call original
return $result * 2; # modify return value
};
around('My::Module', 'method', sub {
my ($orig, @args) = @_;
return $orig->(@args);
});
The hook receives ($orig_coderef, @original_args). It may call $orig
zero or more times with any arguments. Its return value becomes the return
value of the method. The hook is responsible for context handling when that
matters.
around() is the preferred alternative to mock() when you need to call
through to the original: it captures the original and passes it as the first
argument, avoiding the boilerplate of a separate \&{...} capture.
Uses the same LIFO mock stack as mock(): unmock() peels one layer,
restore_all() drains all. diagnose_mocks() records the layer type as
'around'.
API SPECIFICATION
Input
target -- Str, 'Pkg::method' or ('Pkg', 'method')
hook -- CodeRef; receives ($orig_coderef, @original_args)
Output
returns: undef
MESSAGES
"Package, method and hook are required for around()" -- target or hook missing or non-CODE
mock_scoped
Create a scoped mock that restores automatically when the guard goes out of scope.
Single-method forms
my $g = mock_scoped 'My::Module::method' => sub { 'mocked' };
my $g = mock_scoped('My::Module', 'method', sub { ... });
Multi-method forms
my $g = mock_scoped('My::Module',
fetch => sub { 'mocked_fetch' },
save => sub { 'mocked_save' },
);
my $g = mock_scoped(
'My::Module::fetch' => sub { 'mocked_fetch' },
'Other::Module::save' => sub { 'mocked_save' },
);
All mocked methods are restored when $g goes out of scope.
API SPECIFICATION
Input
args -- four recognised forms (see above)
Output
returns: Test::Mockingbird::Guard
MESSAGES
"mock_scoped: unrecognised argument form" -- none of the four forms matched
"mock_scoped: expected coderef for '$target'" -- non-CODE value provided
spy
Wrap a method so that every call is recorded. The original method is still called and its return value is passed back to the caller.
my $spy = spy 'My::Module::method';
My::Module::method('arg');
my @calls = $spy->(); # ( ['My::Module::method', 'arg'], ... )
restore_all();
Returns a coderef that, when invoked, returns the list of captured call
records. Each record is an arrayref [ $full_method, @args ].
Limitation
spy() does not call mock() internally and therefore does not apply
prototype preservation. Wrapping a prototyped function emits a
Prototype mismatch warning.
API SPECIFICATION
Input
target -- Str, 'Pkg::method' or ('Pkg', 'method')
Output
returns: CodeRef # yields list of call records on invocation
MESSAGES
"Package and method are required for spying" -- target missing or incomplete
inject
Inject a mock dependency into a package.
inject('My::Module', 'Dependency', $mock_object);
inject 'My::Module::Dependency' => $mock_object;
Injecting undef is valid; use argument count (not definedness of the
third argument) to distinguish shorthand from longhand.
API SPECIFICATION
Input
package -- Str
dependency -- Str
value -- Any (including undef)
Output
returns: undef
MESSAGES
"Package and dependency are required for injection" -- missing name
inject_all
Inject multiple dependencies into a package in one call.
inject_all('My::Service', {
DB => $mock_db,
Logger => $mock_logger,
});
An empty hashref is a no-op. Each pair is equivalent to a separate
inject() call and participates in the same mock stack.
API SPECIFICATION
Input
package -- Str
dependencies -- HashRef
Output
returns: undef
MESSAGES
"inject_all requires a package name" -- undef or empty package
"inject_all requires a hashref of dependencies" -- second arg not a HashRef
intercept_new
Intercept the new constructor of a class.
intercept_new 'My::Service' => $stub_obj;
intercept_new 'My::Service' => sub { My::Double->new(@_[1..$#_]) };
When given a plain value (including undef), every call to
My::Service->new returns that value. When given a coderef, every
call invokes the coderef with the original arguments (including the class
name as the first argument) and returns its result.
This is a thin wrapper around mock(); restore_all(), unmock(),
and diagnose_mocks() all work identically.
API SPECIFICATION
Input
class -- Str (non-empty)
factory -- Any; CodeRef invoked per call, or scalar returned verbatim
Output
returns: undef
MESSAGES
"intercept_new requires a class name" -- undef/empty class
"intercept_new requires a replacement object or coderef" -- factory missing
restore_all
Restore all mocked methods and injected dependencies.
restore_all(); # restore everything
restore_all 'My::Module'; # restore only My::Module's mocks
When called with a package name, only mocks whose fully-qualified names begin with that package are restored. The call-order log is pruned to remove entries for the restored package.
API SPECIFICATION
Input
package -- Str, optional
Output
returns: undef
restore
Restore all mock layers for a single method target.
restore 'My::Module::method';
If the method was never mocked this is a no-op.
API SPECIFICATION
Input
target -- Str
Output
returns: undef
MESSAGES
"restore requires a target" -- undef target
mock_return
Mock a method to always return a fixed value.
mock_return 'My::Module::method' => 42;
API SPECIFICATION
Input
target -- Str
value -- Any
Output
returns: undef
MESSAGES
"mock_return requires a target and a value" -- target undefined
mock_exception
Mock a method to always throw an exception.
mock_exception 'My::Module::method' => 'something went wrong';
API SPECIFICATION
Input
target -- Str
message -- Str
Output
returns: undef
MESSAGES
"mock_exception requires a target and an exception message" -- either missing
mock_sequence
Mock a method to return a sequence of values over successive calls. The last value repeats when the sequence is exhausted.
mock_sequence 'My::Module::method' => (1, 2, 3);
API SPECIFICATION
Input
target -- Str
values -- Array (one or more)
Output
returns: undef
MESSAGES
"mock_sequence requires a target and at least one value" -- empty value list
mock_once
Install a mock that fires exactly once. After the first call the previous implementation is automatically restored.
mock_once 'My::Module::method' => sub { 'temporary' };
API SPECIFICATION
Input
target -- Str
code -- CodeRef
Output
returns: undef
MESSAGES
"mock_once requires a target and a coderef" -- missing or non-CODE factory
PSEUDOCODE
parse target → (package, method)
wrapper = sub {
result = code(@_)
unmock(package, method) -- pop this very layer
return result
}
install wrapper via mock() with TYPE='mock_once'
assert_call_order
Assert that the named methods were called in left-to-right order.
assert_call_order('A::fetch', 'B::process', 'C::save');
Produces one TAP ok/not-ok line and returns a boolean. Intervening calls to other methods are ignored.
API SPECIFICATION
Input
methods -- Array of Str (two or more fully-qualified names)
Output
returns: Bool
MESSAGES
"assert_call_order requires at least two method names" -- fewer than two given
clear_call_log
Clear the call-order log without restoring mocks or spies.
clear_call_log();
restore_all() also clears the log automatically.
API SPECIFICATION
Input
none
Output
returns: undef
diagnose_mocks
Return a structured hashref of all currently active mock layers.
my $diag = diagnose_mocks();
# $diag->{'My::Pkg::method'} = {
# depth => 1,
# layers => [ { type => 'mock_return', installed_at => '...' } ],
# }
API SPECIFICATION
Input
none
Output
returns: HashRef
diagnose_mocks_pretty
Return a human-readable multi-line string of all active mock layers.
API SPECIFICATION
Input
none
Output
returns: Str
SUPPORT
Please report bugs at https://github.com/nigelhorne/Test-Mockingbird/issues.
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
SEE ALSO
REPOSITORY
https://github.com/nigelhorne/Test-Mockingbird
FORMAL SPECIFICATION
mock
mock ≙
∀ target : Str; replacement : CodeRef •
pre target ≠ '' ∧ defined(replacement)
post mocked'[target] = ⟨saved(target)⟩ ⌢ mocked[target]
∧ sym_table'[target].CODE = replacement
∧ prototype(replacement) = prototype(saved(target))
unmock
unmock ≙
∀ target : Str •
let prev = head(mocked[target]) •
post mocked'[target] = tail(mocked[target])
∧ sym_table'[target].CODE = prev
∧ mock_meta'[target] = tail(mock_meta[target])
before
before ≙
∀ target : Str; hook : CodeRef •
pre target ≠ '' ∧ ref(hook) = 'CODE'
let orig = sym_table[target].CODE •
post sym_table'[target].CODE = wrapper
∧ wrapper(@args) ≙ hook(@args); orig(@args)
after
after ≙
∀ target : Str; hook : CodeRef •
pre target ≠ '' ∧ ref(hook) = 'CODE'
let orig = sym_table[target].CODE •
post sym_table'[target].CODE = wrapper
∧ wrapper(@args) ≙ let ret = orig(@args) • hook(@args); ret
around
around ≙
∀ target : Str; hook : CodeRef •
pre target ≠ '' ∧ ref(hook) = 'CODE'
let orig = sym_table[target].CODE •
post sym_table'[target].CODE = wrapper
∧ wrapper(@args) ≙ hook(orig, @args)
mock_scoped
mock_scoped ≙
install all mocks via mock()
∧ return Guard(full_methods)
∧ Guard.DESTROY ⇒ ∀ m ∈ full_methods • unmock(m)
spy
spy ≙
∀ target : Str •
pre defined(target)
post sym_table'[target].CODE = wrapper(orig)
∧ wrapper: @args → (calls' = calls ⌢ ⟨[target, @args]⟩ ∧ orig(@args))
inject
inject ≙
∀ pkg : Str; dep : Str; val : Any •
pre pkg ≠ '' ∧ dep ≠ ''
post sym_table'["${pkg}::${dep}"].CODE = sub { val }
inject_all
inject_all ≙
∀ pkg : Str; deps : HashRef •
post ∀ (k,v) ∈ deps • inject(pkg, k, v)
intercept_new
intercept_new ≙
∀ class : Str; factory : Any •
pre class ≠ '' ∧ @args ≥ 2
let rep = (factory : CodeRef) ? factory : sub { factory } •
post mock("${class}::new", rep)
restore_all
restore_all ≙
global: mocked' = {} ∧ mock_meta' = {} ∧ call_log' = []
scoped: ∀ target ∈ dom(mocked) • target =~ /^pkg::/ ⇒ unmock_all(target)
∧ call_log' = [ e ∈ call_log | e !~ /^pkg::/ ]
restore
restore ≙
∀ target : Str •
pre defined(target)
post mocked[target] = []
mock_return
mock_return ≙
∀ target : Str; value : Any •
post sym_table'[target].CODE = sub { value }
mock_exception
mock_exception ≙
∀ target : Str; msg : Str •
post sym_table'[target].CODE = sub { croak msg }
mock_sequence
mock_sequence ≙
∀ target : Str; values : Seq(Any) •
pre |values| ≥ 1
post let queue = values •
sym_table'[target].CODE = sub { head(queue) if |queue|=1 else shift(queue) }
mock_once
mock_once ≙
∀ target : Str; code : CodeRef •
post sym_table'[target] = sub {
result = code(@args)
unmock(target)
return result
}
assert_call_order
assert_call_order ≙
∀ expected : Seq(Str) •
pre |expected| ≥ 2
post result = (∀ i • ∃ p_i : ℕ | p_0 < p_1 < … ∧ call_log[p_i] = expected[i])
clear_call_log
clear_call_log ≙ post call_log' = []
diagnose_mocks
diagnose_mocks ≙
returns { target ↦ { depth, layers } | target ∈ dom(mocked) }
diagnose_mocks_pretty
diagnose_mocks_pretty ≙ stringify(diagnose_mocks())
before
before ≙
∀ target : Str; hook : CodeRef •
pre target ≠ '' ∧ ref(hook) = 'CODE'
let orig = sym_table[target].CODE •
post sym_table'[target].CODE = wrapper
∧ wrapper(@args) ≙ hook(@args); orig(@args)
after
after ≙
∀ target : Str; hook : CodeRef •
pre target ≠ '' ∧ ref(hook) = 'CODE'
let orig = sym_table[target].CODE •
post sym_table'[target].CODE = wrapper
∧ wrapper(@args) ≙
let ret = orig(@args) •
hook(@args);
ret
around
around ≙
∀ target : Str; hook : CodeRef •
pre target ≠ '' ∧ ref(hook) = 'CODE'
let orig = sym_table[target].CODE •
post sym_table'[target].CODE = wrapper
∧ wrapper(@args) ≙ hook(orig, @args)
LICENCE AND COPYRIGHT
Copyright 2025-2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.