lib/Test/Mockingbird/DeepMock.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 100.00%
TER3 (LCSAJ): 100.0% (5/5)
Approximate LCSAJ segments: 59

LCSAJ Legend

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.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    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;