Test doubles that don't leak.
Two ways to stand in for collaborators: double(...) creates a new
stand-in from scratch; allow(obj).to.receive('method') stubs a
method on an existing object or class. Every stub installed in an example is
auto-uninstalled when the example ends, so they never bleed across specs.
#double(), ad-hoc test doubles
The simplest form takes a name (cosmetic, for error messages) and a list of
method => value pairs. Unstubbed methods return Any and
record the call, so doubles are permissive by design.
describe 'order summary', {
it 'reads name and total from a user double', {
my $user = double('User', name => 'alice', total => 99);
expect($user.name).to.be('alice');
expect($user.total).to.be(99);
}
}
If the stub value is a Callable, the double invokes it with the call's arguments:
my $upper = double('Upper', shout => -> $s { $s.uc });
expect($upper.shout('hi')).to.be('HI');
#verifying class-based doubles
Pass a class as the first argument and the double becomes verifying: every stubbed method must exist on the class, and dispatching a method the class doesn't have dies. This catches typos and stubs that drift out of sync with the real implementation.
class Greeter {
method hello($name) { "hello, $name" }
}
it 'stubs only methods that exist on the class', {
my $g = double(Greeter, hello => 'mocked');
expect($g.hello('world')).to.be('mocked');
expect($g.double-class === Greeter).to.be(True);
}
# stubbing a method the class doesn't define dies at creation:
# double(Greeter, missing => 1); # โ dies
# calling a method the class doesn't define dies at dispatch:
# $g.bogus; # โ dies
#allow().to.receive(), stub real objects
allow($target).to.receive('method') installs a temporary stub on a
method of an existing class or instance. The stub is uninstalled when the example
ends, restoring the original implementation.
it 'stubs hello on this one instance only', {
my $g = Greeter.new;
allow($g).to.receive('hello').and-return('STUB');
expect($g.hello('alice')).to.be('STUB'); # stubbed
expect(Greeter.new.hello('bob')).to.be('hello, bob'); # untouched
}
The full chain:
| .and-return(v) | Stub returns v. Omit the chain for Any. |
| .and-raise(ex) | Stub throws the supplied exception. |
| .and-call-original | Delegate back to the real implementation. |
| .and-do(&cb) | Invoke &cb with the call's positional args; its return is the result. |
#partial mocks
allow($obj).to.receive('m') only replaces m. Every other
method on the same instance keeps its real behavior, including methods that read
or mutate state. Stub the collaborator boundaries, leave the rest of the object
alone.
class Account {
has Int $.balance is rw = 0;
method deposit($n) { $!balance += $n; $!balance }
method label { "Account($!balance)" }
}
describe 'partial mock', {
it 'stubs label but lets deposit run for real', {
my $a = Account.new(balance => 100);
allow($a).to.receive('label').and-return('STUB');
expect($a.label).to.be('STUB'); # stubbed
expect($a.deposit(50)).to.be(150); # real implementation
expect($a.balance).to.be(150); # real state mutation
}
}
#allow-any-instance-of
Stubs an instance method at the class's method-table level, so every instance, existing or future, dispatches into the stub for the rest of the example.
class Repo { method find($id) { "real:$id" } }
it 'affects every instance for the duration of the example', {
allow-any-instance-of(Repo).to.receive('find').and-return('stub');
expect(Repo.new.find(1)).to.be('stub');
expect(Repo.new.find(2)).to.be('stub');
}
allow($obj).to.receive('m')
for the same method takes precedence over allow-any-instance-of(Class).
Only that one instance sees the instance-specific stub.
#spy(), pass-through recorder
spy($real-instance) wraps each user-defined method on the instance
with a recording stub that delegates to the real implementation. Returns the
same instance. Use it when you want to verify a call without changing behavior.
describe 'spying on a real object', {
it 'records calls without changing behavior', {
my $g = Greeter.new;
spy($g);
expect($g.hello('alice')).to.be('hello, alice'); # real method ran
expect($g).to.have-received('hello'); # call recorded
}
}
#expect(obj).to.have-received()
Verify call records on a double, on a spy(...)-wrapped instance, or on
any object whose method has been stubbed via allow(...). Chain
count modifiers and argument matchers for precise expectations.
my $log = double('Logger');
$log.info('starting');
$log.info('done');
expect($log).to.have-received('info'); # at least one call
expect($log).to.have-received('info').twice; # exactly two
expect($log).to.have-received('info').at-least(2);
expect($log).to.have-received('info').with('starting');
expect($log).to.have-received('info').with('starting').once;
| Modifier | Meaning |
|---|---|
| .once / .twice / .thrice | Exactly 1 / 2 / 3 calls. |
| .times(n) / .exactly(n) | Exactly n calls. |
| .at-least(n) | n or more calls. |
| .at-most(n) | n or fewer calls. |
| .with(args, :named) | Filter recorded calls. Combine with count modifiers. |
#argument matchers
For each positional or named arg, supply a matcher object instead of a literal value.
| Matcher | Matches |
|---|---|
| anything | Any value, including undefined. |
| instance-of(Type) | Anything for which $arg ~~ Type is true. |
| hash-including(:k<v>) | Any Associative containing the listed key/value pairs. |
| array-including(item, ...) | Any Positional containing all the listed items. |
$log.info('hello', 42);
expect($log).to.have-received('info')
.with(instance-of(Str), instance-of(Int));
$log.info({ user => 'alice', region => 'us', extra => 1 });
expect($log).to.have-received('info')
.with(hash-including(user => 'alice'));
# matchers nest:
expect($log).to.have-received('info').with(
hash-including(user => instance-of(Str), count => anything)
);
it
(or in before-each) is removed before the next example. A stub
installed in before-all lives for the entire describe it
sits in.