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.

2 Comments

> 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.

Leave a comment

About Chase Whitener

user-pic I blog about Perl.