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: =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;