Mock Testing Web Services with Mojo
Occasionally, the need to write a web service client comes about. For example, when the decision gets made to move away from a piece of software that you run in-house to a suite of hosted apps.
The hosted apps offer RESTful APIs for communication that you will need to use to transfer your data. Let's pretend that there isn't yet a Perl client implementation to fit our needs. So, the first thing that needs to be done is to write a client for these web services (using Mojolicious) to handle the few API methods you'll need.
The client
You end up with an overly simplified client library that might look like this:
package WebService::Foo;
use Mojo::Base -base;
use Mojo::IOLoop;
use Mojo::URL;
use Mojo::UserAgent;
has service_url => sub { Mojo::URL->new('https://some_url/api/') };
has ua => sub { Mojo::UserAgent->new() };
sub account {
my ($self, $id, $cb) = @_;
my $url = Mojo::URL->new($self->service_url)->path("Account/$id");
#blocking
unless ($cb) {
my $tx = $self->ua->get($url);
die $tx->error->{message} unless $tx->success;
return $tx->res->json;
}
#non-blocking
Mojo::IOLoop->delay(
sub {
my $delay = shift;
$self->ua->get($url,$delay->begin);
},
sub {
my ($delay, $tx) = @_;
return $self->$cb($tx->error->{message}, undef) unless $tx->success;
return $self->$cb(undef,$tx->res->json);
}
)->catch(sub { $self->$cb(pop, undef) })->wait;
return $self;
}
1;
The client only implements one method, account
, that allows you to get accounts from the web service by their ID in either a blocking or a non-blocking way.
Ugh! How do I test this?!
The usual list of statements as to why this type of testing is hard comes up quickly:
- I can't test against the live service.
- Do I have access to a testing environment?
- If I release this, I can't require userland to have credentials to a testing environment.
- etc.
This inevitably leads to the following question: if I can't create accurate tests for it, how can I trust it in my application?
Well, in short, you mock it!
Create a Mock Web service
Using an application like Mojo's get command, cURL, or PostMan you can send erroneous and proper requests to the actual API service and copy the responses to create your mock.
The mock will end up being a simple web server that could look like this:
# some values
my $ID = '001W000000KY0vBIAT';
my $ID_DEL = '001W000000KY0vBIAC';
my $ID_BAD = '001W000000KY0vBZZZ';
my $RECORD = {Id => "001W000000KY0vBIAT",IsDeleted => 0,Name => 'foo'};
# setup mock
my $mock = Mojolicious->new;
$mock->routes->get('/api/Account/:id' => sub {
my $c = shift;
my $id = $c->stash('id');
return $c->render(status=>404,text=>"No ID supplied") unless $id;
return $c->render(status=>404,text=>"ID is deleted") if $id eq $ID_DEL;
return $c->render(status=>400,text=>"Malformed id") if $id eq $ID_MAL;
return $c->render(status=>200,json=>$RECORD) if $id eq $ID;
return $c->render(status=>404,text=>"Provided ID does not exist");
});
Write your tests
Now that you have your mock server, you can write a test for the account
method in our client above that might look like this:
use Mojo::Base -strict;
use Test::More;
use Mojolicious;
use Mojo::IOLoop;
use Try::Tiny qw(try catch);
BEGIN { use_ok('WebService::Foo') || BAIL_OUT("Can't use WebService::Foo"); }
my $ID = '001W000000KY0vBIAT';
my $ID_DEL = '001W000000KY0vBIAC';
my $ID_BAD = '001W000000KY0vBZZZ';
my $RECORD = {Id => "001W000000KY0vBIAT",IsDeleted => 0,Name => 'foo'};
my $ws = try { WebService::Foo->new(); } catch { BAIL_OUT("Unable to create new instance: $_"); };
isa_ok($ws, 'WebService::Foo', 'new: got an instance') || BAIL_OUT("can't instantiate");
# setup mock
my $mock = Mojolicious->new;
$mock->log->level('fatal'); # only log fatal errors to keep the server quiet
$mock->routes->get('/api/Account/:id' => sub {
my $c = shift;
my $id = $c->stash('id');
return $c->render(status=>404,text=>"No ID supplied") unless $id;
return $c->render(status=>404,text=>"ID is deleted") if $id eq $ID_DEL;
return $c->render(status=>400,text=>"Malformed id") if $id eq $ID_MAL;
return $c->render(status=>200,json=>$RECORD) if $id eq $ID;
return $c->render(status=>404,text=>"Provided ID does not exist");
});
$ws->ua->server->app($mock); # point our UserAgent to our new mock server
# set the URL
$ws->service_url(Mojo::URL->new('/api/'));
# actual testing
can_ok($ws, qw(account) );
# blocking tests
{
my $res;
# error handling
$res = try {return $ws->account() } catch { $_; };
like( $res, qr/No ID supplied/, 'account error: empty call');
$res = try {return $ws->account('') } catch { $_; };
like( $res, qr/No ID supplied/, 'account error: empty string call');
$res = try {return $ws->account(undef) } catch { $_; };
like( $res, qr/No ID supplied/, 'account error: undef call');
$res = try {return $ws->account($ID_DEL) } catch { $_; };
like( $res, qr/ID is deleted/, 'account error: deleted ID');
$res = try {return $ws->account($ID_MAL) } catch { $_; };
like( $res, qr/Malformed id/, 'account error: malformed ID');
$res = try {return $ws->account('gobblygook') } catch { $_; };
like( $res, qr/Provided ID does not exist/, 'account error: unknown id');
# successful tests
$res = try{return $ws->account($ID) } catch { $_; };
is_deeply( $res, $RECORD, 'account: successful result');
}
# non-blocking tests
{ # error: start and stop loop manually
my ($res, $err);
$ws->account($ID_MAL, sub {
(undef, $err, $res) = @_;
Mojo::IOLoop->stop;
});
Mojo::IOLoop->start;
like($err, qr/Malformed id/, 'account-nb: error: proper malformed ID error');
is($res, undef, "account-nb: error: proper undef results");
}
{ # success: start and stop loop manually
my ($res, $err);
$ws->account($ID, sub {
(undef, $err, $res) = @_;
Mojo::IOLoop->stop;
});
Mojo::IOLoop->start;
is($err, undef, 'account-nb: No errors');
is_deeply($res, $RECORD, "account-nb: successful result");
}
{ # error: delays are prettier
my ($res, $err);
Mojo::IOLoop->delay(
sub { $ws->account($ID_MAL, shift->begin()); },
sub { (undef, $err, $res) = @_; }
)->wait;
like($err, qr/Malformed id/, 'account-nb: error: proper malformed ID error');
is($res, undef, "account-nb: error: proper undef results");
}
{ # success: delays are prettier
my ($res, $err);
Mojo::IOLoop->delay(
sub { $ws->account($ID, shift->begin()); },
sub { (undef, $err, $res) = @_; }
)->wait;
is($err, undef, 'account-nb: No errors');
is_deeply($res, $RECORD, "account-nb: successful result");
}
done_testing;
Blocking tests
The blocking tests are pretty straight-forward. If anything goes wrong, your client dies. You expect the caller to catch those errors using Try::Tiny. You test with bad IDs, deleted IDs, no IDs, good IDs, and you check the response from your mock.
Non-blocking tests
The non-blocking tests are a bit less straight-forward. You can't be guaranteed the tests will run if they're done directly in the callback. So, you declare your error and response variables and update them when the callback gets executed.
You've got the same tests done in two ways above.
Starting and stopping
In the callback you manually stop the Mojo::IOLoop and then start it above where you want to run the tests.
my ($res, $err);
$ws->account($ID_MAL, sub {
(undef, $err, $res) = @_;
Mojo::IOLoop->stop;
});
Mojo::IOLoop->start;
like($err, qr/Malformed id/, 'account-nb: error: proper malformed ID error');
is($res, undef, "account-nb: error: proper undef results");
Delays!! YAY
Another, cleaner (in my opinion) way to run the test would be to use the delay method of Mojo::IOLoop. This is much easier on the eyes and mind as you just have to wait on the steps to finish rather than worrying about when to start and stop the loop.
my ($res, $err);
Mojo::IOLoop->delay(
sub { $ws->account($ID_MAL, shift->begin()); },
sub { (undef, $err, $res) = @_; }
)->wait;
like($err, qr/Malformed id/, 'account-nb: error: proper malformed ID error');
is($res, undef, "account-nb: error: proper undef results");
Enjoy
If you've done your job well, your mock should behave how the real service behaves and give you some confidence that your client is written properly, allowing the caller to get data and trap errors.
> If you've done your job well, your mock should behave how the real service behaves
I'm curious, how do you ensure that? In particular, my worry would be that changes happen in the way the live server works and my app is never tested against that. So I can never declare that my app works, only that it works against a flimsy fake server I coded myself.
That is, unfortunately, the nature of writing and maintaining clients for web services you do not control.
A well-behaved web service gives you versioned endpoints to hit, a la Salesforce. If I write and test a client against version 34.0 of their services, I can only assume proper behavior against that version.
As new versions come up, I'm forced to read the change log and update my tests if need be.