File Coverage

File:blib/lib/Test/Mockingbird/Async.pm
Coverage:99.1%

linestmtbrancondsubtimecode
1package Test::Mockingbird::Async;
2
3
6
6
6
25705
5
82
use strict;
4
6
6
6
10
2
133
use warnings;
5
6
6
6
6
10
4
119
use Carp qw(croak);
7
6
6
6
9
5
62
use Exporter 'import';
8
6
6
6
11
5
1809
use Test::Mockingbird ();
9
10our @EXPORT_OK = qw(
11        mock_future_return
12        mock_future_fail
13        mock_future_sequence
14        mock_future_once
15        async_spy
16);
17
18=encoding utf-8
19
20 - 28
=head1 NAME

Test::Mockingbird::Async - Future-based async mocking for Test::Mockingbird

=head1 VERSION

Version 0.14

=cut
29
30our $VERSION = '0.14';
31
32 - 121
=head1 SYNOPSIS

    use Test::Mockingbird;
    use Test::Mockingbird::Async qw(
        mock_future_return
        mock_future_fail
        mock_future_sequence
        mock_future_once
        async_spy
    );

    # Mock a method to return a pre-resolved Future
    mock_future_return 'My::DB::fetch' => { id => 1 };
    my $result = My::DB::fetch()->get;   # { id => 1 }

    # Mock a method to return a pre-failed Future
    mock_future_fail 'My::DB::fetch' => 'not found';
    my ($msg) = My::DB::fetch()->failure;   # 'not found'

    # Return different values over successive calls
    mock_future_sequence 'My::DB::fetch' => (10, 20, 30);
    My::DB::fetch()->get;   # 10
    My::DB::fetch()->get;   # 20
    My::DB::fetch()->get;   # 30 (repeated from here)

    # Return a Future exactly once, then restore the previous implementation
    mock_future_once 'My::DB::ping' => 'ok';
    My::DB::ping()->get;   # 'ok', then original restored

    # Spy on a method that returns a Future
    my $spy = async_spy 'My::DB::fetch';
    My::DB::fetch('key');
    my @calls = $spy->();
    my $call = $calls[0];
    # $call->{args}   is [ 'My::DB::fetch', 'key' ]
    # $call->{future} is the Future returned by the original

    restore_all();

=head1 DESCRIPTION

C<Test::Mockingbird::Async> extends L<Test::Mockingbird> with helpers for
testing code that uses L<Future>-based asynchronous APIs.

All mocks and spies installed by this module use the same underlying stack
as the core engine. C<restore_all()>, C<unmock()>, and C<diagnose_mocks()>
work identically to their core counterparts. Every installed layer is
recorded in C<diagnose_mocks()> with a type of C<mock_future_return>,
C<mock_future_fail>, C<mock_future_sequence>, C<mock_future_once>, or
C<async_spy>.

C<async_spy> also writes to the call-order log, so
C<assert_call_order()> works across a mix of plain spies and async spies.

=head2 Dependency

This module requires the L<Future> distribution (available from CPAN).
It is not a mandatory dependency of L<Test::Mockingbird> itself; the
module loads C<Future> on first use and croaks with a helpful message if
it is not installed.

=head1 METHODS

=head2 mock_future_return

Mock a method so that it always returns a pre-resolved C<Future>.

    mock_future_return 'My::DB::fetch' => $value;
    mock_future_return 'My::DB::fetch' => ($val1, $val2);   # multi-value

The method is replaced with a stub that returns C<Future->done(@values)>.
Restore with C<restore_all()> or C<unmock()>.

=head3 API SPECIFICATION

=head4 Input

    target -- Str, 'Pkg::method'
    values -- List[Any], zero or more; passed to Future->done

=head4 Output

    returns: undef

=head3 MESSAGES

  "mock_future_return requires a target" -- target undef
  "Test::Mockingbird::Async requires the Future module." -- Future missing

=cut
122
123sub mock_future_return {
124
15
86514
        my ($target, @values) = @_;
125
126
15
31
        croak 'mock_future_return requires a target' unless defined $target;
127
128
13
19
        _require_future();
129
130
13
45
        local $Test::Mockingbird::TYPE = 'mock_future_return';
131
13
11
26
640
        Test::Mockingbird::mock($target, sub { Future->done(@values) });
132
133
13
12
        return;
134}
135
136 - 164
=head2 mock_future_fail

Mock a method so that it always returns a pre-failed C<Future>.

    mock_future_fail 'My::DB::fetch' => 'not found';
    mock_future_fail 'My::DB::fetch' => ('db error', 'db', { code => 500 });

The method is replaced with a stub that returns
C<Future->fail($message, @details)>. The caller receives a rejected Future;
no exception is thrown at the call site.

=head3 API SPECIFICATION

=head4 Input

    target  -- Str, 'Pkg::method'
    message -- Str, the failure message
    details -- List[Any], optional; further arguments to Future->fail

=head4 Output

    returns: undef

=head3 MESSAGES

  "mock_future_fail requires a target and a failure message" -- either undef
  "Test::Mockingbird::Async requires the Future module." -- Future missing

=cut
165
166sub mock_future_fail {
167
11
9581
        my ($target, $message, @details) = @_;
168
169
11
40
        croak 'mock_future_fail requires a target and a failure message'
170                unless defined $target && defined $message;
171
172
7
8
        _require_future();
173
174
7
6
        local $Test::Mockingbird::TYPE = 'mock_future_fail';
175
7
5
15
53
        Test::Mockingbird::mock($target, sub { Future->fail($message, @details) });
176
177
7
7
        return;
178}
179
180 - 216
=head2 mock_future_sequence

Mock a method so that it returns a sequence of C<Future> values over
successive calls. When the sequence is exhausted, the last item is repeated.

    mock_future_sequence 'My::DB::fetch' => (10, 20, 30);
    My::DB::fetch()->get;   # 10
    My::DB::fetch()->get;   # 20
    My::DB::fetch()->get;   # 30 (repeats)

Each item in the sequence may be either a plain value (wrapped automatically
in C<Future->done>) or a pre-built C<Future> object (passed through as-is,
allowing a mix of resolved and failed Futures in the sequence):

    mock_future_sequence 'My::DB::fetch' =>
        42,
        Future->fail('oops');

=head3 API SPECIFICATION

=head4 Input

    target -- Str, 'Pkg::method'
    items  -- List[Any|Future], one or more; plain values are wrapped in
              Future->done, Future objects are returned as they are

=head4 Output

    returns: undef

=head3 MESSAGES

  "mock_future_sequence requires a target and at least one item" -- target
      undef or no items
  "Test::Mockingbird::Async requires the Future module." -- Future missing

=cut
217
218sub mock_future_sequence {
219
16
97748
        my ($target, @items) = @_;
220
221
16
55
        croak 'mock_future_sequence requires a target and at least one item'
222                unless defined $target && @items;
223
224
13
17
        _require_future();
225
226
13
15
        my @queue = @items;
227
228
13
11
        local $Test::Mockingbird::TYPE = 'mock_future_sequence';
229        Test::Mockingbird::mock($target, sub {
230
30
4498
                my $item = @queue == 1 ? $queue[0] : shift @queue;
231                # Pass pre-built Futures through unchanged; wrap plain values
232
30
83
                return (ref $item && $item->isa('Future')) ? $item : Future->done($item);
233
13
35
        });
234
235
13
18
        return;
236}
237
238 - 268
=head2 mock_future_once

Install a mock that returns a pre-resolved C<Future> exactly once. After the
first call the previous implementation is automatically restored.

    mock_future_return 'My::DB::ping' => 'baseline';
    mock_future_once   'My::DB::ping' => 'temporary';

    My::DB::ping()->get;   # 'temporary'
    My::DB::ping()->get;   # 'baseline' (previous mock restored)

This is useful for simulating transient failures, one-time responses, or
state transitions in async code.

=head3 API SPECIFICATION

=head4 Input

    target -- Str, 'Pkg::method'
    values -- List[Any], zero or more; passed to Future->done

=head4 Output

    returns: undef

=head3 MESSAGES

  "mock_future_once requires a target" -- target undef
  "Test::Mockingbird::Async requires the Future module." -- Future missing

=cut
269
270sub mock_future_once {
271
11
10264
        my ($target, @values) = @_;
272
273
11
22
        croak 'mock_future_once requires a target' unless defined $target;
274
275
9
12
        _require_future();
276
277
9
16
        my ($package, $method) = Test::Mockingbird::_parse_target($target);
278
279        my $wrapper = sub {
280
7
17
                my $future = Future->done(@values);
281
7
60
                Test::Mockingbird::unmock($package, $method);
282
7
17
                return $future;
283
9
16
        };
284
285
9
9
        local $Test::Mockingbird::TYPE = 'mock_future_once';
286
9
13
        Test::Mockingbird::mock($target, $wrapper);
287
288
9
8
        return;
289}
290
291 - 336
=head2 async_spy

Wrap a method so that every call is recorded along with the C<Future> it
returned. The original method is still called and its return value is
passed back to the caller unchanged.

    my $spy = async_spy 'My::DB::fetch';
    My::DB->fetch(id => 1);

    my @calls = $spy->();

    my $call = $calls[0];
    # $call->{args}   is [ 'My::DB::fetch', $invocant, id => 1 ]
    # $call->{future} is whatever Future the original returned

    my $result = $call->{future}->get;   # inspect resolved value

Unlike the plain C<spy()> coderef (which returns arrayrefs), C<async_spy>
returns hashrefs so the C<future> field is unambiguous.

C<async_spy> writes to the call-order log, so C<assert_call_order()> works
across a mix of plain and async spies.

=head3 Limitations

C<async_spy> assumes the spied method returns a C<Future>. If the method
returns a plain value the C<future> field in each call record will hold that
value rather than a C<Future>, and calling C<< ->get >> on it will fail.

=head3 API SPECIFICATION

=head4 Input

    target -- Str, 'Pkg::method' or ('Pkg', 'method')

=head4 Output

    returns: CodeRef; yields the list of call records, each
             { args => [ 'Pkg::method', @args ], future => $returned }

=head3 MESSAGES

  "Package and method are required for async_spy" -- target missing or incomplete
  "Test::Mockingbird::Async requires the Future module." -- Future missing

=cut
337
338sub async_spy {
339
18
14971
        _require_future();
340
341
18
23
        my ($package, $method) = Test::Mockingbird::_parse_target(@_);
342
343
18
21
        croak 'Package and method are required for async_spy'
344                unless Test::Mockingbird::_is_name($package)
345                        && Test::Mockingbird::_is_name($method);
346
347
15
14
        my $full_method = "${package}::${method}";
348
349        # Resolve what the wrapper delegates to (the parent's implementation
350        # for an inherited method).  mock() separately saves the CODE slot for
351        # stack bookkeeping.
352
15
17
        my $orig = Test::Mockingbird::_call_through($package, $method);
353
354
15
11
        my @calls;
355
356        my $wrapper = sub {
357
16
490
                my @call_args = @_;
358
16
15
                my $future    = $orig->(@call_args);
359
16
196
                push @calls, { args => [$full_method, @call_args], future => $future };
360
16
22
                Test::Mockingbird::_record_call($full_method);
361
16
17
                return $future;
362
15
22
        };
363
364
15
11
        local $Test::Mockingbird::TYPE = 'async_spy';
365
15
19
        Test::Mockingbird::mock($full_method, $wrapper);
366
367
15
7
27
1586
        return sub { @calls };
368}
369
370# ----------------------------------------------------------------------
371# Private helpers
372# ----------------------------------------------------------------------
373
374sub _require_future {
375
60
60
60
36
108
63
        eval { require Future; 1 }
376                or croak "Test::Mockingbird::Async requires the Future module.\n"
377                        . "Install it with: cpanm Future\n";
378}
379
380 - 463
=head1 FORMAL SPECIFICATION

Each function requires C<Future> (C<_require_future>) and installs one layer
through C<Test::Mockingbird::mock()> with C<type> set to its own name; see
L<Test::Mockingbird/FORMAL SPECIFICATION> for C<mock>, C<unmock> and
C<resolve>.

=head2 mock_future_return

    mock_future_return ≙
      âˆ€ target : Str; values : Seq(Any) •
        pre  defined(target)
        post mock(target, sub { Future->done(values) })

=head2 mock_future_fail

    mock_future_fail ≙
      âˆ€ target : Str; msg : Str; details : Seq(Any) •
        pre  defined(target) ∧ defined(msg)
        post mock(target, sub { Future->fail(msg, details) })

=head2 mock_future_sequence

    mock_future_sequence ≙
      âˆ€ target : Str; items : Seq(Any) •
        pre  defined(target) ∧ |items| ≥ 1
        post let queue = items •
          mock(target, sub {
            let item = (|queue| = 1) ? head(queue) : shift(queue) •
            item isa Future ? item : Future->done(item)
          })

=head2 mock_future_once

    mock_future_once ≙
      âˆ€ target : Str; values : Seq(Any) •
        pre  defined(target)
        post mock(target, sub {
               let f = Future->done(values) • unmock(target); f
             })

=head2 async_spy

    async_spy ≙
      âˆ€ pkg, meth : Str •
        pre  name(pkg) ∧ name(meth)
        let t = pkg::meth; orig = resolve(pkg, meth) •
        post mock(t, wrapper)
             âˆ§ wrapper(@args) ≙ let f = orig(@args) •
                                calls' = calls ⌢ ⟨{ args => [t, @args], future => f }⟩
                                âˆ§ call_log' = call_log ⌢ ⟨t⟩; f
             âˆ§ returns sub { calls }

=head1 SUPPORT

This module is provided as-is without any warranty.

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::DeepMock>

=item * L<Future>

=back

=head1 LICENCE AND COPYRIGHT

Copyright 2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
464
4651;