| File: | blib/lib/Test/Mockingbird/Async.pm |
| Coverage: | 99.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | ||||||
| 10 | our @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 | ||||||
| 30 | our $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 | ||||||
| 123 | sub 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 | ||||||
| 166 | sub 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 | ||||||
| 218 | sub 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 | ||||||
| 270 | sub 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 | ||||||
| 338 | sub 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 | ||||||
| 374 | sub _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 | ||||||
| 465 | 1; | |||||