Cleaning up the Test::Class::Moose base class

I'm quite enjoying Test::Class::Moose. It's very easy to use and it gives you such fine-grained control over your test suite and powerful reporting capabilities that it's turning out to be far more powerful than I had expected. It's actually easy enough to use for beginners, but power users will really appreciate it. There was, however, a major issue I had with it and it stems from a habit I picked up from Test::Class.

For those who are very familiar with using Test::Class (or if you've read my Test::Class tutorial), you may be used to seeing a base class that looks like this:

package My::Test::Class;
use parent 'Test::Class';

INIT { Test::Class->runtests }

sub startup  : Tests(startup)  {}
sub setup    : Tests(setup)    {}
sub teardown : Tests(teardown) {}
sub shutdown : Tests(shutdown) {}

1;

The empty test control methods are there so that a subclass knows it can always safely do this:

sub startup : Tests(startup) {
    my $test = shift;
    $test->next::method;
    # more code here
}

Those stub test methods are no longer needed with Test::Class::Moose, but until today, that INIT block was an annoying code smell that had some unfortunate side-effects. Let's make that go away.

The INIT Phase

First of all, what does that INIT block do?

INIT { Test::Class->runtests }

In Test::Class, that would simply run all loaded test classes at INIT time. However, that means you could do this:

prove -lv t/lib/path/to/my/test/class.pm

And voilà, you test class could be executed just like it's a *.t program. That's because it would be loaded, it would inherit from your base class, the INIT block would fire and Rube Goldberg would be laughing in his grave.

Sadly, this leads to nasty bugs like this one I blogged about almost 3 years ago. Basically, if you use $some::module, the code in that module is executed before INIT fires but the subroutines are not (aside from import()). However, if you load the code directly with, say perl t/lib/path/to/my/test/class.pm, the INIT block fires before the code in the module is executed. You can read perldoc perlmod for more information about INIT and friends.

How does this impact Moose?

At first glance, you might not think issues with INIT and friends would impact Moose, but there are a couple of subtle repercussions. For example, we know that subroutine attributes only fire at compile time. That's why the Moose manual explains how to use inherited subroutine attributes with Moose. In short, you have to ensure that you're inheriting at compile-time, not at CHECK/INIT/UNITCHECK or runtime:

BEGIN { extends 'Some::Class' }

Since Moose isn't part of the Perl language and much of its behavior fires at runtime, you can have some strange issues, such as when I wrote the following code:

package TestsFor::Person::Employee;
use Test::Class::Moose extends => 'TestsFor::Person';

sub extra_constructor_args {
    return ( employee_number => 666 );
}

BEGIN {
    after 'test_constructor' => sub {
        my $test = shift;
        is $test->test_person->employee_number, 666,
          '... and we should get the correct employee number';
    };
}

1;

That's really ugly and, in fact, I received an email from someone who called me on that, arguing that Test::Routine and Test::Roo were simpler in this case. I think he was right and I thought for a while about the best way to resolve this. You shouldn't have to think about timing issues like this when you're writing your test classes.

A Cleaner Test::Class::Moose

Today I've released Test::Class::Moose version 0.11 and it makes this case easier to handle. Your base class now can look like this:

package My::Test::Class;
use Test::Class::Moose;
1;

In fact, you may not even need a base test class at all — just have your classes use Test::Class::Moose directly — but a base test class is handy enough that people put all sorts of interesting code in there.

So how do you do the INIT trick that lets you run an individual test class? I now recommend use of a driver *.t script for your test suite. Here's what one might look like:

use Test::Class::Moose::Load 't/lib';
my $test_suite = Test::Class::Moose->new(
     show_timing  => 1,
     randomize    => 0,
     statistics   => 1,
     test_classes => $ENV{TEST_CLASS},
)->runtests;

my $report = $test_suite->test_report;
# the reporting object, of course, can be ignored if you
# don't need reporting on the test suite

Unlike Test::Class, Test::Class::Moose has a constructor that lets you control the test suite behavior prior to running the suite. There is a new attribute, test_classes which takes the name of a test class or an array reference of test class names. Only those test classes will be run. Yes, you could have written a Test::Class::Moose subclass to control this, but I wanted to make this built-in because running individual classes is a common use case. Now, instead of this:

prove -lv t/lib/path/to/my/test/class.pm

You do this:

TEST_CLASS=Class::I::Want::To::Run prove -lv t/test_class_tests.t

And things work just like the are supposed to. As a bonus, that nasty BEGIN block in my TestsFor::Person::Employee class goes away:

package TestsFor::Person::Employee;
use Test::Class::Moose extends => 'TestsFor::Person';

sub extra_constructor_args {
    return ( employee_number => 666 );
}

after 'test_constructor' => sub {
    my $test = shift;
    is $test->test_person->employee_number, 666,
      '... and we should get the correct employee number';
};

1;

And your test classes now look like proper Moose code rather than worrying about BEGIN versus INIT phases.

I'll be giving a talk about Test::Class::Moose at YAPC::NA and I've also proposed it for YAPC::EU. I hope to see you there!

4 Comments

Wouldn’t it be easier (and more featureful!) to say “test_classes => \@ARGV,” and then do the following?

prove -lv t/test_class_tests.t :: Class::I::Want::To::Run

Then how did the code handle the case of empty $ENV{'TEST_CLASS'}?

Leave a comment

About Ovid

user-pic Have Perl; Will Travel. Freelance Perl/Testing/Agile consultant. Photo by http://www.circle23.com/. Warning: that site is not safe for work. The photographer is a good friend of mine, though, and it's appropriate to credit his work.