keeping objects healthy with object::exercise
TRANSCRIPT
Recall that Lazy-ness is a virtue
Hardcoded testing is a pain.
Tests require tests...
Alternative: Data-driven testing.
Declarative specs.
Generated tests.
We have all written this:
Create an object.
Run a command.
Check $@.
Execute a test.
Run a command...
The test cyclemy $obj = $class->new( @argz );
eval{
my $expect = { qw( your structure from hell ) };my $found = $obj->foo( @foo_argz );cmp_deeply $found, $expect, "foo";
};BAIL_OUT "foo fails" if $@;
eval{
my $expect = [ { qw( another structure from hell ) } ];my $found = [ $obj->bar( @bar_argz ) ];cmp_deeply $found, $expect, "bar";
};BAIL_OUT "bar fails" if $@;...
MJD's “Red Flags”
Code is framework, probably updated by cut+paste.
Spend more time hacking framework than module.
Hardwiring the the tests requres testing the tests.
Troubleshooting the tests...
Updating the tests.
Fix: Add a level of abstraction.
Put loops, boilerplate into framework.
Data drives test loop.
Metadata used to generate test data.
Replace hardwired tests with data.
Abstraction: Object::Exercise
A “little language”.
Describe a call, sanity checks.
Data drives the tests.
Array based for simpler processing.
Perl makes this easy
Perl can dispatch $object->$method( ... ).
The $method scalar can be
Text for symbol table dispatch.
Subref for direct dispatch into code.
Text for whitebox, Subref for blackbox.
Planning your exercise.
An array of steps.
Each step is an operation or directive.
Directives are text like “verbose”.
Operations are arrayref's with a
Method + args.
Expect & outcome.
Entering the labrynth
Entry point is a subref.
Avoids namespace collisions with methods:
$object->$exercise( @testz );
initiates testing.
Perly One-Step
Nothing more than a method and arguments:
[
method => arg, ...
]
Executes $obj->$method( arg, … ).
Successful if no exceptions.
Testy Two-Step
Supply fixed return as arrayref:
[
[ method => arg, ... ],
[ compare values ],
]
[ 1 ], [ undef ], [ %struct_from_hell ]
Compared to [ $obj->$method( @argz ) ].
Expecting failure
Testing failure modes raises exceptions.
Explicit undef expects eval { ... } to fail.
Reports successful exception:
[
[ method => @bogus_values ],
undef
]
Saying it your way
Third element is a message:
[
[ $method, @blah_blah ],
[ 42 ],
“$method supplies The Answer”
]
Great expectations
Beyond fixed values: regexen, subrefs.
qr/ ... / $found->[0] =~ $regex ? pass : fail ;
sub { ... } $sub->( @$found ) ? pass : fail ;
Generated regexen, closures simplify testing.
Both can have the optional message.
Giving orders
Directives are text scalars.
Turn on/off verbosity in tests.
Set a breakpoint before calling $method.
Treat input as regex text and compile it.
One-test settings
Text scalars before first arrayref are directives.
Test frobnicate verbosely, rest no-verbose:
noverbose =>
[
verbose =>
[ qw( frobnicate blah blah ) ]
],
One-test breakpoint
Adds $DB::Single = 1 prior to calling $obj->$method.
Simplifies perl -d to check a single method.
[
debug =>
[ qw( foobar bletch blort ) ],
[ 1 ]
]
Regexen in YAML::XS
YAML::XS segfaults handling stored rexen.
“regex” directive compiles expect value as regex:
[
regex =>
[ qw( foobar bletch) ],
'[A-Z]\w+$',
]
$obj->foobar( 'bletch' ) =~ qr/[A-Z]\w+$/;
Result: Declariative Testing
Tests can be stored in YAML, JSON.
Templates can be expanded in code.
Text methods useful for “blackbox” testing.
Validate what method returns.
Coderef's useful for “whitebox” testing.
Investigate internal structure of object.
Reset incremental values for iterative tests.
Example: Adventure Map Test
Minimal map: One locations entry.
name: Empty Map 00namespace : EmptyMap00locations: blackhole: name: BlackHole description : You are standing in a Black Hole. exits : Out : blackhole
Override the map contents
One way: Store separate mission files.
Every test needs to be replicated.
Two way: Replace the map.
Override the method returning locations.
Init reads the map
"add_locations" processes the map:
sub init { my ($class, $config_path) = @_; die "You must specify a config file to init()" unless defined $config_path; $_config = YAML::LoadFile($config_path); $class->add_items($_config->{items}); $class->add_actors($_config->{actors}); $class->add_locations($_config->{locations}); $class->add_player('player1', $_config->{player});}
Adding locations is a loop
They are added one-by-one.
sub add_locations{ my ($class, $locations) = @_; foreach my $key (keys %{$locations}) { $class->add_location($key, $locations->{$key}); }}
Adding one location
Finally: The root of all evil...
New locations are added with a key and struct:
sub add_location{ my ($class, $key, $config) = @_; my $location = Adventure::Location->new; $location->init($key, $config); $class->locations->{$key} = $location;}
Faking the location
sub install_test_location{
my $package = shift;my $test_struct = shift;*{ qualify_to_ref add_location => $package }= sub{
my ($class, $key ) = @_;$class->locations->{$key}= Adventure::Location->new->init($key, $test_struct ); # hardwired map
};}
Aside: Generic “add” handler
# e.g., install Adventure::Location::add_locationsub add_thingy{
my ( $parent, $thing ) = @_;my $name = ‘add_’ . $thing;my $package = qualify ucfirst(lc $thing), $parent;
*{ qualify_to_ref $name => $package }= sub{
my ($class, $name, $data ) = @_;$class->$type->{$name}= $package->new->init($name, $data );
};}