Mocking LWP::UserAgent

Important update: Don’t follow this advice, follow that advice!

chromatic mentioned how to use dependency injection in You’re Already Using Dependency Injection. Although I had read that when he posted it, I hadnt actually ever done it. That is, until today.

I’m writing a simple web crawler for a university course, and that obviously screams out for a test suite. In the past, I’ve always assumed that the test suite would have internet access, that some website would be up (avoid Twitter, cough cough), and just ran some tests against the real internet. It hasn’t ever been a problem, but I wanted to give this idiom a try. I’m also using Moose in my first real project, which has been pretty nice.

The essence of dependency injection is that you don’t hardcode your dependencies. Instead of hardcoding that you want a LWP::UserAgent, you take what you’re given from the constructor, and use what you would have hardcoded as the default. This lets you easily swap out the real thing for a fake one.

has 'ua' => (
    is => 'ro',
    isa => 'LWP::UserAgent',
    required => 1,
    default => sub { LWP::RobotUA->new },
    lazy => 1,
);

This is great – until you want to swap it out for testing. A good mock should pass the $mock->isa('LWP::UserAgent) test, but neither Test::Mock::LWP or Test::Mock::LWP::Dispatch do:

Attribute (ua) does not pass the type constraint because: Validation failed for ‘LWP::UserAgent’ with value Test::MockObject=HASH(0xbed4f8) at constructor…

Instead of writing my own LWP::UserAgent mock from scratch, there are a couple ways to fix this:

  • Use a type union
  • Use duck typing (see Chris Prather’s comment)
  • Create a custom constraint

I chose the latter, simply because as a beginner with Moose, I knew how to get it to work:

subtype 'LWP::UA',
    as 'Object',
    where { $_->isa('LWP::UserAgent') or $_->isa('Test::MockObject') };

Now I can use LWP::UserAgent, LWP::RobotUA (etc), or a mocked object. This probably isn’t very safe, because a mock anything will pass the constraint. Then again, you’ll get what you deserve if you mock Fruit::Orange and put it where a Fruit::Apple is expected.

Still, I (and others) expected that a simple isa => 'LWP::UserAgent' would work, and it didn’t – when you use the exported mock object. Skip to the bottom to see how to make it work the way it should.

So, when I need to test the module, I can just provide a mock that does what I need it to and nothing more. First, set up your mock. I chose to use Test::Mock::LWP::Dispatch, because I liked the interface.

use Test::Mock::LWP::Dispatch;
use HTTP::Response;
use HTTP::Headers;

# Not an actual LWP::UserAgent - mock exported by Test::Mock::LWP::Dispatch
$mock_ua->map(
    'http://localhost/', # when this is requested...
    sub { # run this subroutine reference to create the corresponding response
        my $req = shift;
        die 'Unsupported method: ' . $req->method unless $req->method eq 'GET';

        my $uri = $req->uri;
        my $html = do { open my $in, '<:raw ', 't/test.html' or die $!; local $/; };
        return HTTP::Response->new(200, 'OK',
        HTTP::Headers->new('Content-Length' => length $html), $html);
    },
);

And then use the mock:

my $crawler = My::Crawler->new(
ua => $mock_ua,
);
my $data = $crawler->crawl;

isa_ok($data, 'HASH');
is((keys %$data)[0], 'http://localhost/', 'URL fetched OK');
# ...

The bottom line

Instead of creating your own type constraint that lets in any LWP::UserAgent and any Test::MockObject, you can get Test::Mock::LWP::Dispatch to give you a mock that does pass the simple isa test:

use Test::Mock::LWP::Dispatch ();   # Don't allow any exports
my $mocked = LWP::UserAgent->new(); # Mock, from Test::Mock::LWP::Dispatch
isa_ok($mocked, 'LWP::UserAgent');  # Passes
my $crawler = My::Crawler->new(
    ua => $mocked # Passes the isa => 'LWP::UserAgent' constraint
);

This appears to be an undocumented difference between the exported mock, and a mock you create. So, just don’t use the exported mock object (and keep it from being exported with use Test::Mock::LWP::Dispatch ();)

This doesn’t appear to work for Test::Mock::LWP (which has a fugly interface anyways, ::Dispatch is nicer):

mike@charron:~/code/git/DOHERTY/WWW-3172 (master *)$ perl -MTest::More -MTest::Mock::LWP::Dispatch -E 'isa_ok(LWP::UserAgent->new, "LWP::UserAgent"); done_testing;'
ok 1 - The object isa LWP::UserAgent
1..1
mike@charron:~/code/git/DOHERTY/WWW-3172 (master *)$ perl -MTest::More -MTest::Mock::LWP -E 'isa_ok(LWP::UserAgent->new, "LWP::UserAgent"); done_testing;'
not ok 1 - The object isa LWP::UserAgent
# Failed test 'The object isa LWP::UserAgent'
# at -e line 1.
# The object isn't a 'LWP::UserAgent' it's a 'Test::MockObject'
1..1
# Looks like you failed 1 test of 1.