| File: | lib/Test/Mockingbird/Async.pm |
| Coverage: | 27.5% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Test::Mockingbird::Async; | |||||
| 2 | ||||||
| 3 | 2 2 2 | 10086 2 25 | use strict; | |||
| 4 | 2 2 2 | 2 2 55 | use warnings; | |||
| 5 | ||||||
| 6 | 2 2 2 | 2 2 69 | use Carp qw(croak); | |||
| 7 | 2 2 2 | 3 2 20 | use Exporter 'import'; | |||
| 8 | 2 2 2 | 2 2 456 | 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 - 26 | =head1 NAME Test::Mockingbird::Async - Future-based async mocking for Test::Mockingbird =head1 VERSION Version 0.13 =cut | |||||
| 27 | ||||||
| 28 | our $VERSION = '0.13'; | |||||
| 29 | ||||||
| 30 - 114 | =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 (Params::Validate::Strict schema)
- C<target>: required, scalar string; shorthand C<'Pkg::method'> form
- C<@values>: zero or more values passed to C<Future->done>
=head4 Output (Returns::Set schema)
- C<return>: undef
=cut | |||||
| 115 | ||||||
| 116 | sub mock_future_return { | |||||
| 117 | 2 | 448 | my ($target, @values) = @_; | |||
| 118 | ||||||
| 119 | 2 | 2 | croak 'mock_future_return requires a target' unless defined $target; | |||
| 120 | ||||||
| 121 | 2 | 2 | _require_future(); | |||
| 122 | ||||||
| 123 | 0 | 0 | local $Test::Mockingbird::TYPE = 'mock_future_return'; | |||
| 124 | 0 0 | 0 0 | Test::Mockingbird::mock($target, sub { Future->done(@values) }); | |||
| 125 | ||||||
| 126 | 0 | 0 | return; | |||
| 127 | } | |||||
| 128 | ||||||
| 129 - 152 | =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 (Params::Validate::Strict schema)
- C<target>: required, scalar string
- C<$message>: required, scalar string; the failure message
- C<@details>: optional; additional failure metadata passed to C<Future->fail>
=head4 Output (Returns::Set schema)
- C<return>: undef
=cut | |||||
| 153 | ||||||
| 154 | sub mock_future_fail { | |||||
| 155 | 0 | 0 | my ($target, $message, @details) = @_; | |||
| 156 | ||||||
| 157 | 0 | 0 | croak 'mock_future_fail requires a target and a failure message' | |||
| 158 | unless defined $target && defined $message; | |||||
| 159 | ||||||
| 160 | 0 | 0 | _require_future(); | |||
| 161 | ||||||
| 162 | 0 | 0 | local $Test::Mockingbird::TYPE = 'mock_future_fail'; | |||
| 163 | 0 0 | 0 0 | Test::Mockingbird::mock($target, sub { Future->fail($message, @details) }); | |||
| 164 | ||||||
| 165 | 0 | 0 | return; | |||
| 166 | } | |||||
| 167 | ||||||
| 168 - 197 | =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 (Params::Validate::Strict schema)
- C<target>: required, scalar string
- C<@items>: required; one or more plain values or C<Future> objects
=head4 Output (Returns::Set schema)
- C<return>: undef
=cut | |||||
| 198 | ||||||
| 199 | sub mock_future_sequence { | |||||
| 200 | 0 | 0 | my ($target, @items) = @_; | |||
| 201 | ||||||
| 202 | 0 | 0 | croak 'mock_future_sequence requires a target and at least one item' | |||
| 203 | unless defined $target && @items; | |||||
| 204 | ||||||
| 205 | 0 | 0 | _require_future(); | |||
| 206 | ||||||
| 207 | 0 | 0 | my @queue = @items; | |||
| 208 | ||||||
| 209 | 0 | 0 | local $Test::Mockingbird::TYPE = 'mock_future_sequence'; | |||
| 210 | Test::Mockingbird::mock($target, sub { | |||||
| 211 | 0 | 0 | my $item = @queue == 1 ? $queue[0] : shift @queue; | |||
| 212 | # Pass pre-built Futures through unchanged; wrap plain values | |||||
| 213 | 0 | 0 | return (ref $item && $item->isa('Future')) ? $item : Future->done($item); | |||
| 214 | 0 | 0 | }); | |||
| 215 | ||||||
| 216 | 0 | 0 | return; | |||
| 217 | } | |||||
| 218 | ||||||
| 219 - 244 | =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 (Params::Validate::Strict schema) - C<target>: required, scalar string - C<@values>: zero or more values passed to C<Future->done> =head4 Output (Returns::Set schema) - C<return>: undef =cut | |||||
| 245 | ||||||
| 246 | sub mock_future_once { | |||||
| 247 | 0 | 0 | my ($target, @values) = @_; | |||
| 248 | ||||||
| 249 | 0 | 0 | croak 'mock_future_once requires a target' unless defined $target; | |||
| 250 | ||||||
| 251 | 0 | 0 | _require_future(); | |||
| 252 | ||||||
| 253 | 0 | 0 | my ($package, $method) = Test::Mockingbird::_parse_target($target); | |||
| 254 | ||||||
| 255 | my $wrapper = sub { | |||||
| 256 | 0 | 0 | my $future = Future->done(@values); | |||
| 257 | 0 | 0 | Test::Mockingbird::unmock($package, $method); | |||
| 258 | 0 | 0 | return $future; | |||
| 259 | 0 | 0 | }; | |||
| 260 | ||||||
| 261 | 0 | 0 | local $Test::Mockingbird::TYPE = 'mock_future_once'; | |||
| 262 | 0 | 0 | Test::Mockingbird::mock($target, $wrapper); | |||
| 263 | ||||||
| 264 | 0 | 0 | return; | |||
| 265 | } | |||||
| 266 | ||||||
| 267 - 306 | =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 (Params::Validate::Strict schema)
- C<target>: required; C<'Pkg::method'> shorthand or C<('Pkg', 'method')> longhand
=head4 Output (Returns::Set schema)
- C<return>: coderef; when called, returns the list of call records
=cut | |||||
| 307 | ||||||
| 308 | sub async_spy { | |||||
| 309 | 0 | 0 | _require_future(); | |||
| 310 | ||||||
| 311 | 0 | 0 | my ($package, $method) = Test::Mockingbird::_parse_target(@_); | |||
| 312 | ||||||
| 313 | 0 | 0 | croak 'Package and method are required for async_spy' | |||
| 314 | unless $package && $method; | |||||
| 315 | ||||||
| 316 | 0 | 0 | my $full_method = "${package}::${method}"; | |||
| 317 | ||||||
| 318 | # Capture current implementation so the wrapper can delegate to it. | |||||
| 319 | # mock() will also capture it independently for stack bookkeeping; | |||||
| 320 | # both captures see the same coderef at this point. | |||||
| 321 | 0 | 0 | my $orig; | |||
| 322 | { | |||||
| 323 | ## no critic (ProhibitNoStrict) | |||||
| 324 | 2 2 2 0 | 3 1 183 0 | no strict 'refs'; | |||
| 325 | 0 0 | 0 0 | $orig = \&{$full_method}; | |||
| 326 | } | |||||
| 327 | ||||||
| 328 | 0 | 0 | my @calls; | |||
| 329 | ||||||
| 330 | my $wrapper = sub { | |||||
| 331 | 0 | 0 | my @call_args = @_; | |||
| 332 | 0 | 0 | my $future = $orig->(@call_args); | |||
| 333 | 0 | 0 | push @calls, { args => [$full_method, @call_args], future => $future }; | |||
| 334 | 0 | 0 | Test::Mockingbird::_record_call($full_method); | |||
| 335 | 0 | 0 | return $future; | |||
| 336 | 0 | 0 | }; | |||
| 337 | ||||||
| 338 | 0 | 0 | local $Test::Mockingbird::TYPE = 'async_spy'; | |||
| 339 | 0 | 0 | Test::Mockingbird::mock($full_method, $wrapper); | |||
| 340 | ||||||
| 341 | 0 0 | 0 0 | return sub { @calls }; | |||
| 342 | } | |||||
| 343 | ||||||
| 344 | # ---------------------------------------------------------------------- | |||||
| 345 | # Private helpers | |||||
| 346 | # ---------------------------------------------------------------------- | |||||
| 347 | ||||||
| 348 | sub _require_future { | |||||
| 349 | 2 2 0 | 1 116 | eval { require Future; 1 } | |||
| 350 | or croak "Test::Mockingbird::Async requires the Future module.\n" | |||||
| 351 | . "Install it with: cpanm Future\n"; | |||||
| 352 | } | |||||
| 353 | ||||||
| 354 - 384 | =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 | |||||
| 385 | ||||||
| 386 | 1; | |||||