Name
Test::Consumer::Contract - check a reader against the keys its producer promises
Synopsis
A distribution declares what its value object promises, who produces it, who reads it, and why each exception is safe:
use Test::Consumer::Contract qw(run_consumer_contract);
run_consumer_contract(
source => 'Widget::Maker',
value_object => {
class => 'Widget',
from => { latitude => 1, longitude => 2 },
derives => { x => 1, y => 2 },
},
guaranteed => [qw(x y)],
conditional => { colour => 'only the painted path sets it' },
producers => { 'lib/Widget/Maker.pm' => [qw(x y colour)] },
consumers => {
'Widget-User/lib/Widget/User.pm' => {
may_rely_on => {
render => { colour => 'defaults to undef on purpose' },
},
},
},
ignored => [['Widget-List/', 'reads its own normalised output']],
accessors => [qw(x y)], # documented accessors a consumer may read
not_accessors => { # documented, but not a slot of one result
new => 'the constructor',
ACCURACY_LEVELS => 'a class constant',
},
inspection => [qw(can isa DOES)],
);
done_testing();
Then it runs these checks:
-
- the producers still set the keys the contract claims
Each file named in
producers(andworkspace_producers) is read and each key it is responsible for must still be assigned there, so a producer that quietly stops stamping a key fails. -
- registered consumers read only what every producer sets
Each file named in
consumersis scanned for reads of a result. A read of a key inguaranteedis fine; a read of anything else is reported with its file, line, sub and the reason the key is a problem, unless the read is registered undermay_rely_onkeyed by the name of the enclosing sub. An entry that is no longer read is reported as stale, so the registry keeps describing the code. -
- every discovered consumer is registered
Files in sibling distributions that receive a result but are named in neither
consumersnorignoredfail the check, so a new consumer cannot slip past check 2 unnoticed. -
- the accessor lists match the value object, in both directions
The value object's documented accessors are the
=head2(and deeper) headings of its source and the public subs it defines. Each one the declaration names inaccessorsornot_accessorsmust be an accessor the value object really has, and - the direction that matters when a value object grows - each one the value object documents must be accounted for inaccessors,not_accessorsorconditional.conditionalis where a documented accessor belongs when only some results carry it. So a value object that gains a documented accessor cannot leave the declaration behind: the check fails, naming the accessor and the three places to put it.
Checks 2 and 3 need the sibling distributions that share a workspace; on a
CPAN-style install they skip with a diagnostic and only check 1 runs. Check 4
needs the value object's own file, which include puts in reach. This is a
static, lexical check: it reads source, it does not run the producer.
Requirements
The caller must load a test module (Test::Most is what this module uses) and
call done_testing() after run_consumer_contract(); this module adds
subtests but does not end the plan.
New
Takes the declaration above as a list. source and guaranteed are
required; every other option has a default:
-
dist_rootThe distribution being checked. Defaults to the parent of the directory holding the running test file, falling back to the current directory when that is not a distribution root.
-
workspaceThe directory holding the sibling distributions. Defaults to the parent of
dist_root.Every path in a declaration - in
include,producers,workspace_producers,consumersandignored- is written with/between the directories, whatever separator the platform itself uses, and the paths inconsumersandignoredare relative to the workspace. The checks name files the same way, so a path can be copied from a failure message into the declaration unchanged. -
includeWorkspace-relative lib directories to put at the front of
@INC, so a value object is read from the checkout in the workspace rather than from an installed copy. -
conditionalKey => why it is not guaranteed, used in the failure messages.
-
workspace_producersLike
producers, but paths are relative to the workspace: producers in a sibling distribution that this distribution's value object comes from. -
not_accessorsDocumented accessors of the value object that are not a slot of one result - the constructor, a class constant - as name => why, so that the accessor check can be satisfied by saying what a name is instead of listing it as something a consumer may read. A name here that the value object does not document fails, as an entry in
accessorswould. -
methodsThe calls that hand a result to the caller;
geocodeandreverse_geocodeby default. A variable bound from one of these is followed, as is a variable passed to a sub as its first parameter. -
require_workspaceSibling distribution names that must exist for checks 2 and 3 to run. Pass the ones your consumers live in, or those checks cannot tell a missing checkout from a passing one.
Unknown options are an error rather than being silently ignored.
Run
Runs the three checks as subtests. Called for you by
run_consumer_contract().
Run_Consumer_Contract
my $contract = run_consumer_contract(%declaration);
Builds the contract and runs it, returning the object.
Workspace_Present
True when every distribution named in require_workspace is in the workspace.
Sets_Key
$contract->sets_key($source_text, 'database')
True when the text assigns the key, in any of the spellings a producer might
use: key => value, 'key' => value, $obj->{'key'} = value.
Strip
Returns the source with POD and comments replaced by blank space, preserving line numbers.
Reads_of_Results
my @reads = $contract->reads_of_results($stripped_text);
Every ->{key} and ->method() read of a variable that holds a
result, as [key, line, kind, sub]. A variable holds one if it is bound from a
geocode() or reverse_geocode() call, or if it is the first parameter of a
sub that this file calls with such a variable - so a helper that takes the result
still counts, whatever it names it. One level of that is followed, which is
enough for an adapter; a deeper chain is a sign the read belongs in the
consumer's registry entry.
Documented_Accessors
my @names = $contract->documented_accessors($source);
Every name the value object's source documents as part of its interface: its
=head2 (and deeper) headings and the public subs it defines. =head1
headings are left out - they are NAME, VERSION, SEE ALSO and the rest of the
boilerplate, which document no accessor - and so are the private subs, whose
leading underscore keeps them out of the interface. A method the value object
documents but implements through AUTOLOAD is found by its heading, which is
what makes this the list of what is documented rather than of what is compiled.
Limitations
- The scanner is static and lexical. It follows reads on variables bound from a producing call plus one level of call into a helper; a result that is stored in a hash, returned, or passed through two helpers is not followed.
- Discovery requires the file to name the source distribution. A consumer
that is handed results without naming the distribution is not picked up; add it
to
consumersby hand. - Files in the
dist_rootdistribution are not scanned for consumption - its own sources are treated as the producer. A consumer that lives in the producing distribution is not discovered. - The producer evidence is static: a producer that sets a key by a route the three recognised spellings do not cover is reported as not setting it.
require_workspaceis what tells a missing checkout apart from a passing one. With nothing to require, a distribution checked on its own has no sibling consumers to find and the discovery check will fail on finding none.- The accessor check reads the value object's own file, so an accessor it
inherits, or documents in another file, is not seen. A heading or sub that names
something which is not an accessor of a result - a constructor, a constant - has
to be listed in
not_accessors, which is what that option is for; the check then holds the entry to the value object, so a renamed or removed name fails rather than sitting in the list unread.
Author
Nigel Horne, <njh at nigelhorne.com>
License and Copyright
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms.