Asante! Uwasilishaji wako umepokelewa!
Ups! Kitu kilienda vibaya wakati wa kuwasilisha fomu.

Unit Testing Asynchronous Code - Perl

Makala hii imesasishwa kwenye
March 18, 2024
Developers working on an asynchronous code project.

How writing asynchronous code using Futures can help simplify unit testing

Well-tested code is always prefered over something that has no tests, as with more tests we can be more confident that the code actually does what we want it to. Within the realm of tests, there exists a spectrum of scope - how much of the code is being tested at once. At the small end, tests for individual pieces (often single functions or one or a few methods of a class) are called "unit tests"; while at the other end the larger scope of "integration tests" combine entire systems together and check end-to-end functionality of the whole lot.

There are as many different opinions on what mix of tests is best as there are people you could ask, but my personal opinion is that the majority of tests should be small-scoped unit tests, between them covering as much of the codebase as is possible. This ensures that every part of the system gets its own individual unit test, in which failures can be fairly easily isolated as belonging to that one individual piece being tested. Such fault isolation can be harder with larger-scoped integration tests.

Unit Isolation

Exactly how you go about doing this sort of unit test of course is going to depend on what the piece being tested is actually doing. For individual stateless functions, such as utility calculations, testing these is an easy matter of providing a variety of inputs and checking that the correct output comes out. But often, pieces that need testing aren't so easy to isolate like this, as they will themselves depend on other parts of the system.

Most pieces that need testing in any non-trivial codebase will be code that sits in the middle of some larger system and interacts with other pieces on both sides - receiving some sort of request or trigger for action from above, and causing some internal activity within its implementation at lower layers of the system. We sometimes call these parts middleware - all the software that sits in the middle of the system, between the outside interface that interacts with the outside world, and the innermost base functions.

When testing pieces of code like this that sit between two other layers elsewhere, it becomes important that we can provide both sides from the unit testing script itself, so that the code being tested is properly isolated from the rest of the system. Generally in unit testing we use a variety of techniques that could be called mocking, to provide fake versions of those lower-level parts of the system that the code being tested would normally interact with. These mocked pieces run under the control of the unit-test script, so it can provide whatever behaviour required to perform the actual test.

The whole subject of mocking for unit-testing is a wide and far-reaching one, but today we'll look at one particular situation where using Futures to implement asynchronous behaviour makes it easier to test by mocking.

Mocking

Often, the most flexible way to write middleware is to arrange that the lower-level pieces that it needs to work are themselves passed in as function arguments or construction parameters, perhaps with some sensible defaults applied if the caller didn't provide them. This flexibility has many advantages, but for us it allows the code to be easily unit-tested as the unit test can pass in a mocking implementation of that lower layer.

Lets consider the following somewhat-contrived example; a function that returns the price of a given product in a given currency, by using a couple of helper instances.

sub get_product_price {
   my %args = @_;
   my $product_code  = $args{product_code};
   my $want_currency = $args{currency};
   
   my $catalog   = $args{catalog} // ProductCatalog->new;
   my $converter = $args{converter} // CurrencyConverter->new;
   
   return $catalog->get_product(product_code => $product_code)
   ->then(sub {
       my ($product) = @_;
       
       if($product->price_currency eq $want_currency) {
           return Future->done($product->price);
       } else {
           return $converter->convert(
               amount => $product->price,
               from   => $product->price_currency,
               to     => $want_currency,
           );
       }
   });
}

By default, this function will use two helpers from the real pieces of code ProductCatalog and CurrencyConverter, but when we want to test it in our unit test script, we can pass in some instances specifically. If we place the code for these in the test script itself we can then provide whatever behaviour is required for the test.

use Test::More;

package t::TestCatalog {
   ...
}

is(
   get_product_price(
       product_code => "ABC123",
       currency     => "USD",
       catalog      => t::TestCatalog->new,
   )->get,
   10.00,
   'get_product_price for ABC123 in USD'
);

If it's a simple method which always has to return the same value then we can just write that inline in the definition of the class:

package t::TestCatalog {
   sub get_product {
       return Future->done(Product->new(
           price          => 10,
           price_currency => "USD",
       ));
   }
}

This is a handy short definition which is probably sufficient for short unit tests, but it isn't very flexible as it's hard to change and customise for each test within the file. If we want to trial lots of different behaviours of the piece under test for different return values from this mock function then we'll need some way to make it return different values for different tests.

One way we could arrange for this is to write a larger implementation of the function, perhaps controlled by some global variables. This can work well, but means that half of the behaviour sits with the definition of this shared mocking instance, and half of it sits with the individual tests themselves. This can lead to the overall logic of the test being hard to read, having to keep swapping between the two parts of its definition being in different places.

Since the interface around the function we're testing is all based on futures, we can use a better arrangement that makes use of the deferred value semantics of a future to allow more flexible behaviour while keeping all of the code inline in one place in individual tests.

Mocking With Futures

As the entire point of a future is to represent an operation that is outstanding and might not have finished yet, we can use this in our mocking functions that go around the piece of code being tested. Rather than needing to implement the full mocked behaviour inside the function definition, we simply have to construct and return a new future that isn't yet complete. Later on, the test logic will fill this in.

my $GET_PRODUCT_F;

package t::TestCatalog {
   sub get_product {
       return $GET_PRODUCT_F = Future->new;
   }
}

This one mock implementation will now be sufficient for any number of different tests we want to apply.

But now how do we use this implementation? Readers familiar with using mocking for unit-testing might be wondering when the behaviour within this function actually applies to create the required result for a test. The key to this is to notice that the piece being tested also returns a future, which means that it doesn't have to be completed yet and yield its final answer before the unit test itself regains control. In fact, by simply storing the future returned by the piece being tested into a variable, the unit test is now free to perform some other action first. By this point, the function being tested will have called the get_product method, so we can provide a completion result for the $GET_PRODUCT_F future that it created.

{
   my $f = get_product_price(
       product_code => "ABC123",
       currency     => "USD",
       catalog      => t::TestCatalog->new,
   );

   $GET_PRODUCT_F->done(Product->new(
       price          => 10,
       price_currency => "USD",
   ));
   
   is($f->get, 10.00,
       'get_product_price for ABC123 in USD');
}

By writing the code in this form, we have now returned the lexical top-to-bottom reading nature of the source code back to matching the flow of behaviour over time. We can read the various stages of behaviour through the test block, in the right order.

We can go further than this. By capturing incoming arguments, and implementing both mocking methods, we can build a far more complete test harness for the piece of code being tested, able to apply all manner of behavioural tests to it.

my %GET_PRODUCT_ARGS;
my $GET_PRODUCT_F;
package t::TestCatalog {
   sub get_product {
       %GET_PRODUCT_ARGS = @_;
       return $GET_PRODUCT_F = Future->new;
   }
}

my %CONVERT_ARGS;
my $CONVERT_F;
package t::CurrencyConverter {
   sub convert {
       my %CONVERT_ARGS = @_;
       return $CONVERT_F = Future->new;
   }
}

We can now proceed to write a more complete test that checks the ways in which these functions are invoked by the piece being tested, and that it makes correct use of the values they return.

{
   my $f = get_product_price(
       product_code => "DEF456",
       currency     => "EUR",
       catalog      => t::TestCatalog->new,
       converter    => t::CurrencyConverter->new,
   );

   is_deeply(\%GET_PRODUCT_ARGS,
       {
           product_code => "DEF456",
           ... # other args go here
       },
       "ProductCatalog invoked with correct arguments"
   );
   $GET_PRODUCT_F->done(Product->new(
       price          => 15,
       price_currency => "USD",
   ));

   ok(!$f->is_done, "result not yet available before currency conversion");
   
   is_deeply(\%CONVERT_ARGS,
       {
           amount => 15,
           ... # other args go here
       },
       "CurrencyConverter invoked with correct arguments"
   );
   $CONVERT_F->done(20);
   
   is($f->get, 20.00,
       'get_product_price for DEF456 in USD');
}

Having designed the API of the original function to return its result using a future and additionally making use of futures within its implementation, we have been able to write the unit test in a concise but powerful way.

By allowing the unit test to concurrently perform the actions of both the caller to, and the callees from, the code under test, we are fully in control of the logic of the test. We can implement the required steps on either side of the code in a neat logical manner, allowing the test code to read in a neat form from top to bottom. The source code follows the behaviour when running by "ping-ponging" between steps of the outside caller calling the function under test, and steps of the inside implementation of the helper functions invoked by that code. This makes the logic of the unit test easier to read and understand.

Well-tested code is a good thing to have; and well-written and readable unit tests for that code definitely help us achieve it. The deferred nature of futures allow us to interleave the steps required for unit-testing middleware code in a neat readable way, helping to ensure our code is nicely tested and reliable.

Note: This post has been ported from https://tech.binary.com/ (our old tech blog). Author of this post is https://metacpan.org/author/PEVANS