File Coverage

lib/Test/Mockingbird/DeepMock.pm
Criterion Covered Total %
statement 88 94 93.6
branch 38 46 82.6
condition 16 24 66.6
subroutine 10 10 100.0
pod 1 1 100.0
total 153 175 87.4


line stmt bran cond sub pod time code
1             package Test::Mockingbird::DeepMock;
2              
3 4     4   538248 use strict;
  4         9  
  4         169  
4 4     4   21 use warnings;
  4         6  
  4         257  
5              
6 4     4   21 use Exporter 'import';
  4         9  
  4         149  
7 4     4   105 use Carp qw(croak);
  4         8  
  4         283  
8 4     4   23 use Test::Mockingbird ();
  4         7  
  4         190  
9 4     4   83 use Test::More ();
  4         7  
  4         6661  
10              
11             our @EXPORT_OK = qw(deep_mock);
12              
13             =head1 NAME
14              
15             Test::Mockingbird::DeepMock - Declarative, structured mocking and spying for Perl tests
16              
17             =head1 VERSION
18              
19             Version 0.06
20              
21             =cut
22              
23             our $VERSION = '0.06';
24              
25             =head1 SYNOPSIS
26              
27             use Test::Mockingbird::DeepMock qw(deep_mock);
28              
29             {
30             package MyApp;
31             sub greet { "hello" }
32             sub double { $_[1] * 2 }
33             }
34              
35             deep_mock(
36             {
37             mocks => [
38             {
39             target => 'MyApp::greet',
40             type => 'mock',
41             with => sub { "mocked" },
42             }, {
43             target => 'MyApp::double',
44             type => 'spy',
45             tag => 'double_spy',
46             },
47             ], expectations => [
48             {
49             tag => 'double_spy',
50             calls => 2,
51             },
52             ],
53             },
54             sub {
55             is MyApp::greet(), 'mocked', 'greet() was mocked';
56              
57             MyApp::double(2);
58             MyApp::double(3);
59             }
60             );
61              
62             =head1 DESCRIPTION
63              
64             C<Test::Mockingbird::DeepMock> provides a declarative, data-driven way to
65             describe mocking, spying, injection, and expectations in Perl tests.
66              
67             Instead of scattering C<mock>, C<spy>, and C<restore_all> calls throughout
68             your test code, DeepMock lets you define a complete mocking plan in a single
69             hashref, then executes your test code under that plan.
70              
71             This produces tests that are:
72              
73             =over 4
74              
75             =item * easier to read
76              
77             =item * easier to maintain
78              
79             =item * easier to extend
80              
81             =item * easier to reason about
82              
83             =back
84              
85             DeepMock is built on top of L<Test::Mockingbird>, adding structure,
86             expectations, and a clean DSL.
87              
88             =head1 WHY DEEP MOCK?
89              
90             Traditional mocking in Perl tends to be:
91              
92             =over 4
93              
94             =item * imperative
95              
96             =item * scattered across the test body
97              
98             =item * difficult to audit
99              
100             =item * easy to forget to restore
101              
102             =back
103              
104             DeepMock solves these problems by letting you declare everything up front:
105              
106             deep_mock(
107             {
108             mocks => [...],
109             expectations => [...],
110             },
111             sub { ... }
112             );
113              
114             This gives you:
115              
116             =over 4
117              
118             =item * a single place to see all mocks and spies
119              
120             =item * automatic restore of all mocks
121              
122             =item * structured expectations
123              
124             =item * reusable patterns
125              
126             =item * a clean separation between setup and test logic
127              
128             =back
129              
130             =head1 PLAN STRUCTURE
131              
132             A DeepMock plan is a hashref with the following keys:
133              
134             =head2 C<mocks>
135              
136             An arrayref of mock specifications. Each entry is a hashref:
137              
138             {
139             target => 'Package::method', # required
140             type => 'mock' | 'spy' | 'inject',
141             with => sub { ... }, # for mock/inject
142             tag => 'identifier', # for spies or scoped mocks
143             scoped => 1, # optional
144             }
145              
146             =head3 Types
147              
148             =over 4
149              
150             =item C<mock>
151              
152             Replaces the target method with the provided coderef.
153              
154             =item C<spy>
155              
156             Wraps the method and records all calls. Must have a C<tag>.
157              
158             =item C<inject>
159              
160             Injects a value or behavior into the target (delegates to C<Test::Mockingbird::inject>).
161              
162             =back
163              
164             =head2 C<expectations>
165              
166             An arrayref of expectation specifications. Each entry is a hashref:
167              
168             {
169             tag => 'double_spy', # required
170             calls => 2, # optional
171             args_like => [ # optional
172             [ qr/foo/, qr/bar/ ],
173             ],
174             }
175              
176             =head3 Expectation fields
177              
178             =over 4
179              
180             =item C<tag>
181              
182             Identifies which spy this expectation applies to.
183              
184             =item C<calls>
185              
186             Expected number of calls.
187              
188             =item C<args_eq>
189              
190             Arrayref of arrayrefs. Each inner array lists exact argument values expected
191             for a specific call. Values are compared with C<Test::More::is>.
192              
193             =item C<args_deeply>
194              
195             Arrayref of arrayrefs. Each inner array lists deep structures to compare
196             against the arguments for a specific call. Uses C<Test::Deep::cmp_deeply>.
197              
198             =item C<args_like>
199              
200             Arrayref of arrayrefs of regexes. Each inner array describes expected
201             arguments for a specific call.
202              
203             =item C<never>
204              
205             Asserts that the spy was never called.
206             Mutually exclusive with C<calls>.
207              
208             =back
209              
210             =head2 C<globals>
211              
212             Optional hashref controlling global behavior:
213              
214             globals => {
215             restore_on_scope_exit => 1, # default
216             }
217              
218             =head1 COOKBOOK
219              
220             =head2 Mocking a method
221              
222             mocks => [
223             {
224             target => 'MyApp::greet',
225             type => 'mock',
226             with => sub { "hi" },
227             },
228             ]
229              
230             =head2 Spying on a method
231              
232             mocks => [
233             {
234             target => 'MyApp::double',
235             type => 'spy',
236             tag => 'dbl',
237             },
238             ]
239              
240             =head2 Injecting a dependency
241              
242             mocks => [
243             {
244             target => 'MyApp::Config::get',
245             type => 'inject',
246             with => { debug => 1 },
247             },
248             ]
249              
250             =head2 Expecting a call count
251              
252             expectations => [
253             {
254             tag => 'dbl',
255             calls => 3,
256             },
257             ]
258              
259             =head2 Expecting argument patterns
260              
261             expectations => [
262             {
263             tag => 'dbl',
264             args_like => [
265             [ qr/^\d+$/ ], # first call
266             [ qr/^\d+$/ ], # second call
267             ],
268             },
269             ]
270              
271             =head2 Full example
272              
273             deep_mock(
274             {
275             mocks => [
276             { target => 'A::foo', type => 'mock', with => sub { 1 } },
277             { target => 'A::bar', type => 'spy', tag => 'bar' },
278             ],
279             expectations => [
280             { tag => 'bar', calls => 2 },
281             ],
282             },
283             sub {
284             A::foo();
285             A::bar(10);
286             A::bar(20);
287             }
288             );
289              
290             =head1 TROUBLESHOOTING
291              
292             =head2 "Not enough arguments for deep_mock"
293              
294             You are using the BLOCK prototype form:
295              
296             deep_mock {
297             ...
298             }, sub { ... };
299              
300             This only works if C<deep_mock> has a C<(&$)> prototype AND the first
301             argument is a real block, not a hashref.
302              
303             DeepMock uses C<($$)> to avoid Perl's block-vs-hashref ambiguity.
304              
305             Use parentheses instead:
306              
307             deep_mock(
308             { ... },
309             sub { ... }
310             );
311              
312             =head2 "Type of arg 1 must be block or sub {}"
313              
314             You are still using the BLOCK prototype form. Switch to parentheses.
315              
316             =head2 "Use of uninitialized value in multiplication"
317              
318             Your spied method is being called with no arguments during spy installation.
319             Make your method robust:
320              
321             sub double { ($_[1] // 0) * 2 }
322              
323             =head2 My mocks aren't restored
324              
325             Ensure you didn't disable automatic restore:
326              
327             globals => { restore_on_scope_exit => 0 }
328              
329             =head2 Nested deep_mock scopes are not supported
330              
331             DeepMock installs mocks using L<Test::Mockingbird>, which provides only
332             global restore semantics via C<restore_all>. Because Test::Mockingbird
333             does not expose a per-method restore API, DeepMock cannot safely restore
334             only the mocks installed in an inner scope.
335              
336             As a result, nested calls like:
337              
338             deep_mock { ... } sub {
339             deep_mock { ... } sub {
340             ...
341             };
342             };
343              
344             will cause the inner restore to remove the outer mocks as well.
345              
346             DeepMock therefore does not support nested mocking scopes.
347              
348             =head2 deep_mock
349              
350             Run a block of code with a set of mocks and expectations applied.
351              
352             =head3 Purpose
353              
354             Provides a declarative wrapper around Test::Mockingbird that installs mocks,
355             runs a code block, and then validates expectations such as call counts and
356             argument patterns.
357              
358             =head3 Arguments
359              
360             =over 4
361              
362             =item * C<$plan> - HashRef
363              
364             A plan describing mocks and expectations. Keys:
365              
366             =over 4
367              
368             =item * C<mocks> - ArrayRef of mock specifications
369              
370             Each specification includes:
371              
372             - C<target> - "Package::method"
373             - C<type> - "mock" or "spy"
374             - C<with> - coderef for mock behavior (mock only)
375             - C<tag> - identifier for later expectations
376              
377             =item * C<expectations> - ArrayRef of expectation specifications
378              
379             Each specification includes:
380              
381             - C<tag> - spy tag to validate
382             - C<calls> - expected call count
383             - C<args_like> - regex argument matching
384             - C<args_eq> - exact argument matching
385             - C<args_deeply> - deep structural matching
386             - C<never> - assert spy was not called
387              
388             =back
389              
390             =item * C<$code> - CodeRef
391              
392             The block to execute while mocks are active.
393              
394             =back
395              
396             =head3 Returns
397              
398             Nothing. Dies on expectation failure.
399              
400             =head3 Side Effects
401              
402             Temporarily installs mocks and spies into the target packages. All mocks are
403             removed after the code block completes.
404              
405             =head3 Notes
406              
407             This routine does not support nested deep_mock scopes. All mocks are global
408             until restored.
409              
410             =head3 API
411              
412             =head4 Input (Params::Validate::Strict)
413              
414             {
415             mocks => ArrayRef,
416             expectations => ArrayRef,
417             },
418             CodeRef
419              
420             =head4 Output (Returns::Set)
421              
422             returns: undef
423              
424             =cut
425              
426             sub deep_mock
427             {
428 13     13 1 608295 my ($plan, $code) = @_;
429              
430 13 50       56 croak 'deep_mock expects a HASHREF plan' unless ref $plan eq 'HASH';
431              
432 13         23 my %handles;
433              
434             # Install mocks for this scope and capture restore handles
435 13   50     62 my @installed = _install_mocks($plan->{mocks} || [], \%handles);
436              
437 12         23 my ($wantarray, @ret, $ret, $err);
438 12         20 $wantarray = wantarray;
439              
440 12 50       40 if ($wantarray) {
    50          
441 0         0 @ret = eval { $code->() };
  0         0  
442 0         0 $err = $@;
443             } elsif (defined $wantarray) {
444 0         0 $ret = eval { $code->() };
  0         0  
445 0         0 $err = $@;
446             } else {
447 12         22 eval { $code->() };
  12         35  
448 12         3601 $err = $@;
449             }
450              
451 12   100     166 _run_expectations($plan->{expectations} || [], \%handles);
452              
453             my $auto_restore = !exists $plan->{globals}{restore_on_scope_exit}
454 11   66     396 || $plan->{globals}{restore_on_scope_exit};
455              
456 11 100       68 Test::Mockingbird::restore_all() if $auto_restore;
457              
458 11 50       32 croak $err if $err;
459              
460 11 50       86 return $wantarray ? @ret : $ret;
461             }
462              
463             # ----------------------------------------------------------------------
464             # NAME
465             # _install_mocks
466             #
467             # PURPOSE
468             # Install mocks and spies as described in the plan. Creates
469             # Test::Mockingbird handles and stores them in the provided hash.
470             #
471             # ENTRY CRITERIA
472             # - $mocks: ArrayRef of mock specifications
473             # - $handles: HashRef for storing created mock and spy handles
474             # - Each mock specification must include:
475             # target => "Package::method"
476             # type => "mock" or "spy"
477             # tag => identifier for expectations
478             # with => coderef (required for type "mock")
479             #
480             # EXIT STATUS
481             # - Returns a list of guard objects for later cleanup
482             # - Croaks on invalid specifications
483             #
484             # SIDE EFFECTS
485             # - Modifies symbol tables of target packages to install mocks/spies
486             # - Populates $handles with created spy and mock handles
487             #
488             # NOTES
489             # - Internal helper, not part of the public API
490             # - Does not support nested mocking scopes
491             # ----------------------------------------------------------------------
492             sub _install_mocks {
493 19     19   29373 my ($mocks, $handles) = @_;
494              
495 19         34 my @installed; # list of [$pkg, $method] for this scope
496              
497 19         47 for my $m (@$mocks) {
498 22 100       97 my $target = $m->{target} or croak 'mock entry missing target';
499              
500 21         65 my ($pkg, $method) = _normalize_target($target);
501              
502 21   50     70 my $type = $m->{type} || 'mock';
503              
504 21 100       87 if ($type eq 'mock') {
    100          
    100          
505             # --------------------------------------------------------------
506             # MOCK
507             # --------------------------------------------------------------
508 7 100 66     89 croak "mock type requires 'with' coderef" unless defined $m->{with} && ref $m->{with} eq 'CODE';
509              
510 6         42 Test::Mockingbird::mock($pkg, $method, $m->{with});
511              
512 6         321 push @installed, [ $pkg, $method ];
513              
514 6 100       51 $handles->{ $m->{tag} }{guard} = 1 if $m->{tag};
515             } elsif ($type eq 'spy') {
516             # --------------------------------------------------------------
517             # SPY
518             # --------------------------------------------------------------
519 11         101 my $spy = Test::Mockingbird::spy($pkg, $method);
520              
521 11         38 push @installed, [ $pkg, $method ];
522              
523 11 50       91 $handles->{ $m->{tag} }{spy} = $spy if $m->{tag};
524             } elsif ($type eq 'inject') {
525             # --------------------------------------------------------------
526             # INJECT
527             # --------------------------------------------------------------
528 1         7 Test::Mockingbird::inject($pkg, $method, $m->{with});
529              
530 1         44 push @installed, [ $pkg, $method ];
531              
532 1 50       4 $handles->{ $m->{tag} }{inject} = 1 if $m->{tag};
533             } else {
534 2         45 croak "Unknown mock type '$type' for target '$target'";
535             }
536             }
537              
538 15         68 return @installed;
539             }
540              
541             # ----------------------------------------------------------------------
542             # NAME
543             # _run_expectations
544             #
545             # PURPOSE
546             # Validate expectations against recorded spy calls. Supports call
547             # counts, regex matching, exact matching, deep matching, and "never".
548             #
549             # ENTRY CRITERIA
550             # - $expectations: ArrayRef of expectation specifications
551             # - $handles: HashRef containing spy handles keyed by tag
552             # - Each expectation may include:
553             # tag => spy tag to validate
554             # calls => expected call count
555             # args_like => regex argument matching
556             # args_eq => exact argument matching
557             # args_deeply => deep structural matching
558             # never => assert spy was not called
559             #
560             # EXIT STATUS
561             # - Returns nothing
562             # - Emits TAP output via Test::More and Test::Deep
563             # - Croaks if a required spy handle is missing
564             #
565             # SIDE EFFECTS
566             # - Produces test output
567             #
568             # NOTES
569             # - Internal helper, not part of the public API
570             # - Caller must ensure all tags refer to installed spies
571             # ----------------------------------------------------------------------
572              
573             sub _run_expectations {
574 23     23   10992 my ($exps, $handles) = @_;
575              
576 23         67 for my $exp (@$exps) {
577             my $tag = $exp->{tag}
578 19 100       139 or croak 'expectation missing tag';
579              
580             my $spy = $handles->{$tag}{spy}
581 17 100       86 or croak "no spy handle for tag '$tag'";
582              
583 16         43 my @calls = $spy->(); # each call: [ full_method, @args ]
584              
585             # --------------------------------------------------------------
586             # CALL COUNT
587             # --------------------------------------------------------------
588 16 100       93 if (defined $exp->{calls}) {
589             Test::More::is(
590             scalar(@calls),
591             $exp->{calls},
592 7         46 "DeepMock: calls for $tag"
593             );
594             }
595              
596             # --------------------------------------------------------------
597             # args_like (regex matching)
598             # --------------------------------------------------------------
599 16 100       4124 if (my $args_like = $exp->{args_like}) {
600 3         17 for my $i (0 .. $#$args_like) {
601 5         1836 my $patterns = $args_like->[$i];
602 5   50     21 my $call = $calls[$i] || [];
603 5         21 my @args = @$call[1 .. $#$call];
604              
605 5         18 for my $j (0 .. $#$patterns) {
606 5         27 my $re = $patterns->[$j];
607 5 50       47 Test::More::like(
608             $args[$j],
609             ref $re ? $re : qr/$re/,
610             "DeepMock: arg $j for call $i of $tag (args_like)"
611             );
612             }
613             }
614             }
615              
616             # --------------------------------------------------------------
617             # args_eq (exact string/number matching)
618             # --------------------------------------------------------------
619 16 100       2049 if (my $args_eq = $exp->{args_eq}) {
620 2         9 for my $i (0 .. $#$args_eq) {
621 4         1252 my $expected = $args_eq->[$i];
622 4   50     14 my $call = $calls[$i] || [];
623 4         16 my @args = @$call[1 .. $#$call];
624              
625 4         12 for my $j (0 .. $#$expected) {
626 4         26 Test::More::is(
627             $args[$j],
628             $expected->[$j],
629             "DeepMock: arg $j for call $i of $tag (args_eq)"
630             );
631             }
632             }
633             }
634              
635             # --------------------------------------------------------------
636             # args_deeply (structural deep comparison)
637             # --------------------------------------------------------------
638 16 100       1233 if (my $args_deeply = $exp->{args_deeply}) {
639 3         31 require Test::Deep;
640              
641 3         13 for my $i (0 .. $#$args_deeply) {
642 6         31479 my $expected = $args_deeply->[$i];
643 6   50     32 my $call = $calls[$i] || [];
644 6         27 my @args = @$call[1 .. $#$call];
645              
646 6         21 for my $j (0 .. $#$expected) {
647 5         41 Test::Deep::cmp_deeply(
648             $args[$j],
649             $expected->[$j],
650             "DeepMock: arg $j for call $i of $tag (args_deeply)"
651             );
652             }
653             }
654             }
655             # --------------------------------------------------------------
656             # never (assert spy was never called)
657             # --------------------------------------------------------------
658 16 100       25309 if ($exp->{never}) {
659 3         20 Test::More::is(
660             scalar(@calls),
661             0,
662             "DeepMock: $tag was never called"
663             );
664             }
665             }
666             }
667              
668             # ----------------------------------------------------------------------
669             # NAME
670             # _normalize_target
671             #
672             # PURPOSE
673             # Convert a target specification into a canonical (package, method)
674             # pair. Accepts either "Package::method" or separate arguments.
675             #
676             # ENTRY CRITERIA
677             # - $pkg_or_full: String, either "Package::method" or a package name
678             # - $maybe_method: Optional string, method name if provided separately
679             #
680             # EXIT STATUS
681             # - Returns a two element list: ($package, $method)
682             # - Croaks if the target cannot be parsed
683             #
684             # SIDE EFFECTS
685             # - None
686             #
687             # NOTES
688             # - Internal helper, not part of the public API
689             # - Caller must ensure the returned package and method exist or will
690             # be created by mocking
691             # ----------------------------------------------------------------------
692              
693             sub _normalize_target {
694             # ENTRY: $arg1 may be 'Package::method' or a package name
695             # $arg2 may be a method name if provided separately
696 27     27   15510 my ($arg1, $arg2) = @_;
697              
698             # If only one arg and it looks like Package::method, split it
699 27 100 66     338 if (defined $arg1 && !defined $arg2 && $arg1 =~ /^(.*)::([^:]+)$/) {
      100        
700 24         160 return ($1, $2);
701             }
702              
703             # Otherwise, return as-is (package, method)
704 3         18 return ($arg1, $arg2);
705              
706             # EXIT: always returns ($pkg, $method)
707             }
708              
709             =head1 SUPPORT
710              
711             This module is provided as-is without any warranty.
712              
713             Please report any bugs or feature requests to C<bug-test-mockingbird at rt.cpan.org>,
714             or through the web interface at
715             L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Test-Mockingbird>.
716             I will be notified, and then you'll
717             automatically be notified of progress on your bug as I make changes.
718              
719             You can find documentation for this module with the perldoc command.
720              
721             perldoc Test::Mockingbird::DeepMock
722              
723             =cut
724              
725             1;