Unit testing - meeting the Feathers standard
In a posting about unit testing a few months ago (Real unit tests, and bugs that go THWACK), I talked about Michael Feathers’ rules for unit tests, from page 12 in Working Effectively with Legacy Code. He also has a 2005 blog posting about it (A Set of Unit Testing Rules), where he added one more rule, which I’ll come back to in a minute. Feathers says it’s not a unit test if…
- It talks to the database
- It communicates across the network
- It touches the file system
- You have to do special things to your environment (such as editing config files) to run it.
I don’t think these rules get nearly enough attention. But I’m proud to say that I’m one step closer to meeting the standard in my Perl code, because I’ve found a way to avoid the filesystem. I’ll use a trivial little class as example. This class in the file “foo.pm” reads in a text file with lines formatted like this - “name=value”, and has a method for returning the value for a given name:
use strict;
use warnings FATAL => 'all';
package foo;
sub new {
my $class = shift @_;
my $file = shift @_;
my %self;
open FILE, $file or die "open '$file': $!\\n";
while (<file>) {
chomp;
/^(\w)+=(.*)$/ || die "syntax error in '$_'\\n";
$self{$1} = $2;
}
bless \%self, $class;
}
sub get {
my $self = shift @_;
my $name = shift @_;
return $self->{$name};
}
1;
To avoid using the filesystem in the unit tests for this code, I need to mock out the filesystem operations. I imagine that there’s a way to override Perl’s builtin file operators like open and <>, but I found it easier to just change my code to use object-oriented file operations that will be easier to mock. So I changed the code like so:
use strict;
use warnings FATAL => 'all';
use IO::File;
package foo;
sub new {
my $class = shift @_;
my $fileName = shift @_;
my %self;
my $file = new IO::File $fileName or die "open '$fileName': $!\\n";
while (defined ($_ = $file->getline)) {
chomp;
/^(\w)+=(.*)$/ || die "syntax error in '$_'\\n";
$self{$1} = $2;
}
$file->close;
bless \%self, $class;
}
sub get {
my $self = shift @_;
my $name = shift @_;
return $self->{$name};
}
1;
The behavior hasn’t changed, but now it’s easier to test. Here’s my fooTest.pm file with the beginning of a unit test suite -
package fooTest;
use strict;
use warnings FATAL => 'all';
use base 'Test::Unit::TestCase';
use Test::MockObject;
my $iomock;
BEGIN {
$iomock = Test::MockObject->new;
$iomock->fake_module("IO::File",
getline => sub { },
);
$iomock->set_true("close");
$iomock->fake_new("IO::File");
}
use foo;
sub new {
my $self = shift()->SUPER::new(@_);
return $self;
}
sub testEmpty {
my $self = shift @_;
$iomock->set_false("getline");
my $foo = new foo("dummy");
$self->assert_equals(undef, $foo->get("nothing"));
}
sub testTwoLines {
my $self = shift @_;
$iomock->set_series("getline", "a=1\\n", "b=2");
my $foo = new foo("dummy");
$self->assert_equals("1", $foo->get("a"));
$self->assert_equals("2", $foo->get("b"));
}
sub testBadSyntax {
my $self = shift @_;
$iomock->set_series("getline", "*=1\\n");
eval {
new foo("dummy");
};
my $err = $@;
$self->assert_matches(qr/syntax error/i, $err);
}
This test is based on the xUnit-style Test::Unit::TestCase, which requires just a bit more code to actually get it to do something -
>perl -e "use Test::Unit::TestRunner; Test::Unit::Test Runner->new->start(@ARGV)" fooTest.pm ... Time: 0 wallclock secs ( 0.05 usr + 0.02 sys = 0.06 CPU) OK (3 tests)
The tests use the Test::MockObject module to handle the dirty work of mocking the standard IO::File module. Instead of creating temporary files to use as test input, I simply tell the mock object what to return from the getline method. So I can stay out of the filesystem and not worry about creating and cleaning up temporary files. This also makes it easier to run the tests in parallel. In fact, here’s the extra rule that Feathers added in his blog post - it’s not a unit test if…
- It can’t run at the same time as any of your other unit tests
I don’t think my test above meets this standard, because the $iomock object is shared among all the tests. I’m not sure yet how to overcome that limitation. Also, some of the MockObject library is still a mystery to me, like how to mock the constructor for a class. But I’m one step closer to the Feathers standard.