Test doubles
Greenlight provides strict mocks, inert stubs, and spies that record calls. The
per-test Doubles service supplies these doubles. Request this service through
constructor injection:
use Greenlight\Attribute\Test;
use Greenlight\Doubles\Doubles;
use Greenlight\Doubles\MockPlan;
use Greenlight\Expect\Expect;
final class CheckoutServiceTest
{
public function __construct(private Doubles $doubles) {}
#[Test]
public function chargesTheOrder(): void
{
$gateway = $this->doubles->mock(
PaymentGateway::class,
function (MockPlan $plan): void {
$plan->expects('charge')
->with(1999, 'GBP')
->once()
->andReturns('payment-123');
},
);
$payment = new CheckoutService($gateway)->checkout(1999, 'GBP');
Expect::value($payment->id)->toBe('payment-123');
}
}
Greenlight verifies mocks when the test ends.
Incorrect use of the doubles API throws InvalidDoubleUsage. This exception
identifies incorrect test code. It is not an expectation failure.
Double selection
- If the test expects specific calls and responses, use
mock(Type::class, $plan). An unplanned interaction fails immediately. - If the test needs an unused dependency, use
stub(Type::class). Each intercepted interaction fails immediately. - If the test must examine calls to a method declared
voidor without a native return type, usespy(Type::class). The spy records an unplanned call.
These spy calls return null. A call to a spy method with any other native
return type fails the test. PHPDoc return tags do not change this rule.
Mock call plans
The plan passed to mock() declares expectations with expects():
$plan->expects('reserve')
->with($sku, 2)
->once()
->andReturns(true);
Each method expectation has a default cardinality of at least one call. Use one of these methods to change its cardinality:
once()times(int $count)atLeast(int $count)never()
A call that does not match an expectation fails immediately. At teardown, an unmet expectation fails the test. Greenlight reports all unmet expectations together.
Greenlight examines expectations in declaration order. It uses the first unsaturated expectation that accepts the call.
Without an argument constraint, an expectation accepts each argument list that the method declaration permits. Greenlight rejects arguments that the method does not declare.
Mock responses
Each mock method with a native non-void return type needs an explicit
response. A method declared void or without a native return type needs no
response and returns null. PHPDoc return tags do not change this rule.
The following examples show separate response plans.
Return one value for every matched call:
$plan->expects('nextId')->andReturns('id-1');
Return successive values for two calls:
$plan->expects('nextId')
->times(2)
->andReturnsSequence('id-1', 'id-2');
Calculate the response from the call arguments:
$plan->expects('convert')
->andReturnsUsing(fn (int $value): int => $value * 2);
Throw an exception:
$plan->expects('load')
->andThrows(new NotFound('Missing record.'));
andReturnsSequence() consumes one value for each call that matches. Greenlight
reports an error if a call occurs after the sequence is empty.
For a method that declares never, use andThrows(). If a configured answer
returns, Greenlight reports an InvalidDoubleUsage error.
Argument matches
Bare values passed to with() use strict comparison (===):
$plan->expects('save')->with($expectedOrder);
Use Argument::equals($expectedOrder) to apply deep equality to a value object.
Use withNoArguments() to require a call that supplies no arguments:
$plan->expects('loadDefaults')->withNoArguments();
This constraint is useful when a method has optional or variadic parameters.
with() requires at least one value or argument matcher.
Use Argument matchers for broader constraints:
use Greenlight\Doubles\Argument;
$plan->expects('save')->with(
Argument::type(Order::class),
Argument::predicate(
fn (int $attempt): bool => $attempt > 0,
'a positive attempt',
),
Argument::any(),
);
Available matchers are:
Argument::any()Argument::type(string $type)Argument::intersection(string $first, string $second, string ...$rest)Argument::union(string $first, string $second, string ...$rest)Argument::predicate(Closure $predicate, string $description = 'predicate')Argument::equals(mixed $value)Argument::allOf(ArgumentMatcher $first, ArgumentMatcher $second, ArgumentMatcher ...$rest)Argument::captor()
Use Argument::intersection() when a value must have every specified type.
Use Argument::union() when a value can have one or more specified types:
use Greenlight\Doubles\Argument;
$plan->expects('save')->with(
Argument::intersection(Entity::class, Persistable::class),
Argument::union('string', Stringable::class),
);
The bundled PHPStan extension preserves the combined value type in each
ArgumentMatcher generic type. It reports a matcher in with() when its type
cannot match the selected method parameter. Other type names use mixed, as
they do for Argument::type().
Use a typed predicate to apply a type constraint and a value constraint:
use Greenlight\Doubles\Argument;
$plan->expects('save')->with(Argument::predicate(
fn (Order $order): bool => $order->isReady(),
'a ready order',
));
The parameter type rejects an incompatible value before the predicate runs. Greenlight rejects the plan if this type cannot match the method parameter.
Use Argument::allOf() to apply two or more constraints to one argument.
Greenlight checks the matchers from left to right. allOf() does not accept a
captor.
Argument capture
Capture one argument from every matched call:
$captor = $plan->expects('save')
->times(2)
->andReturns(true)
->captureArgument(0);
// Exercise the subject.
Expect::value($captor->values())->toHaveCount(2);
Expect::value($captor->value())->toBeInstanceOf(Order::class);
values() returns each captured value. value() returns the last value. It
fails if the captor did not capture a value.
If a plan must capture more than one argument, put an explicit
Argument::captor() inside with() for each argument.
Spy calls
callsTo() returns argument lists in call order. It copies each top-level
argument value at the start of the call. Later assignments to a reference
parameter do not change earlier recordings. Objects keep their identity.
Greenlight does not clone them.
$events = $this->doubles->spy(EventPublisher::class);
new CheckoutService($events)->checkout();
Expect::value(
$this->doubles->callsTo($events, 'publish'),
)->toEqual([[new OrderPlaced('order-1')]]);
Clone calls
Greenlight intercepts a public, non-final __clone() method. It does not run
the application implementation:
- A mock requires a planned
__clonecall. An unexpected clone fails immediately and at verification, even if application code catches the exception. - A spy records the clone call.
- A stub rejects the clone call.
An intercepted clone is a separate object with the same expectations and call
history as the original double. This also applies to a clone of a clone.
callsTo() accepts each of these objects and returns the same call history.
This history contains calls to other methods.
Call counts apply across all these objects.
A clone does not reset a planned count. For example, expects('__clone')->once()
permits one clone in total.
If a test clones a mock, add a __clone expectation to its plan.
If the test clones a stub, use a mock with an explicit clone expectation.
A final __clone() keeps its implementation and can run application code.
Supported types and limits
Double targets are interfaces or non-final, non-readonly classes.
A class double intercepts only overridable public instance methods.
Concrete final, static, and protected methods keep their original implementations. Calls through these methods can run application code.
Greenlight does not run the class constructor when it creates a double. Prefer an interface at the application boundary.
Greenlight suppresses a non-final destructor. It cannot suppress a final destructor, which can run application code.
Object parameter defaults
Methods can declare object defaults and objects inside nested arrays.
Greenlight copies each new expression from the method’s source file into the
proxy. It preserves constructor arguments, imported names, and literal values.
It does not construct these default objects when it creates the double.
PHP constructs them when a call omits the applicable argument.
The method must have a readable PHP source file. Methods declared through
eval() cannot supply object default expressions. Declare methods on separate
lines so Greenlight can identify the original declaration. A default cannot depend on
a private constructor that the proxy cannot access. Dynamic constant access
also requires a class without private constants. These cases produce
InvalidDoubleUsage.
Define unqualified constants before you create the double. Greenlight resolves these constants in the original namespace, with PHP’s global fallback, when it generates the proxy. Private scalar and enum constants retain their values.