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: =encoding utf-8 15: 16: =head1 NAME 17: 18: Test::Mockingbird::DeepMock - Declarative structured mocking and spying for Perl tests 19: 20: =head1 VERSION 21: 22: Version 0.14 23: 24: =cut 25: 26: our $VERSION = '0.14'; 27: 28: =head1 SYNOPSIS 29: 30: use Test::Mockingbird::DeepMock qw(deep_mock); 31: 32: deep_mock( 33: { 34: mocks => [ 35: { target => 'MyApp::greet', type => 'mock', with => sub { 'hi' } }, 36: { target => 'MyApp::double', type => 'spy', tag => 'double_spy' }, 37: ], 38: expectations => [ 39: { tag => 'double_spy', calls => 2 }, 40: ], 41: }, 42: sub { 43: is MyApp::greet(), 'hi', 'greet mocked'; 44: MyApp::double(2); 45: MyApp::double(3); 46: } 47: ); 48: 49: =head1 DESCRIPTION 50: 51: C<Test::Mockingbird::DeepMock> provides a declarative, data-driven way to 52: describe mocking, spying, injection, and expectations in Perl tests. 53: 54: Instead of scattering C<mock>, C<spy>, and C<restore_all> calls throughout 55: your test code, DeepMock lets you define a complete mocking plan in a single 56: hashref, then executes your test code under that plan. 57: 58: =head1 LIMITATIONS 59: 60: =over 4 61: 62: =item Nested deep_mock scopes are not supported 63: 64: C<deep_mock> calls C<restore_all()> on exit, which removes every active mock. 65: A nested C<deep_mock> call will cause the inner exit to also tear down the 66: outer mocks. 67: 68: =item inject type in mocks list 69: 70: The C<inject> mock type delegates to C<Test::Mockingbird::inject()>. Because 71: C<inject()> builds a sub that returns the C<with> value verbatim, passing a 72: coderef via C<with> stores the coderef itself; it is not called on each 73: access. To inject a factory, wrap it explicitly. 74: 75: =back 76: 77: =head1 PLAN STRUCTURE 78: 79: =head2 C<mocks> 80: 81: ArrayRef of mock specs. Each entry: 82: 83: { 84: target => 'Package::method', 85: type => 'mock' | 'spy' | 'inject', 86: with => sub { ... }, 87: tag => 'identifier', 88: } 89: 90: =head2 C<expectations> 91: 92: ArrayRef of expectation specs. Each entry: 93: 94: { 95: tag => 'spy_tag', 96: calls => $n, 97: args_like => [ [qr/pat1/, qr/pat2/], ... ], 98: args_eq => [ ['exact', 'args'], ... ], 99: args_deeply => [ [$struct], ... ], 100: never => 1, 101: order => [ 'A::m1', 'B::m2' ], 102: } 103: 104: The C<order> key is processed after all per-spy expectations and does not 105: require a C<tag>. 106: 107: =head2 C<globals> 108: 109: Optional: 110: 111: globals => { restore_on_scope_exit => 1 } 112: 113: =head2 C<time> 114: 115: Optional time-travel plan applied before mocks are installed: 116: 117: time => { 118: freeze => '2025-01-01T00:00:00Z', 119: travel => '2025-01-03T00:00:00Z', 120: advance => [ 2 => 'minutes' ], 121: rewind => [ 1 => 'hour' ], 122: } 123: 124: =head1 TROUBLESHOOTING 125: 126: =head2 "Not enough arguments for deep_mock" 127: 128: DeepMock uses C<($$)> prototype. Use C<deep_mock( {...}, sub { ... } )>. 129: 130: =head2 My mocks are not restored 131: 132: Check C<globals => { restore_on_scope_exit => 0 }> has not been set. 133: 134: =head1 METHODS 135: 136: =head2 deep_mock 137: 138: Run a code block with a set of mocks and expectations applied. 139: 140: =head3 API SPECIFICATION 141: 142: =head4 Input (Params::Validate::Strict schema) 143: 144: $plan -- HashRef with keys: mocks, expectations, globals, time 145: $code -- CodeRef 146: 147: =head4 Output (Returns::Set schema) 148: 149: returns: whatever $code returns (context-sensitive) 150: 151: =head3 MESSAGES 152: 153: "deep_mock expects a HASHREF plan" -- first arg is not a hashref 154: 155: =head3 FORMAL SPECIFICATION 156: 157: deep_mock â 158: â plan : HashRef; code : CodeRef ⢠159: pre ref(plan) = 'HASH' 160: post apply_time(plan.time) 161: â§ install_mocks(plan.mocks) 162: â§ result = code() 163: â§ check_expectations(plan.expectations) 164: â§ restore_all() 165: 166: =head3 PSEUDOCODE 167: 168: validate plan is HASH 169: apply time plan if present 170: install each mock/spy/inject from plan.mocks, building %handles 171: capture wantarray context 172: eval { run $code } 173: run expectations against %handles 174: restore all mocks (unless restore_on_scope_exit => 0) 175: restore time state 176: re-croak any exception from $code 177: return $code's result in correct context 178: 179: =cut 180: 181: sub deep_mock { 182: my ($plan, $code) = @_; 183: 184: croak 'deep_mock expects a HASHREF plan' unless ref $plan eq 'HASH'; 185: 186: my %handles; 187: 188: _apply_time_plan($plan->{time}); 189: 190: _install_mocks($plan->{mocks} // [], \%handles); 191: 192: # Preserve the caller's context through the eval block 193: my $ctx = wantarray; 194: my (@list_ret, $scalar_ret, $err); 195: 196: { 197: local $@; 198: if ($ctx) {Mutants (Total: 1, Killed: 1, Survived: 0)
199: @list_ret = eval { $code->() }; 200: } elsif (defined $ctx) { 201: $scalar_ret = eval { $code->() }; 202: } else { 203: eval { $code->() }; 204: } 205: $err = $@; 206: } 207: 208: _run_expectations($plan->{expectations} // [], \%handles); 209: 210: my $auto_restore = !exists $plan->{globals}{restore_on_scope_exit} 211: || $plan->{globals}{restore_on_scope_exit}; 212: 213: Test::Mockingbird::restore_all() if $auto_restore; 214: Test::Mockingbird::TimeTravel::restore_all(); 215: 216: croak $err if $err; 217: 218: return $ctx ? @list_ret : $scalar_ret;
Mutants (Total: 2, Killed: 2, Survived: 0)
219: } 220: 221: # _normalize_target -- Private 222: # 223: # Purpose: Delegate to Test::Mockingbird::_parse_target. Kept here as 224: # a thin wrapper so that white-box tests calling 225: # Test::Mockingbird::DeepMock::_normalize_target() continue to 226: # work without requiring updates to those tests. 227: # Entry: $target -- Str, 'Pkg::method' or ('Pkg', 'method') 228: # Exit: ($package, $method) 229: # Side effects: none 230: sub _normalize_target { 231: return Test::Mockingbird::_parse_target(@_);
Mutants (Total: 2, Killed: 2, Survived: 0)
232: } 233: 234: # _install_mocks -- Private 235: # 236: # Purpose: Install each mock/spy/inject described in the plan and 237: # record spy handles in %handles for later expectation checks. 238: # Entry: $mocks -- ArrayRef of mock spec hashrefs 239: # $handles -- HashRef to populate with spy/guard handles 240: # Exit: undef 241: # Side effects: Modifies symbol tables of target packages. 242: # Populates $handles. 243: sub _install_mocks { ●244 → 248 → 278 244: my ($mocks, $handles) = @_; 245: 246: my @installed; 247: 248: for my $m (@$mocks) { 249: my $target = $m->{target} 250: or croak 'mock entry missing target'; 251: 252: my ($pkg, $method) = _normalize_target($target); 253: my $full = "${pkg}::${method}"; 254: my $type = $m->{type} // 'mock'; 255: 256: if ($type eq 'mock') {
Mutants (Total: 1, Killed: 1, Survived: 0)
257: croak "mock type requires 'with' coderef" 258: unless defined $m->{with} && ref $m->{with} eq 'CODE'; 259: 260: Test::Mockingbird::mock($pkg, $method, $m->{with}); 261: $handles->{ $m->{tag} }{guard} = 1 if $m->{tag}; 262: 263: } elsif ($type eq 'spy') { 264: my $spy = Test::Mockingbird::spy($pkg, $method); 265: $handles->{ $m->{tag} }{spy} = $spy if $m->{tag}; 266: 267: } elsif ($type eq 'inject') { 268: Test::Mockingbird::inject($pkg, $method, $m->{with}); 269: $handles->{ $m->{tag} }{inject} = 1 if $m->{tag}; 270: 271: } else { 272: croak "Unknown mock type '$type' for target '$target'"; 273: } 274: 275: push @installed, $full; 276: } 277: 278: return @installed;
Mutants (Total: 2, Killed: 2, Survived: 0)
279: } 280: 281: # _run_expectations -- Private 282: # 283: # Purpose: Validate recorded spy calls against the plan's expectations. 284: # Handles: calls, args_like, args_eq, args_deeply, never, order. 285: # Entry: $exps -- ArrayRef of expectation hashrefs 286: # $handles -- HashRef populated by _install_mocks 287: # Exit: undef 288: # Side effects: Emits TAP via Test::More. Croaks on missing tag or spy. 289: sub _run_expectations { ●290 → 292 → 359 290: my ($exps, $handles) = @_; 291: 292: for my $exp (@$exps) { 293: # order-only entries (no tag) are handled in the second pass below 294: next if exists $exp->{order} && !exists $exp->{tag}; 295: 296: my $tag = $exp->{tag} 297: or croak 'expectation missing tag'; 298: 299: my $spy = $handles->{$tag}{spy} 300: or croak "no spy handle for tag '$tag'"; 301: 302: my @calls = $spy->(); # [ full_method, @args ] 303: 304: # ---- call count ----------------------------------------------- 305: if (defined $exp->{calls}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
306: Test::More::is(scalar @calls, $exp->{calls}, 307: "DeepMock: calls for $tag"); 308: } 309: 310: # ---- args_like (regex matching) -------------------------------- 311: if (my $args_like = $exp->{args_like}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
312: for my $i (0 .. $#$args_like) { 313: my $patterns = $args_like->[$i]; 314: my $call = $calls[$i] // []; 315: my @args = @{$call}[1 .. $#$call]; 316: for my $j (0 .. $#$patterns) { 317: my $re = $patterns->[$j]; 318: Test::More::like($args[$j], 319: ref $re ? $re : qr/$re/, 320: "DeepMock: arg $j call $i of $tag (args_like)"); 321: } 322: } 323: } 324: 325: # ---- args_eq (exact matching) ---------------------------------- 326: if (my $args_eq = $exp->{args_eq}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
327: for my $i (0 .. $#$args_eq) { 328: my $expected = $args_eq->[$i]; 329: my $call = $calls[$i] // []; 330: my @args = @{$call}[1 .. $#$call]; 331: for my $j (0 .. $#$expected) { 332: Test::More::is($args[$j], $expected->[$j], 333: "DeepMock: arg $j call $i of $tag (args_eq)"); 334: } 335: } 336: } 337: 338: # ---- args_deeply (structural) ---------------------------------- 339: if (my $args_deeply = $exp->{args_deeply}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
340: require Test::Deep; 341: for my $i (0 .. $#$args_deeply) { 342: my $expected = $args_deeply->[$i]; 343: my $call = $calls[$i] // []; 344: my @args = @{$call}[1 .. $#$call]; 345: for my $j (0 .. $#$expected) { 346: Test::Deep::cmp_deeply($args[$j], $expected->[$j], 347: "DeepMock: arg $j call $i of $tag (args_deeply)"); 348: } 349: } 350: } 351: 352: # ---- never (assert zero calls) --------------------------------- 353: if ($exp->{never}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
354: Test::More::is(scalar @calls, 0, "DeepMock: $tag was never called"); 355: } 356: } 357: 358: # Second pass: process cross-method order expectations (no tag required) ●359 → 359 → 364 359: for my $exp (@$exps) { 360: next unless exists $exp->{order}; 361: Test::Mockingbird::assert_call_order(@{ $exp->{order} }); 362: } 363: 364: return; 365: } 366: 367: # _apply_time_plan -- Private 368: # 369: # Purpose: Apply a time-travel plan from the deep_mock spec before 370: # installing mocks. All keys are optional. 371: # Entry: $time -- HashRef or undef 372: # Exit: undef 373: # Side effects: Activates Test::Mockingbird::TimeTravel if any key is set. 374: sub _apply_time_plan { ●375 → 384 → 389 375: my $time = $_[0]; 376: return unless $time && ref $time eq 'HASH'; 377: 378: Test::Mockingbird::TimeTravel::freeze_time($time->{freeze}) 379: if exists $time->{freeze}; 380: 381: Test::Mockingbird::TimeTravel::travel_to($time->{travel}) 382: if exists $time->{travel}; 383: 384: if (exists $time->{advance}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
385: my ($amount, $unit) = @{ $time->{advance} }; 386: Test::Mockingbird::TimeTravel::advance_time($amount, $unit); 387: } 388: ●389 → 389 → 394 389: if (exists $time->{rewind}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
390: my ($amount, $unit) = @{ $time->{rewind} }; 391: Test::Mockingbird::TimeTravel::rewind_time($amount, $unit); 392: } 393: 394: return; 395: } 396: 397: =head1 SUPPORT 398: 399: Please report bugs at L<https://github.com/nigelhorne/Test-Mockingbird/issues>. 400: 401: =head1 AUTHOR 402: 403: Nigel Horne, C<< <njh at nigelhorne.com> >> 404: 405: =head1 SEE ALSO 406: 407: =over 4 408: 409: =item * L<Test::Mockingbird> 410: 411: =item * L<Test::Mockingbird::Async> 412: 413: =item * L<Test::Mockingbird::TimeTravel> 414: 415: =back 416: 417: =head1 REPOSITORY 418: 419: L<https://github.com/nigelhorne/Test-Mockingbird> 420: 421: =head1 LICENCE AND COPYRIGHT 422: 423: Copyright 2026 Nigel Horne. 424: 425: Usage is subject to GPL2 licence terms. 426: 427: =cut 428: 429: 1;