lib/Test/Mockingbird/Async.pm

Structural Coverage (Approximate)

TER1 (Statement): 100.00%
TER2 (Branch): 93.75%
TER3 (LCSAJ): -
Approximate LCSAJ segments: 17

LCSAJ Legend

● Covered — this LCSAJ path was executed during testing.

● Not covered — this LCSAJ path was never executed. These are the paths to focus on.

Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:

        start → end → jump
        

Uncovered paths show [NOT COVERED] in the tooltip.

Mutant Testing Legend

Survived (tests missed this) Killed (tests detected this) No mutation
    1: package Test::Mockingbird::Async;
    2: 
    3: use strict;
    4: use warnings;
    5: 
    6: use Carp qw(croak);
    7: use Exporter 'import';
    8: 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: =head1 NAME
   21: 
   22: Test::Mockingbird::Async - Future-based async mocking for Test::Mockingbird
   23: 
   24: =head1 VERSION
   25: 
   26: Version 0.14
   27: 
   28: =cut
   29: 
   30: our $VERSION = '0.14';
   31: 
   32: =head1 SYNOPSIS
   33: 
   34:     use Test::Mockingbird;
   35:     use Test::Mockingbird::Async qw(
   36:         mock_future_return
   37:         mock_future_fail
   38:         mock_future_sequence
   39:         mock_future_once
   40:         async_spy
   41:     );
   42: 
   43:     # Mock a method to return a pre-resolved Future
   44:     mock_future_return 'My::DB::fetch' => { id => 1 };
   45:     my $result = My::DB::fetch()->get;   # { id => 1 }
   46: 
   47:     # Mock a method to return a pre-failed Future
   48:     mock_future_fail 'My::DB::fetch' => 'not found';
   49:     my ($msg) = My::DB::fetch()->failure;   # 'not found'
   50: 
   51:     # Return different values over successive calls
   52:     mock_future_sequence 'My::DB::fetch' => (10, 20, 30);
   53:     My::DB::fetch()->get;   # 10
   54:     My::DB::fetch()->get;   # 20
   55:     My::DB::fetch()->get;   # 30 (repeated from here)
   56: 
   57:     # Return a Future exactly once, then restore the previous implementation
   58:     mock_future_once 'My::DB::ping' => 'ok';
   59:     My::DB::ping()->get;   # 'ok', then original restored
   60: 
   61:     # Spy on a method that returns a Future
   62:     my $spy = async_spy 'My::DB::fetch';
   63:     My::DB::fetch('key');
   64:     my @calls = $spy->();
   65:     my $call = $calls[0];
   66:     # $call->{args}   is [ 'My::DB::fetch', 'key' ]
   67:     # $call->{future} is the Future returned by the original
   68: 
   69:     restore_all();
   70: 
   71: =head1 DESCRIPTION
   72: 
   73: C<Test::Mockingbird::Async> extends L<Test::Mockingbird> with helpers for
   74: testing code that uses L<Future>-based asynchronous APIs.
   75: 
   76: All mocks and spies installed by this module use the same underlying stack
   77: as the core engine. C<restore_all()>, C<unmock()>, and C<diagnose_mocks()>
   78: work identically to their core counterparts. Every installed layer is
   79: recorded in C<diagnose_mocks()> with a type of C<mock_future_return>,
   80: C<mock_future_fail>, C<mock_future_sequence>, C<mock_future_once>, or
   81: C<async_spy>.
   82: 
   83: C<async_spy> also writes to the call-order log, so
   84: C<assert_call_order()> works across a mix of plain spies and async spies.
   85: 
   86: =head2 Dependency
   87: 
   88: This module requires the L<Future> distribution (available from CPAN).
   89: It is not a mandatory dependency of L<Test::Mockingbird> itself; the
   90: module loads C<Future> on first use and croaks with a helpful message if
   91: it is not installed.
   92: 
   93: =head1 METHODS
   94: 
   95: =head2 mock_future_return
   96: 
   97: Mock a method so that it always returns a pre-resolved C<Future>.
   98: 
   99:     mock_future_return 'My::DB::fetch' => $value;
  100:     mock_future_return 'My::DB::fetch' => ($val1, $val2);   # multi-value
  101: 
  102: The method is replaced with a stub that returns C<Future->done(@values)>.
  103: Restore with C<restore_all()> or C<unmock()>.
  104: 
  105: =head3 API SPECIFICATION
  106: 
  107: =head4 Input
  108: 
  109:     target -- Str, 'Pkg::method'
  110:     values -- List[Any], zero or more; passed to Future->done
  111: 
  112: =head4 Output
  113: 
  114:     returns: undef
  115: 
  116: =head3 MESSAGES
  117: 
  118:   "mock_future_return requires a target" -- target undef
  119:   "Test::Mockingbird::Async requires the Future module." -- Future missing
  120: 
  121: =cut
  122: 
  123: sub mock_future_return {
  124: 	my ($target, @values) = @_;
  125: 
  126: 	croak 'mock_future_return requires a target' unless defined $target;
  127: 
  128: 	_require_future();
  129: 
  130: 	local $Test::Mockingbird::TYPE = 'mock_future_return';
  131: 	Test::Mockingbird::mock($target, sub { Future->done(@values) });
  132: 
  133: 	return;
  134: }
  135: 
  136: =head2 mock_future_fail
  137: 
  138: Mock a method so that it always returns a pre-failed C<Future>.
  139: 
  140:     mock_future_fail 'My::DB::fetch' => 'not found';
  141:     mock_future_fail 'My::DB::fetch' => ('db error', 'db', { code => 500 });
  142: 
  143: The method is replaced with a stub that returns
  144: C<Future->fail($message, @details)>. The caller receives a rejected Future;
  145: no exception is thrown at the call site.
  146: 
  147: =head3 API SPECIFICATION
  148: 
  149: =head4 Input
  150: 
  151:     target  -- Str, 'Pkg::method'
  152:     message -- Str, the failure message
  153:     details -- List[Any], optional; further arguments to Future->fail
  154: 
  155: =head4 Output
  156: 
  157:     returns: undef
  158: 
  159: =head3 MESSAGES
  160: 
  161:   "mock_future_fail requires a target and a failure message" -- either undef
  162:   "Test::Mockingbird::Async requires the Future module." -- Future missing
  163: 
  164: =cut
  165: 
  166: sub mock_future_fail {
  167: 	my ($target, $message, @details) = @_;
  168: 
  169: 	croak 'mock_future_fail requires a target and a failure message'
  170: 		unless defined $target && defined $message;
  171: 
  172: 	_require_future();
  173: 
  174: 	local $Test::Mockingbird::TYPE = 'mock_future_fail';
  175: 	Test::Mockingbird::mock($target, sub { Future->fail($message, @details) });
  176: 
  177: 	return;
  178: }
  179: 
  180: =head2 mock_future_sequence
  181: 
  182: Mock a method so that it returns a sequence of C<Future> values over
  183: successive calls. When the sequence is exhausted, the last item is repeated.
  184: 
  185:     mock_future_sequence 'My::DB::fetch' => (10, 20, 30);
  186:     My::DB::fetch()->get;   # 10
  187:     My::DB::fetch()->get;   # 20
  188:     My::DB::fetch()->get;   # 30 (repeats)
  189: 
  190: Each item in the sequence may be either a plain value (wrapped automatically
  191: in C<Future->done>) or a pre-built C<Future> object (passed through as-is,
  192: allowing a mix of resolved and failed Futures in the sequence):
  193: 
  194:     mock_future_sequence 'My::DB::fetch' =>
  195:         42,
  196:         Future->fail('oops');
  197: 
  198: =head3 API SPECIFICATION
  199: 
  200: =head4 Input
  201: 
  202:     target -- Str, 'Pkg::method'
  203:     items  -- List[Any|Future], one or more; plain values are wrapped in
  204:               Future->done, Future objects are returned as they are
  205: 
  206: =head4 Output
  207: 
  208:     returns: undef
  209: 
  210: =head3 MESSAGES
  211: 
  212:   "mock_future_sequence requires a target and at least one item" -- target
  213:       undef or no items
  214:   "Test::Mockingbird::Async requires the Future module." -- Future missing
  215: 
  216: =cut
  217: 
  218: sub mock_future_sequence {
  219: 	my ($target, @items) = @_;
  220: 
  221: 	croak 'mock_future_sequence requires a target and at least one item'
  222: 		unless defined $target && @items;
  223: 
  224: 	_require_future();
  225: 
  226: 	my @queue = @items;
  227: 
  228: 	local $Test::Mockingbird::TYPE = 'mock_future_sequence';
  229: 	Test::Mockingbird::mock($target, sub {
  230: 		my $item = @queue == 1 ? $queue[0] : shift @queue;

Mutants (Total: 1, Killed: 1, Survived: 0)

231: # Pass pre-built Futures through unchanged; wrap plain values 232: return (ref $item && $item->isa('Future')) ? $item : Future->done($item);

Mutants (Total: 2, Killed: 2, Survived: 0)

233: }); 234: 235: return; 236: } 237: 238: =head2 mock_future_once 239: 240: Install a mock that returns a pre-resolved C<Future> exactly once. After the 241: first call the previous implementation is automatically restored. 242: 243: mock_future_return 'My::DB::ping' => 'baseline'; 244: mock_future_once 'My::DB::ping' => 'temporary'; 245: 246: My::DB::ping()->get; # 'temporary' 247: My::DB::ping()->get; # 'baseline' (previous mock restored) 248: 249: This is useful for simulating transient failures, one-time responses, or 250: state transitions in async code. 251: 252: =head3 API SPECIFICATION 253: 254: =head4 Input 255: 256: target -- Str, 'Pkg::method' 257: values -- List[Any], zero or more; passed to Future->done 258: 259: =head4 Output 260: 261: returns: undef 262: 263: =head3 MESSAGES 264: 265: "mock_future_once requires a target" -- target undef 266: "Test::Mockingbird::Async requires the Future module." -- Future missing 267: 268: =cut 269: 270: sub mock_future_once { 271: my ($target, @values) = @_; 272: 273: croak 'mock_future_once requires a target' unless defined $target; 274: 275: _require_future(); 276: 277: my ($package, $method) = Test::Mockingbird::_parse_target($target); 278: 279: my $wrapper = sub { 280: my $future = Future->done(@values); 281: Test::Mockingbird::unmock($package, $method); 282: return $future;

Mutants (Total: 2, Killed: 2, Survived: 0)

283: }; 284: 285: local $Test::Mockingbird::TYPE = 'mock_future_once'; 286: Test::Mockingbird::mock($target, $wrapper); 287: 288: return; 289: } 290: 291: =head2 async_spy 292: 293: Wrap a method so that every call is recorded along with the C<Future> it 294: returned. The original method is still called and its return value is 295: passed back to the caller unchanged. 296: 297: my $spy = async_spy 'My::DB::fetch'; 298: My::DB->fetch(id => 1); 299: 300: my @calls = $spy->(); 301: 302: my $call = $calls[0]; 303: # $call->{args} is [ 'My::DB::fetch', $invocant, id => 1 ] 304: # $call->{future} is whatever Future the original returned 305: 306: my $result = $call->{future}->get; # inspect resolved value 307: 308: Unlike the plain C<spy()> coderef (which returns arrayrefs), C<async_spy> 309: returns hashrefs so the C<future> field is unambiguous. 310: 311: C<async_spy> writes to the call-order log, so C<assert_call_order()> works 312: across a mix of plain and async spies. 313: 314: =head3 Limitations 315: 316: C<async_spy> assumes the spied method returns a C<Future>. If the method 317: returns a plain value the C<future> field in each call record will hold that 318: value rather than a C<Future>, and calling C<< ->get >> on it will fail. 319: 320: =head3 API SPECIFICATION 321: 322: =head4 Input 323: 324: target -- Str, 'Pkg::method' or ('Pkg', 'method') 325: 326: =head4 Output 327: 328: returns: CodeRef; yields the list of call records, each 329: { args => [ 'Pkg::method', @args ], future => $returned } 330: 331: =head3 MESSAGES 332: 333: "Package and method are required for async_spy" -- target missing or incomplete 334: "Test::Mockingbird::Async requires the Future module." -- Future missing 335: 336: =cut 337: 338: sub async_spy { 339: _require_future(); 340: 341: my ($package, $method) = Test::Mockingbird::_parse_target(@_); 342: 343: croak 'Package and method are required for async_spy' 344: unless Test::Mockingbird::_is_name($package) 345: && Test::Mockingbird::_is_name($method); 346: 347: 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: my $orig = Test::Mockingbird::_call_through($package, $method); 353: 354: my @calls; 355: 356: my $wrapper = sub { 357: my @call_args = @_; 358: my $future = $orig->(@call_args); 359: push @calls, { args => [$full_method, @call_args], future => $future }; 360: Test::Mockingbird::_record_call($full_method); 361: return $future;

Mutants (Total: 2, Killed: 2, Survived: 0)

362: }; 363: 364: local $Test::Mockingbird::TYPE = 'async_spy'; 365: Test::Mockingbird::mock($full_method, $wrapper); 366: 367: return sub { @calls };

Mutants (Total: 2, Killed: 2, Survived: 0)

368: } 369: 370: # ---------------------------------------------------------------------- 371: # Private helpers 372: # ---------------------------------------------------------------------- 373: 374: sub _require_future { 375: 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: =head1 FORMAL SPECIFICATION 381: 382: Each function requires C<Future> (C<_require_future>) and installs one layer 383: through C<Test::Mockingbird::mock()> with C<type> set to its own name; see 384: L<Test::Mockingbird/FORMAL SPECIFICATION> for C<mock>, C<unmock> and 385: C<resolve>. 386: 387: =head2 mock_future_return 388: 389: mock_future_return ≙ 390: ∀ target : Str; values : Seq(Any) • 391: pre defined(target) 392: post mock(target, sub { Future->done(values) }) 393: 394: =head2 mock_future_fail 395: 396: mock_future_fail ≙ 397: ∀ target : Str; msg : Str; details : Seq(Any) • 398: pre defined(target) ∧ defined(msg) 399: post mock(target, sub { Future->fail(msg, details) }) 400: 401: =head2 mock_future_sequence 402: 403: mock_future_sequence ≙ 404: ∀ target : Str; items : Seq(Any) • 405: pre defined(target) ∧ |items| ≥ 1 406: post let queue = items • 407: mock(target, sub { 408: let item = (|queue| = 1) ? head(queue) : shift(queue) • 409: item isa Future ? item : Future->done(item) 410: }) 411: 412: =head2 mock_future_once 413: 414: mock_future_once ≙ 415: ∀ target : Str; values : Seq(Any) • 416: pre defined(target) 417: post mock(target, sub { 418: let f = Future->done(values) • unmock(target); f 419: }) 420: 421: =head2 async_spy 422: 423: async_spy ≙ 424: ∀ pkg, meth : Str • 425: pre name(pkg) ∧ name(meth) 426: let t = pkg::meth; orig = resolve(pkg, meth) • 427: post mock(t, wrapper) 428: ∧ wrapper(@args) ≙ let f = orig(@args) • 429: calls' = calls ⌢ ⟨{ args => [t, @args], future => f }⟩ 430: ∧ call_log' = call_log ⌢ ⟨t⟩; f 431: ∧ returns sub { calls } 432: 433: =head1 SUPPORT 434: 435: This module is provided as-is without any warranty. 436: 437: Please report bugs at L<https://github.com/nigelhorne/Test-Mockingbird/issues>. 438: 439: =head1 AUTHOR 440: 441: Nigel Horne, C<< <njh at nigelhorne.com> >> 442: 443: =head1 SEE ALSO 444: 445: =over 4 446: 447: =item * L<Test::Mockingbird> 448: 449: =item * L<Test::Mockingbird::DeepMock> 450: 451: =item * L<Future> 452: 453: =back 454: 455: =head1 LICENCE AND COPYRIGHT 456: 457: Copyright 2026 Nigel Horne. 458: 459: Usage is subject to the GPL2 licence terms. 460: If you use it, 461: please let me know. 462: 463: =cut 464: 465: 1;