| File: | blib/lib/Test/Mockingbird/DeepMock.pm |
| Coverage: | 100.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Test::Mockingbird::DeepMock; | |||||
| 2 | ||||||
| 3 | 6 6 6 | 1158 5 71 | use strict; | |||
| 4 | 6 6 6 | 8 3 113 | use warnings; | |||
| 5 | ||||||
| 6 | 6 6 6 | 8 3 103 | use Carp qw(croak); | |||
| 7 | 6 6 6 | 8 4 53 | use Exporter 'import'; | |||
| 8 | 6 6 6 | 6 4 35 | use Test::Mockingbird (); | |||
| 9 | 6 6 6 | 693 6 47 | use Test::Mockingbird::TimeTravel (); | |||
| 10 | 6 6 6 | 11 3 3124 | use Test::More (); | |||
| 11 | ||||||
| 12 | our @EXPORT_OK = qw(deep_mock); | |||||
| 13 | ||||||
| 14 - 22 | =head1 NAME Test::Mockingbird::DeepMock - Declarative structured mocking and spying for Perl tests =head1 VERSION Version 0.13 =cut | |||||
| 23 | ||||||
| 24 | our $VERSION = '0.13'; | |||||
| 25 | ||||||
| 26 - 177 | =head1 SYNOPSIS
use Test::Mockingbird::DeepMock qw(deep_mock);
deep_mock(
{
mocks => [
{ target => 'MyApp::greet', type => 'mock', with => sub { 'hi' } },
{ target => 'MyApp::double', type => 'spy', tag => 'double_spy' },
],
expectations => [
{ tag => 'double_spy', calls => 2 },
],
},
sub {
is MyApp::greet(), 'hi', 'greet mocked';
MyApp::double(2);
MyApp::double(3);
}
);
=head1 DESCRIPTION
C<Test::Mockingbird::DeepMock> provides a declarative, data-driven way to
describe mocking, spying, injection, and expectations in Perl tests.
Instead of scattering C<mock>, C<spy>, and C<restore_all> calls throughout
your test code, DeepMock lets you define a complete mocking plan in a single
hashref, then executes your test code under that plan.
=head1 LIMITATIONS
=over 4
=item Nested deep_mock scopes are not supported
C<deep_mock> calls C<restore_all()> on exit, which removes every active mock.
A nested C<deep_mock> call will cause the inner exit to also tear down the
outer mocks.
=item inject type in mocks list
The C<inject> mock type delegates to C<Test::Mockingbird::inject()>. Because
C<inject()> builds a sub that returns the C<with> value verbatim, passing a
coderef via C<with> stores the coderef itself; it is not called on each
access. To inject a factory, wrap it explicitly.
=back
=head1 PLAN STRUCTURE
=head2 C<mocks>
ArrayRef of mock specs. Each entry:
{
target => 'Package::method',
type => 'mock' | 'spy' | 'inject',
with => sub { ... },
tag => 'identifier',
}
=head2 C<expectations>
ArrayRef of expectation specs. Each entry:
{
tag => 'spy_tag',
calls => $n,
args_like => [ [qr/pat1/, qr/pat2/], ... ],
args_eq => [ ['exact', 'args'], ... ],
args_deeply => [ [$struct], ... ],
never => 1,
order => [ 'A::m1', 'B::m2' ],
}
The C<order> key is processed after all per-spy expectations and does not
require a C<tag>.
=head2 C<globals>
Optional:
globals => { restore_on_scope_exit => 1 }
=head2 C<time>
Optional time-travel plan applied before mocks are installed:
time => {
freeze => '2025-01-01T00:00:00Z',
travel => '2025-01-03T00:00:00Z',
advance => [ 2 => 'minutes' ],
rewind => [ 1 => 'hour' ],
}
=head1 TROUBLESHOOTING
=head2 "Not enough arguments for deep_mock"
DeepMock uses C<($$)> prototype. Use C<deep_mock( {...}, sub { ... } )>.
=head2 My mocks are not restored
Check C<globals => { restore_on_scope_exit => 0 }> has not been set.
=head1 METHODS
=head2 deep_mock
Run a code block with a set of mocks and expectations applied.
=head3 API SPECIFICATION
=head4 Input (Params::Validate::Strict schema)
$plan -- HashRef with keys: mocks, expectations, globals, time
$code -- CodeRef
=head4 Output (Returns::Set schema)
returns: whatever $code returns (context-sensitive)
=head3 MESSAGES
"deep_mock expects a HASHREF plan" -- first arg is not a hashref
=head3 FORMAL SPECIFICATION
deep_mock â
â plan : HashRef; code : CodeRef â¢
pre ref(plan) = 'HASH'
post apply_time(plan.time)
â§ install_mocks(plan.mocks)
â§ result = code()
â§ check_expectations(plan.expectations)
â§ restore_all()
=head3 PSEUDOCODE
validate plan is HASH
apply time plan if present
install each mock/spy/inject from plan.mocks, building %handles
capture wantarray context
eval { run $code }
run expectations against %handles
restore all mocks (unless restore_on_scope_exit => 0)
restore time state
re-croak any exception from $code
return $code's result in correct context
=cut | |||||
| 178 | ||||||
| 179 | sub deep_mock { | |||||
| 180 | 41 | 196204 | my ($plan, $code) = @_; | |||
| 181 | ||||||
| 182 | 41 | 57 | croak 'deep_mock expects a HASHREF plan' unless ref $plan eq 'HASH'; | |||
| 183 | ||||||
| 184 | 39 | 28 | my %handles; | |||
| 185 | ||||||
| 186 | 39 | 56 | _apply_time_plan($plan->{time}); | |||
| 187 | ||||||
| 188 | 39 | 81 | _install_mocks($plan->{mocks} // [], \%handles); | |||
| 189 | ||||||
| 190 | # Preserve the caller's context through the eval block | |||||
| 191 | 35 | 18 | my $ctx = wantarray; | |||
| 192 | 35 | 30 | my (@list_ret, $scalar_ret, $err); | |||
| 193 | ||||||
| 194 | { | |||||
| 195 | 35 35 | 16 23 | local $@; | |||
| 196 | 35 | 36 | if ($ctx) { | |||
| 197 | 2 2 | 2 3 | @list_ret = eval { $code->() }; | |||
| 198 | } elsif (defined $ctx) { | |||||
| 199 | 2 2 | 1 3 | $scalar_ret = eval { $code->() }; | |||
| 200 | } else { | |||||
| 201 | 31 31 | 17 29 | eval { $code->() }; | |||
| 202 | } | |||||
| 203 | 35 | 3237 | $err = $@; | |||
| 204 | } | |||||
| 205 | ||||||
| 206 | 35 | 67 | _run_expectations($plan->{expectations} // [], \%handles); | |||
| 207 | ||||||
| 208 | my $auto_restore = !exists $plan->{globals}{restore_on_scope_exit} | |||||
| 209 | 34 | 74 | || $plan->{globals}{restore_on_scope_exit}; | |||
| 210 | ||||||
| 211 | 34 | 53 | Test::Mockingbird::restore_all() if $auto_restore; | |||
| 212 | 34 | 49 | Test::Mockingbird::TimeTravel::restore_all(); | |||
| 213 | ||||||
| 214 | 34 | 50 | croak $err if $err; | |||
| 215 | ||||||
| 216 | 31 | 73 | return $ctx ? @list_ret : $scalar_ret; | |||
| 217 | } | |||||
| 218 | ||||||
| 219 | # _normalize_target -- Private | |||||
| 220 | # | |||||
| 221 | # Purpose: Delegate to Test::Mockingbird::_parse_target. Kept here as | |||||
| 222 | # a thin wrapper so that white-box tests calling | |||||
| 223 | # Test::Mockingbird::DeepMock::_normalize_target() continue to | |||||
| 224 | # work without requiring updates to those tests. | |||||
| 225 | # Entry: $target -- Str, 'Pkg::method' or ('Pkg', 'method') | |||||
| 226 | # Exit: ($package, $method) | |||||
| 227 | # Side effects: none | |||||
| 228 | sub _normalize_target { | |||||
| 229 | 53 | 84347 | return Test::Mockingbird::_parse_target(@_); | |||
| 230 | } | |||||
| 231 | ||||||
| 232 | # _install_mocks -- Private | |||||
| 233 | # | |||||
| 234 | # Purpose: Install each mock/spy/inject described in the plan and | |||||
| 235 | # record spy handles in %handles for later expectation checks. | |||||
| 236 | # Entry: $mocks -- ArrayRef of mock spec hashrefs | |||||
| 237 | # $handles -- HashRef to populate with spy/guard handles | |||||
| 238 | # Exit: undef | |||||
| 239 | # Side effects: Modifies symbol tables of target packages. | |||||
| 240 | # Populates $handles. | |||||
| 241 | sub _install_mocks { | |||||
| 242 | 48 | 9286 | my ($mocks, $handles) = @_; | |||
| 243 | ||||||
| 244 | 48 | 27 | my @installed; | |||
| 245 | ||||||
| 246 | 48 | 38 | for my $m (@$mocks) { | |||
| 247 | my $target = $m->{target} | |||||
| 248 | 50 | 69 | or croak 'mock entry missing target'; | |||
| 249 | ||||||
| 250 | 47 | 46 | my ($pkg, $method) = _normalize_target($target); | |||
| 251 | 47 | 43 | my $full = "${pkg}::${method}"; | |||
| 252 | 47 | 51 | my $type = $m->{type} // 'mock'; | |||
| 253 | ||||||
| 254 | 47 | 63 | if ($type eq 'mock') { | |||
| 255 | croak "mock type requires 'with' coderef" | |||||
| 256 | 20 | 42 | unless defined $m->{with} && ref $m->{with} eq 'CODE'; | |||
| 257 | ||||||
| 258 | 18 | 32 | Test::Mockingbird::mock($pkg, $method, $m->{with}); | |||
| 259 | 18 | 24 | $handles->{ $m->{tag} }{guard} = 1 if $m->{tag}; | |||
| 260 | ||||||
| 261 | } elsif ($type eq 'spy') { | |||||
| 262 | 18 | 26 | my $spy = Test::Mockingbird::spy($pkg, $method); | |||
| 263 | 18 | 38 | $handles->{ $m->{tag} }{spy} = $spy if $m->{tag}; | |||
| 264 | ||||||
| 265 | } elsif ($type eq 'inject') { | |||||
| 266 | 5 | 9 | Test::Mockingbird::inject($pkg, $method, $m->{with}); | |||
| 267 | 5 | 14 | $handles->{ $m->{tag} }{inject} = 1 if $m->{tag}; | |||
| 268 | ||||||
| 269 | } else { | |||||
| 270 | 4 | 23 | croak "Unknown mock type '$type' for target '$target'"; | |||
| 271 | } | |||||
| 272 | ||||||
| 273 | 41 | 45 | push @installed, $full; | |||
| 274 | } | |||||
| 275 | ||||||
| 276 | 39 | 48 | return @installed; | |||
| 277 | } | |||||
| 278 | ||||||
| 279 | # _run_expectations -- Private | |||||
| 280 | # | |||||
| 281 | # Purpose: Validate recorded spy calls against the plan's expectations. | |||||
| 282 | # Handles: calls, args_like, args_eq, args_deeply, never, order. | |||||
| 283 | # Entry: $exps -- ArrayRef of expectation hashrefs | |||||
| 284 | # $handles -- HashRef populated by _install_mocks | |||||
| 285 | # Exit: undef | |||||
| 286 | # Side effects: Emits TAP via Test::More. Croaks on missing tag or spy. | |||||
| 287 | sub _run_expectations { | |||||
| 288 | 56 | 4361 | my ($exps, $handles) = @_; | |||
| 289 | ||||||
| 290 | 56 | 49 | for my $exp (@$exps) { | |||
| 291 | # order-only entries (no tag) are handled in the second pass below | |||||
| 292 | 38 | 64 | next if exists $exp->{order} && !exists $exp->{tag}; | |||
| 293 | ||||||
| 294 | my $tag = $exp->{tag} | |||||
| 295 | 33 | 42 | or croak 'expectation missing tag'; | |||
| 296 | ||||||
| 297 | my $spy = $handles->{$tag}{spy} | |||||
| 298 | 30 | 51 | or croak "no spy handle for tag '$tag'"; | |||
| 299 | ||||||
| 300 | 28 | 29 | my @calls = $spy->(); # [ full_method, @args ] | |||
| 301 | ||||||
| 302 | # ---- call count ----------------------------------------------- | |||||
| 303 | 28 | 29 | if (defined $exp->{calls}) { | |||
| 304 | Test::More::is(scalar @calls, $exp->{calls}, | |||||
| 305 | 18 | 34 | "DeepMock: calls for $tag"); | |||
| 306 | } | |||||
| 307 | ||||||
| 308 | # ---- args_like (regex matching) -------------------------------- | |||||
| 309 | 28 | 2834 | if (my $args_like = $exp->{args_like}) { | |||
| 310 | 5 | 9 | for my $i (0 .. $#$args_like) { | |||
| 311 | 8 | 463 | my $patterns = $args_like->[$i]; | |||
| 312 | 8 | 12 | my $call = $calls[$i] // []; | |||
| 313 | 8 8 | 11 12 | my @args = @{$call}[1 .. $#$call]; | |||
| 314 | 8 | 11 | for my $j (0 .. $#$patterns) { | |||
| 315 | 8 | 17 | my $re = $patterns->[$j]; | |||
| 316 | 8 | 34 | Test::More::like($args[$j], | |||
| 317 | ref $re ? $re : qr/$re/, | |||||
| 318 | "DeepMock: arg $j call $i of $tag (args_like)"); | |||||
| 319 | } | |||||
| 320 | } | |||||
| 321 | } | |||||
| 322 | ||||||
| 323 | # ---- args_eq (exact matching) ---------------------------------- | |||||
| 324 | 28 | 762 | if (my $args_eq = $exp->{args_eq}) { | |||
| 325 | 4 | 9 | for my $i (0 .. $#$args_eq) { | |||
| 326 | 7 | 476 | my $expected = $args_eq->[$i]; | |||
| 327 | 7 | 9 | my $call = $calls[$i] // []; | |||
| 328 | 7 7 | 9 10 | my @args = @{$call}[1 .. $#$call]; | |||
| 329 | 7 | 22 | for my $j (0 .. $#$expected) { | |||
| 330 | 8 | 162 | Test::More::is($args[$j], $expected->[$j], | |||
| 331 | "DeepMock: arg $j call $i of $tag (args_eq)"); | |||||
| 332 | } | |||||
| 333 | } | |||||
| 334 | } | |||||
| 335 | ||||||
| 336 | # ---- args_deeply (structural) ---------------------------------- | |||||
| 337 | 28 | 641 | if (my $args_deeply = $exp->{args_deeply}) { | |||
| 338 | 3 | 10 | require Test::Deep; | |||
| 339 | 3 | 6 | for my $i (0 .. $#$args_deeply) { | |||
| 340 | 6 | 10848 | my $expected = $args_deeply->[$i]; | |||
| 341 | 6 | 9 | my $call = $calls[$i] // []; | |||
| 342 | 6 6 | 8 6 | my @args = @{$call}[1 .. $#$call]; | |||
| 343 | 6 | 6 | for my $j (0 .. $#$expected) { | |||
| 344 | 6 | 18 | Test::Deep::cmp_deeply($args[$j], $expected->[$j], | |||
| 345 | "DeepMock: arg $j call $i of $tag (args_deeply)"); | |||||
| 346 | } | |||||
| 347 | } | |||||
| 348 | } | |||||
| 349 | ||||||
| 350 | # ---- never (assert zero calls) --------------------------------- | |||||
| 351 | 28 | 3305 | if ($exp->{never}) { | |||
| 352 | 3 | 7 | Test::More::is(scalar @calls, 0, "DeepMock: $tag was never called"); | |||
| 353 | } | |||||
| 354 | } | |||||
| 355 | ||||||
| 356 | # Second pass: process cross-method order expectations (no tag required) | |||||
| 357 | 51 | 531 | for my $exp (@$exps) { | |||
| 358 | 33 | 43 | next unless exists $exp->{order}; | |||
| 359 | 6 6 | 5 12 | Test::Mockingbird::assert_call_order(@{ $exp->{order} }); | |||
| 360 | } | |||||
| 361 | ||||||
| 362 | 51 | 52 | return; | |||
| 363 | } | |||||
| 364 | ||||||
| 365 | # _apply_time_plan -- Private | |||||
| 366 | # | |||||
| 367 | # Purpose: Apply a time-travel plan from the deep_mock spec before | |||||
| 368 | # installing mocks. All keys are optional. | |||||
| 369 | # Entry: $time -- HashRef or undef | |||||
| 370 | # Exit: undef | |||||
| 371 | # Side effects: Activates Test::Mockingbird::TimeTravel if any key is set. | |||||
| 372 | sub _apply_time_plan { | |||||
| 373 | 39 | 28 | my $time = $_[0]; | |||
| 374 | 39 | 53 | return unless $time && ref $time eq 'HASH'; | |||
| 375 | ||||||
| 376 | Test::Mockingbird::TimeTravel::freeze_time($time->{freeze}) | |||||
| 377 | 4 | 10 | if exists $time->{freeze}; | |||
| 378 | ||||||
| 379 | Test::Mockingbird::TimeTravel::travel_to($time->{travel}) | |||||
| 380 | 4 | 7 | if exists $time->{travel}; | |||
| 381 | ||||||
| 382 | 4 | 14 | if (exists $time->{advance}) { | |||
| 383 | 1 1 | 1 2 | my ($amount, $unit) = @{ $time->{advance} }; | |||
| 384 | 1 | 1 | Test::Mockingbird::TimeTravel::advance_time($amount, $unit); | |||
| 385 | } | |||||
| 386 | ||||||
| 387 | 4 | 5 | if (exists $time->{rewind}) { | |||
| 388 | 1 1 | 1 1 | my ($amount, $unit) = @{ $time->{rewind} }; | |||
| 389 | 1 | 2 | Test::Mockingbird::TimeTravel::rewind_time($amount, $unit); | |||
| 390 | } | |||||
| 391 | ||||||
| 392 | 4 | 4 | return; | |||
| 393 | } | |||||
| 394 | ||||||
| 395 - 425 | =head1 SUPPORT Please report bugs at L<https://github.com/nigelhorne/Test-Mockingbird/issues>. =head1 AUTHOR Nigel Horne, C<< <njh at nigelhorne.com> >> =head1 SEE ALSO =over 4 =item * L<Test::Mockingbird> =item * L<Test::Mockingbird::Async> =item * L<Test::Mockingbird::TimeTravel> =back =head1 REPOSITORY L<https://github.com/nigelhorne/Test-Mockingbird> =head1 LICENCE AND COPYRIGHT Copyright 2026 Nigel Horne. Usage is subject to GPL2 licence terms. =cut | |||||
| 426 | ||||||
| 427 | 1; | |||||