File Coverage

File:blib/lib/Test/Mockingbird/DeepMock.pm
Coverage:100.0%

linestmtbrancondsubtimecode
1package 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
12our @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
24our $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
179sub 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
228sub _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.
241sub _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.
287sub _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.
372sub _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
4271;