TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): 100.0% (5/5)
Approximate LCSAJ segments: 59
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package Test::Mockingbird::DeepMock; 2: 3: use strict; 4: use warnings; 5: 6: use Carp qw(croak); 7: use Exporter 'import'; 8: use Test::Mockingbird (); 9: use Test::Mockingbird::TimeTravel (); 10: use Test::More (); 11: 12: our @EXPORT_OK = qw(deep_mock); 13: 14: =head1 NAME 15: 16: Test::Mockingbird::DeepMock - Declarative structured mocking and spying for Perl tests 17: 18: =head1 VERSION 19: 20: Version 0.13 21: 22: =cut 23: 24: our $VERSION = '0.13'; 25: 26: =head1 SYNOPSIS 27: 28: use Test::Mockingbird::DeepMock qw(deep_mock); 29: 30: deep_mock( 31: { 32: mocks => [ 33: { target => 'MyApp::greet', type => 'mock', with => sub { 'hi' } }, 34: { target => 'MyApp::double', type => 'spy', tag => 'double_spy' }, 35: ], 36: expectations => [ 37: { tag => 'double_spy', calls => 2 }, 38: ], 39: }, 40: sub { 41: is MyApp::greet(), 'hi', 'greet mocked'; 42: MyApp::double(2); 43: MyApp::double(3); 44: } 45: ); 46: 47: =head1 DESCRIPTION 48: 49: C<Test::Mockingbird::DeepMock> provides a declarative, data-driven way to 50: describe mocking, spying, injection, and expectations in Perl tests. 51: 52: Instead of scattering C<mock>, C<spy>, and C<restore_all> calls throughout 53: your test code, DeepMock lets you define a complete mocking plan in a single 54: hashref, then executes your test code under that plan. 55: 56: =head1 LIMITATIONS 57: 58: =over 4 59: 60: =item Nested deep_mock scopes are not supported 61: 62: C<deep_mock> calls C<restore_all()> on exit, which removes every active mock. 63: A nested C<deep_mock> call will cause the inner exit to also tear down the 64: outer mocks. 65: 66: =item inject type in mocks list 67: 68: The C<inject> mock type delegates to C<Test::Mockingbird::inject()>. Because 69: C<inject()> builds a sub that returns the C<with> value verbatim, passing a 70: coderef via C<with> stores the coderef itself; it is not called on each 71: access. To inject a factory, wrap it explicitly. 72: 73: =back 74: 75: =head1 PLAN STRUCTURE 76: 77: =head2 C<mocks> 78: 79: ArrayRef of mock specs. Each entry: 80: 81: { 82: target => 'Package::method', 83: type => 'mock' | 'spy' | 'inject', 84: with => sub { ... }, 85: tag => 'identifier', 86: } 87: 88: =head2 C<expectations> 89: 90: ArrayRef of expectation specs. Each entry: 91: 92: { 93: tag => 'spy_tag', 94: calls => $n, 95: args_like => [ [qr/pat1/, qr/pat2/], ... ], 96: args_eq => [ ['exact', 'args'], ... ], 97: args_deeply => [ [$struct], ... ], 98: never => 1, 99: order => [ 'A::m1', 'B::m2' ], 100: } 101: 102: The C<order> key is processed after all per-spy expectations and does not 103: require a C<tag>. 104: 105: =head2 C<globals> 106: 107: Optional: 108: 109: globals => { restore_on_scope_exit => 1 } 110: 111: =head2 C<time> 112: 113: Optional time-travel plan applied before mocks are installed: 114: 115: time => { 116: freeze => '2025-01-01T00:00:00Z', 117: travel => '2025-01-03T00:00:00Z', 118: advance => [ 2 => 'minutes' ], 119: rewind => [ 1 => 'hour' ], 120: } 121: 122: =head1 TROUBLESHOOTING 123: 124: =head2 "Not enough arguments for deep_mock" 125: 126: DeepMock uses C<($$)> prototype. Use C<deep_mock( {...}, sub { ... } )>. 127: 128: =head2 My mocks are not restored 129: 130: Check C<globals => { restore_on_scope_exit => 0 }> has not been set. 131: 132: =head1 METHODS 133: 134: =head2 deep_mock 135: 136: Run a code block with a set of mocks and expectations applied. 137: 138: =head3 API SPECIFICATION 139: 140: =head4 Input (Params::Validate::Strict schema) 141: 142: $plan -- HashRef with keys: mocks, expectations, globals, time 143: $code -- CodeRef 144: 145: =head4 Output (Returns::Set schema) 146: 147: returns: whatever $code returns (context-sensitive) 148: 149: =head3 MESSAGES 150: 151: "deep_mock expects a HASHREF plan" -- first arg is not a hashref 152: 153: =head3 FORMAL SPECIFICATION 154: 155: deep_mock â 156: â plan : HashRef; code : CodeRef ⢠157: pre ref(plan) = 'HASH' 158: post apply_time(plan.time) 159: â§ install_mocks(plan.mocks) 160: â§ result = code() 161: â§ check_expectations(plan.expectations) 162: â§ restore_all() 163: 164: =head3 PSEUDOCODE 165: 166: validate plan is HASH 167: apply time plan if present 168: install each mock/spy/inject from plan.mocks, building %handles 169: capture wantarray context 170: eval { run $code } 171: run expectations against %handles 172: restore all mocks (unless restore_on_scope_exit => 0) 173: restore time state 174: re-croak any exception from $code 175: return $code's result in correct context 176: 177: =cut 178: 179: sub deep_mock { 180: my ($plan, $code) = @_; 181: 182: croak 'deep_mock expects a HASHREF plan' unless ref $plan eq 'HASH'; 183: 184: my %handles; 185: 186: _apply_time_plan($plan->{time}); 187: 188: _install_mocks($plan->{mocks} // [], \%handles); 189: 190: # Preserve the caller's context through the eval block 191: my $ctx = wantarray; 192: my (@list_ret, $scalar_ret, $err); 193: 194: { 195: local $@; 196: if ($ctx) {Mutants (Total: 1, Killed: 1, Survived: 0)
197: @list_ret = eval { $code->() }; 198: } elsif (defined $ctx) { 199: $scalar_ret = eval { $code->() }; 200: } else { 201: eval { $code->() }; 202: } 203: $err = $@; 204: } 205: 206: _run_expectations($plan->{expectations} // [], \%handles); 207: 208: my $auto_restore = !exists $plan->{globals}{restore_on_scope_exit} 209: || $plan->{globals}{restore_on_scope_exit}; 210: 211: Test::Mockingbird::restore_all() if $auto_restore; 212: Test::Mockingbird::TimeTravel::restore_all(); 213: 214: croak $err if $err; 215: 216: return $ctx ? @list_ret : $scalar_ret;
Mutants (Total: 2, Killed: 2, Survived: 0)
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: return Test::Mockingbird::_parse_target(@_);
Mutants (Total: 2, Killed: 2, Survived: 0)
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 → 246 → 276 242: my ($mocks, $handles) = @_; 243: 244: my @installed; 245: 246: for my $m (@$mocks) { 247: my $target = $m->{target} 248: or croak 'mock entry missing target'; 249: 250: my ($pkg, $method) = _normalize_target($target); 251: my $full = "${pkg}::${method}"; 252: my $type = $m->{type} // 'mock'; 253: 254: if ($type eq 'mock') {
Mutants (Total: 1, Killed: 1, Survived: 0)
255: croak "mock type requires 'with' coderef" 256: unless defined $m->{with} && ref $m->{with} eq 'CODE'; 257: 258: Test::Mockingbird::mock($pkg, $method, $m->{with}); 259: $handles->{ $m->{tag} }{guard} = 1 if $m->{tag}; 260: 261: } elsif ($type eq 'spy') { 262: my $spy = Test::Mockingbird::spy($pkg, $method); 263: $handles->{ $m->{tag} }{spy} = $spy if $m->{tag}; 264: 265: } elsif ($type eq 'inject') { 266: Test::Mockingbird::inject($pkg, $method, $m->{with}); 267: $handles->{ $m->{tag} }{inject} = 1 if $m->{tag}; 268: 269: } else { 270: croak "Unknown mock type '$type' for target '$target'"; 271: } 272: 273: push @installed, $full; 274: } 275: 276: return @installed;
Mutants (Total: 2, Killed: 2, Survived: 0)
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 → 290 → 357 288: my ($exps, $handles) = @_; 289: 290: for my $exp (@$exps) { 291: # order-only entries (no tag) are handled in the second pass below 292: next if exists $exp->{order} && !exists $exp->{tag}; 293: 294: my $tag = $exp->{tag} 295: or croak 'expectation missing tag'; 296: 297: my $spy = $handles->{$tag}{spy} 298: or croak "no spy handle for tag '$tag'"; 299: 300: my @calls = $spy->(); # [ full_method, @args ] 301: 302: # ---- call count ----------------------------------------------- 303: if (defined $exp->{calls}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
304: Test::More::is(scalar @calls, $exp->{calls}, 305: "DeepMock: calls for $tag"); 306: } 307: 308: # ---- args_like (regex matching) -------------------------------- 309: if (my $args_like = $exp->{args_like}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
310: for my $i (0 .. $#$args_like) { 311: my $patterns = $args_like->[$i]; 312: my $call = $calls[$i] // []; 313: my @args = @{$call}[1 .. $#$call]; 314: for my $j (0 .. $#$patterns) { 315: my $re = $patterns->[$j]; 316: 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: if (my $args_eq = $exp->{args_eq}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
325: for my $i (0 .. $#$args_eq) { 326: my $expected = $args_eq->[$i]; 327: my $call = $calls[$i] // []; 328: my @args = @{$call}[1 .. $#$call]; 329: for my $j (0 .. $#$expected) { 330: 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: if (my $args_deeply = $exp->{args_deeply}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
338: require Test::Deep; 339: for my $i (0 .. $#$args_deeply) { 340: my $expected = $args_deeply->[$i]; 341: my $call = $calls[$i] // []; 342: my @args = @{$call}[1 .. $#$call]; 343: for my $j (0 .. $#$expected) { 344: 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: if ($exp->{never}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
352: 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 → 357 → 362 357: for my $exp (@$exps) { 358: next unless exists $exp->{order}; 359: Test::Mockingbird::assert_call_order(@{ $exp->{order} }); 360: } 361: 362: 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 → 382 → 387 373: my $time = $_[0]; 374: return unless $time && ref $time eq 'HASH'; 375: 376: Test::Mockingbird::TimeTravel::freeze_time($time->{freeze}) 377: if exists $time->{freeze}; 378: 379: Test::Mockingbird::TimeTravel::travel_to($time->{travel}) 380: if exists $time->{travel}; 381: 382: if (exists $time->{advance}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
383: my ($amount, $unit) = @{ $time->{advance} }; 384: Test::Mockingbird::TimeTravel::advance_time($amount, $unit); 385: } 386: ●387 → 387 → 392 387: if (exists $time->{rewind}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
388: my ($amount, $unit) = @{ $time->{rewind} }; 389: Test::Mockingbird::TimeTravel::rewind_time($amount, $unit); 390: } 391: 392: return; 393: } 394: 395: =head1 SUPPORT 396: 397: Please report bugs at L<https://github.com/nigelhorne/Test-Mockingbird/issues>. 398: 399: =head1 AUTHOR 400: 401: Nigel Horne, C<< <njh at nigelhorne.com> >> 402: 403: =head1 SEE ALSO 404: 405: =over 4 406: 407: =item * L<Test::Mockingbird> 408: 409: =item * L<Test::Mockingbird::Async> 410: 411: =item * L<Test::Mockingbird::TimeTravel> 412: 413: =back 414: 415: =head1 REPOSITORY 416: 417: L<https://github.com/nigelhorne/Test-Mockingbird> 418: 419: =head1 LICENCE AND COPYRIGHT 420: 421: Copyright 2026 Nigel Horne. 422: 423: Usage is subject to GPL2 licence terms. 424: 425: =cut 426: 427: 1;