No more copy and paste: How to refactor tests with roles

Reading time: 13 minutes

Raise your hand if you’ve ever cut and paste a huge chunk of code — or even a whole file — for testing. I have. And I feel guilty, because I know the DRY mantra: “Don’t repeat yourself!” But somehow, rules we follow for our application code, we forget for our test code.

Here’s a recent example from my own Dancer2::Plugin::Queue. It provides a role, Dancer2::Plugin::Queue::Role::Queue, that any particular backend must implement. I wrote a trivial implementation and some tests for it.

Then it was time to write a backend: Dancer2::Plugin::Queue::MongoDB. And write tests for it. (Can you see where this is going?)

The easiest thing would be to copy the .t files from Dancer2::Plugin::Queue to Dancer2::Plugin::Queue::MongoDB. And that’s exactly what I didn’t want to do.

What did I do instead? I wrote my tests as a role.

What are roles? 🔗︎

If you’ve used Moose or Moo, then you should already know. If you haven’t, then in a nutshell, a Role is a composable unit of behavior. It is not a class, but you can combine roles as part of a class. If you think of inheritance defining an “is-a” relationship, instead a role defines a “does-a” relationship. A class “does” a role, or many roles.

Moose and Moo roles can have attributes and methods just like classes. They can also require methods to be provided by the class that consumes them. This allows for easy, powerful, reusable behavior encapsulation.

Why are roles helpful in testing? When should you use them? 🔗︎

If you think about it, a test describes behavior. Given X, does it do Y? So roles are a very natural way to model and encapsulate tests. A test role can define attributes necessary for conducting the tests (aka “fixtures”), any necessary state initialization, and the tests themselves.

Not every test needs to be written as a role. Even repetitive tests can be written procedurally. Lots of my test files look more or less like this:

use strict;
use warnings;
use Test::More;

my @cases = (
    {
        label => "first case",
        inputs => { ... },
        expected => { ... },
    },
    ...
);

for my $c ( @cases ) {
   # do lots of tests given inputs and expectations
}

Roles are really powerful when you want to test similar behaviors in different *.t files or even different distributions. If the guts of that for loop above would need to be repeated, then that’s a good sign that roles might be a good abstraction.

Even then, you might not need roles. There’s nothing wrong with a test library containing subroutines. For me, roles are the right choice when I need to start passing a lot of state to my tests, or when I have to do a lot of setup or tear down around my tests.

How can I use roles in testing? 🔗︎

Instead of writing a test library, you could write a test class (or more than one) and use Moose or Moo roles to compose your behaviors. But once you’ve started to consider that, you should instead jump straight to one of these modules, which make it really easy:

Which one? Either one. I’m slightly biased because I wrote Test::Roo, but I did it because I wanted something like Test::Routine for non-Moose projects where I didn’t want Moose as a test dependency. If you get familiar with Test::Roo, then you can use it for either Moose or Moo based projects, which I think is convenient.

Ovid has recently released Test::Class::Moose. I know nothing about it, so can't recommend it, but Ovid is a smart guy who knows a lot about large-scale testing, so it might be worth investigating as well.

When shouldn’t I use these modules? 🔗︎

While they offer some nice syntactic sugar even in simple cases, they do add some complexity that might not be worth it for simple tests. While both Moose and Moo are popular, powerful OO frameworks, you might not want to depend on them just for your tests. (Obviously, if your code already uses Moose or Moo, then using Test::Routine or Test::Roo is a no-brainer.)

Examples 🔗︎

I’ll give some example from the Test::Roo::Cookbook. These are also in the ‘examples’ directory of the repository and CPAN distribution.

Intro to Test::Roo 🔗︎

This example introduces you to Test::Roo. It doesn’t actually use any roles, but you need to grok this one to follow the later ones. I’ll annotate it with comments more heavily than in the original.

# loading Test::Roo loads Moo, strictures and Test::More, and makes the current
# package (main) a subclass of Test::Roo::Class
use Test::Roo;

use MooX::Types::MooseLike::Base qw/ArrayRef/;
use Path::Tiny;

# here is a fixture -- a Moo attribute for a text file to use for testing; note that
# it is 'required', so it must be provided in the constructor arguments 
has corpus => (
    is       => 'ro',
    isa      => sub { -f shift },
    required => 1,
);

# another fixture; this one is lazy and caches lines from the corpus on demand
has lines => (
    is  => 'lazy',
    isa => ArrayRef,
);

# here is the builder that actually loads the lines
sub _build_lines {
    my ($self) = @_;
    return [ map { lc } path( $self->corpus )->lines ];
}

# this is the first test -- whether the lines of the corpus are sorted;  the
# first call to 'lines' caches the lines of the file so it is only read
# once for the life of the test object
test 'sorted' => sub {
    my $self = shift;
    is_deeply( $self->lines, [ sort @{$self->lines} ], "alphabetized");
};

# this is the second test -- whether all letters are accounted for in the corpus;
# it uses the cached lines from the first test
test 'a to z' => sub {
    my $self = shift;
    my %letters = map { substr($_,0,1) => 1 } @{ $self->lines };
    is_deeply( [sort keys %letters], ["a" .. "z"], "all letters found" );
};
 
# this instantiates the current package into an object (with the required argument)
# and runs all the tests
run_me( { corpus => "/usr/share/dict/words" } );

# tell Test::More that there are no more tests
done_testing;

Creating a test role with Test::Roo 🔗︎

Now that you’re a little familiar with Test::Roo syntax, I’ll give a real example with a test role, based on how I might test a file-finder like Path::Iterator::Rule.

I didn't actually write Path::Iterator::Rule tests like this, because I hadn't written Test::Roo yet. In hindsight, I wish I had. Just writing the example for the cookbook revealed a bug affecting subclasses.

Testing a file finder requires setting up a temporary directory, creating a bunch of files within it, and testing what files or directories are found for a given set of rules. Path::Iterator::Rule was designed to be subclassed, so that I could turn Path::Class::Rule into a subclass that worked with Path::Class objects.

Right away, I can tell that this is good for role-based testing. Lots of state (list of files to create) needed for the test? Check! Lots of setup (creating the files)? Check! Needs to be reusable by subclasses? Check!

I’ll discuss the test role file in sections.

First, we set up the test role as a package, load in Test::Roo::Role for a standalone role, and bring in the dependencies:

package IteratorTest;
use Test::Roo::Role;
 
use MooX::Types::MooseLike::Base qw/:all/;
use Class::Load qw/load_class/;
use Path::Tiny;

Then, we define two required attributes – the iterator class to test and the “result type” that indicates whether files found are objects and if so, of what type.

has [qw/iterator_class result_type/] => (
    is       => 'ro',
    isa      => Str,
    required => 1,
);

Next, we define a list of test files to create and a temporary directory to hold them. While the list has a default value, note that it could be changed during object construction for more specialized testing.

has test_files => (
    is      => 'ro',
    isa     => ArrayRef,
    default => sub {
        return [
            qw(
            aaaa
            bbbb
            cccc/dddd
            eeee/ffff/gggg
            )
        ];
    },
);
 
has tempdir => (
    is  => 'lazy',
    isa => InstanceOf ['Path::Tiny']
);

We also need a object for the iterator we are testing. This one is lazy and will just be an instance of the iterator_class.

has rule_object => (
    is      => 'lazy',
    isa     => Object,
    clearer => 1,
);

For all our lazy attributes, we now need some builders. I’ll add some extra comments to annotate them.

# gives our Test::More subtest a dynamically determined label
sub _build_description { return shift->iterator_class }

# creates the temporary directory *and* creates all the files in it
sub _build_tempdir {
    my ($self) = @_;
    my $dir = Path::Tiny->tempdir;
    $dir->child($_)->touchpath for @{ $self->test_files };
    return $dir;
}
 
# creates the empty rule object
sub _build_rule_object {
    my ($self) = @_;
    load_class( $self->iterator_class );
    return $self->iterator_class->new;
}

Up to this point, the role has just been defining requirements and fixtures. Next, it’s time to define test behaviors.

First, there is a method to test result types, since that needs some different logic depending on whether objects or strings are returned. This isn’t a ‘test’ declaration, but it will be called from them later.

sub test_result_type {
    my ( $self, $file ) = @_;
    if ( my $type = $self->result_type ) {
        isa_ok( $file, $type, $file );
    }
    else {
        is( ref($file), '', "$file is string" );
    }
}

Finally, we get a simple test definition that checks if finding all files gets the same list of files used to create the testing directory.

test 'find files' => sub {
    my $self = shift;
    $self->clear_rule_object; # make sure have a new one each time
 
    $self->tempdir;
    my $rule = $self->rule_object;
    my @files = $rule->file->all( $self->tempdir, { relative => 1 } );
 
    is_deeply( \@files, $self->test_files, "correct list of files" )
    or diag explain \@files;
 
    $self->test_result_type($_) for @files;
};

# ... more tests ...

1;

If we had more tests, we’d add them after that initial test.

If I were doing this "for real", I would refactor the temp directory construction into a separate role, so that I could have different roles for different behaviors without having to repeat my fixture code, either. Roles composing roles FTW! This is a great use for roles even without even considering the benefits for subclass testing.

Given that test role, testing it for a given implementation in a *.t file just looks like this:

use Test::Roo;
use lib 'lib';
 
with 'IteratorTest';
 
run_me(
    {
        iterator_class => 'Path::Iterator::Rule',
        result_type    => '',
    }
);
 
done_testing;

And then the subclass *.t file is the same thing, just with a different set of constructor arguments.

use Test::Roo;
use lib 'lib';
 
with 'IteratorTest';
 
run_me(
    {
        iterator_class => 'Path::Class::Rule',
        result_type    => 'Path::Class::Entity',
    },
);
 
done_testing;

Test role for Dancer2::Plugin::Queue 🔗︎

Now that you’ve seen the basics of Test::Roo, I’ll show you how I wrote my Dancer2::Plugin::Queue test role. For the blog, I’ll strip out some the bits less relevant to using test roles (including the actual server setup), but you can see all of it in Dancer2::Plugin::Queue::Role::Test.

package Dancer2::Plugin::Queue::Role::Test;

use Test::Roo::Role;
use MooX::Types::MooseLike::Base qw/Str HashRef CodeRef/;

use Dancer2 ':syntax';
use Dancer2::Plugin::Queue;
use HTTP::Tiny;
use Test::TCP;

has backend => (
    is       => 'ro',
    isa      => Str,
    required => 1,
);

has options => (
    is      => 'lazy',
    isa     => HashRef,
);

sub _build_options { }

has _server => (
    is  => 'lazy',
    isa => CodeRef,
);

sub _build__server {
    my ($self) = @_;
    return sub { ... }; # details elided for the blog post
}

test 'queue and dequeue' => sub {
    my $self = shift;
    test_tcp(
        client => sub {
            my $port = shift;
            my $url  = "http://localhost:$port/";
            my $ua = HTTP::Tiny->new;
            my $res = $ua->get( $url . "add?msg=Hello%20World" );
            like $res->{content}, qr/Hello World/i, "sent and receieved message";
        },
        server => $self->_server,
    );
};

1;

The *.t file just overrides the options builder method, composes the test role, and invokes it for the trivial “Array” backend. It looks like this:

use Test::Roo;
 
sub _build_options { return { name => 'foo' } }
 
with 'Dancer2::Plugin::Queue::Role::Test';
 
run_me( { backend => 'Array' } );
 
done_testing;

Summary 🔗︎

If you don’t like copy-and-paste coding, roles are a great way to compose behaviors for testing. If you’re already used to Moose or Moo — or using them in your code — now it’s easy to use roles for testing, too.

Oh, yeah, remember that queue plugin, Dancer2::Plugin::Queue::MongoDB, the one where I was thinking to copy and paste the test? I’ve already shown you the test role. Now I’ll show you how I used it.

Most of the code below is just fixture setup — making sure there is a MongoDB and clearing a test database for the test run. Again, I’ll annotate it a bit more than the original.

use Test::Roo;
use MooX::Types::MooseLike::Base qw/:all/;

use MongoDB 0.45;
use MongoDBx::Queue;

# We use this client for checking for a DB and dropping tables before a run;
# it's lazy so we can test creating it in an eval and skip tests otherwise
has client => (
    is => 'lazy',
    isa => InstanceOf['MongoDB::MongoClient'],
);

sub _build_client {
    MongoDB::MongoClient->new;
}

has db_name => (
    is => 'ro',
    isa => Str,
    default => sub { 'test_dancer_plugin_queue_mongodb' },
);

# this overrides the test role's builder to set the right options in the server
# for this backend implementation
sub _build_options {
    my ($self) = @_;
    return { db_name => $self->db_name };
}

# Here's where we actually check that the database exists.  We try to
# build the lazy client attribute, and skip tests if it fails.
sub BUILD {
    my ($self) = @_;
    eval { $self->client }
        or plan skip_all => "No MongoDB on localhost";
}

# I didn't show this part of Test::Roo, but this runs before any tests
# to ensure the test table is dropped
before setup => sub {
    my $self = shift;
    my $db   = $self->client->get_database($self->db_name);
    my $coll = $db->get_collection('queue');
    $coll->drop;
};

# This runs after all tests to clean up
after teardown => sub {
    my $self = shift;
    my $db   = $self->client->get_database($self->db_name);
    $db->drop;
};

# This composes the test role for us to run
with 'Dancer2::Plugin::Queue::Role::Test';

run_me({ backend => 'MongoDB' });
done_testing;

See? The .t files is all fixture management and the test behaviors stay in the role from the superclass. As the test role gets better or bugs are fixed, the tests for the implementations get better, too.

I don’t have to copy and paste a thing.

My advice to you 🔗︎

If you made it to the end of this long article, I’ll guess that you’re intrigued or maybe even excited. Here’s what you need to do next:

  • Install Test::Roo. Go on. Do it. Right now.
  • Copy the first example from the cookbook into a file. (Install Path::Tiny while you’re at it.)
  • Run it! Then tweak the code and run it again.

That’s all you need to get started. Try splitting the test from that file out into a role in another .pm file and compose it back to the .t file using with. Run it again!

Now that you see how it works, the next time you are about to cut and paste some test code, try roles instead.

Good luck, and stay DRY!

Additional Reading 🔗︎

•      •      •

If you enjoyed this or have feedback, please let me know by or