A strict mocking solution. It works with any testing framework; the examples below use PHPUnit.
A mock is defined as an ordered list of expected method calls. Every call made on the mock must match the next expectation in that list by method name and parameters, otherwise an exception is thrown. Missing calls are detected as well: a mock that still has unconsumed expectations when it is destroyed throws too.
Every exception carries a JSON message with the mocked class, the position of the failing call, the actual and expected values, and the file and line where the mock was created.
- php: ^8.3
- nikic/php-parser: ^5.8
Through Composer as chubbyphp/chubbyphp-mock.
composer require chubbyphp/chubbyphp-mock "^2.2" --devCreate a MockObjectBuilder and pass it the class or interface to mock together with the list of expected calls.
Each expected call is an instance of one of the following mock methods:
| Mock method | Constructor | Behaviour |
|---|---|---|
WithoutReturn |
(string $name, array $parameters, bool $strict = true) |
Validates the call and returns nothing. |
WithReturn |
(string $name, array $parameters, mixed $return, bool $strict = true) |
Validates the call and returns the given value. |
WithReturnSelf |
(string $name, array $parameters, bool $strict = true) |
Validates the call and returns the mock itself (fluent APIs). |
WithException |
(string $name, array $parameters, \Throwable $exception, bool $strict = true) |
Validates the call and throws the given exception. |
WithCallback |
(string $name, callable $callback) |
Validates the method name and delegates to the callback with the actual parameters. Its return value is returned by the mock. |
Parameters are compared with === by default. Pass $strict = false to compare by value instead; arrays are then
compared entry by entry and objects property by property (or via __serialize / __sleep when available).
Use WithCallback whenever a parameter cannot be known in advance (for example a generated id or a timestamp) or when
you want to assert on it inside the callback.
<?php
declare(strict_types=1);
namespace MyProject\Tests\Unit\RequestHandler;
use Chubbyphp\Mock\MockMethod\WithCallback;
use Chubbyphp\Mock\MockMethod\WithReturn;
use Chubbyphp\Mock\MockMethod\WithReturnSelf;
use Chubbyphp\Mock\MockObjectBuilder;
use MyProject\RequestHandler\PingRequestHandler;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
final class PingRequestHandlerTest extends TestCase
{
public function testHandle(): void
{
$builder = new MockObjectBuilder();
// no calls expected: any method call on this mock fails the test
$request = $builder->create(ServerRequestInterface::class, []);
$responseBody = $builder->create(StreamInterface::class, [
// the written JSON is not known in advance, so assert on it in a callback
new WithCallback('write', static function (string $string): int {
$data = json_decode($string, true);
self::assertArrayHasKey('datetime', $data);
return \strlen($string);
}),
]);
$response = $builder->create(ResponseInterface::class, [
// calls must happen in exactly this order
new WithReturnSelf('withHeader', ['Content-Type', 'application/json']),
new WithReturnSelf('withHeader', ['Cache-Control', 'no-cache, no-store, must-revalidate']),
new WithReturnSelf('withHeader', ['Pragma', 'no-cache']),
new WithReturnSelf('withHeader', ['Expires', '0']),
new WithReturn('getBody', [], $responseBody),
]);
$responseFactory = $builder->create(ResponseFactoryInterface::class, [
new WithReturn('createResponse', [200, ''], $response),
]);
$requestHandler = new PingRequestHandler($responseFactory);
self::assertSame($response, $requestHandler->handle($request));
}
}chubbyphp-mock has no dependency on PHPUnit. A host framework only needs to provide a place to build the mocks,
a way to assert inside a WithCallback, and a test scope that ends when the test ends.
Unconsumed expectations are reported from the mock's destructor. Keep mocks in local variables of the test and do not store them in long-lived properties, statics or shared setup, otherwise the check is delayed and the failure is attributed to the wrong place.
The examples below use the same PingRequestHandler scenario as the PHPUnit example above.
expect() replaces the PHPUnit assertion inside the callback, everything else stays the same.
<?php
declare(strict_types=1);
use Chubbyphp\Mock\MockMethod\WithCallback;
use Chubbyphp\Mock\MockMethod\WithReturn;
use Chubbyphp\Mock\MockMethod\WithReturnSelf;
use Chubbyphp\Mock\MockObjectBuilder;
use MyProject\RequestHandler\PingRequestHandler;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
it('handles a ping request', function (): void {
$builder = new MockObjectBuilder();
$request = $builder->create(ServerRequestInterface::class, []);
$responseBody = $builder->create(StreamInterface::class, [
new WithCallback('write', static function (string $string): int {
expect(json_decode($string, true))->toHaveKey('datetime');
return \strlen($string);
}),
]);
$response = $builder->create(ResponseInterface::class, [
new WithReturnSelf('withHeader', ['Content-Type', 'application/json']),
new WithReturnSelf('withHeader', ['Cache-Control', 'no-cache, no-store, must-revalidate']),
new WithReturnSelf('withHeader', ['Pragma', 'no-cache']),
new WithReturnSelf('withHeader', ['Expires', '0']),
new WithReturn('getBody', [], $responseBody),
]);
$responseFactory = $builder->create(ResponseFactoryInterface::class, [
new WithReturn('createResponse', [200, ''], $response),
]);
$requestHandler = new PingRequestHandler($responseFactory);
expect($requestHandler->handle($request))->toBe($response);
});Codeception unit tests are PHPUnit test cases with a different base class, so the PHPUnit example works unchanged.
<?php
declare(strict_types=1);
namespace MyProject\Tests\Unit\RequestHandler;
use Chubbyphp\Mock\MockMethod\WithCallback;
use Chubbyphp\Mock\MockMethod\WithReturn;
use Chubbyphp\Mock\MockMethod\WithReturnSelf;
use Chubbyphp\Mock\MockObjectBuilder;
use Codeception\Test\Unit;
use MyProject\RequestHandler\PingRequestHandler;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
final class PingRequestHandlerTest extends Unit
{
public function testHandle(): void
{
$builder = new MockObjectBuilder();
$request = $builder->create(ServerRequestInterface::class, []);
$responseBody = $builder->create(StreamInterface::class, [
new WithCallback('write', function (string $string): int {
$data = json_decode($string, true);
$this->assertArrayHasKey('datetime', $data);
return \strlen($string);
}),
]);
$response = $builder->create(ResponseInterface::class, [
new WithReturnSelf('withHeader', ['Content-Type', 'application/json']),
new WithReturnSelf('withHeader', ['Cache-Control', 'no-cache, no-store, must-revalidate']),
new WithReturnSelf('withHeader', ['Pragma', 'no-cache']),
new WithReturnSelf('withHeader', ['Expires', '0']),
new WithReturn('getBody', [], $responseBody),
]);
$responseFactory = $builder->create(ResponseFactoryInterface::class, [
new WithReturn('createResponse', [200, ''], $response),
]);
$requestHandler = new PingRequestHandler($responseFactory);
$this->assertSame($response, $requestHandler->handle($request));
}
}phpspec injects lenient, unordered Prophecy collaborators by default. Building the mocks with chubbyphp-mock instead gives the spec a strict, ordered script of expected calls.
<?php
declare(strict_types=1);
namespace spec\MyProject\RequestHandler;
use Chubbyphp\Mock\MockMethod\WithCallback;
use Chubbyphp\Mock\MockMethod\WithReturn;
use Chubbyphp\Mock\MockMethod\WithReturnSelf;
use Chubbyphp\Mock\MockObjectBuilder;
use MyProject\RequestHandler\PingRequestHandler;
use PhpSpec\ObjectBehavior;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
final class PingRequestHandlerSpec extends ObjectBehavior
{
public function it_handles_a_ping_request(): void
{
$builder = new MockObjectBuilder();
$request = $builder->create(ServerRequestInterface::class, []);
$responseBody = $builder->create(StreamInterface::class, [
// phpspec has no standalone assertion API, so fail with an exception
new WithCallback('write', static function (string $string): int {
$data = json_decode($string, true);
if (!\array_key_exists('datetime', $data)) {
throw new \RuntimeException('Missing key "datetime" in written JSON');
}
return \strlen($string);
}),
]);
$response = $builder->create(ResponseInterface::class, [
new WithReturnSelf('withHeader', ['Content-Type', 'application/json']),
new WithReturnSelf('withHeader', ['Cache-Control', 'no-cache, no-store, must-revalidate']),
new WithReturnSelf('withHeader', ['Pragma', 'no-cache']),
new WithReturnSelf('withHeader', ['Expires', '0']),
new WithReturn('getBody', [], $responseBody),
]);
$responseFactory = $builder->create(ResponseFactoryInterface::class, [
new WithReturn('createResponse', [200, ''], $response),
]);
$this->beConstructedWith($responseFactory);
$this->handle($request)->shouldReturn($response);
}
}Use the third party package dg/bypass-finals.
This does not remove the final keyword from internal (PHP core or extension) classes.
-
Static methods: They are declared on the mock but throw when called.
-
Properties: Only method calls are intercepted.
-
__constructand__destruct: The mock defines its own constructor and destructor. -
Internal final classes or methods: Even with
dg/bypass-finals, final internal classes or methods cannot be mocked. -
Poorly built extension classes: Some older PHP extensions declare classes that cannot be fully reverse-engineered via reflection. Those classes are not mockable.
\Traversableand interfaces extending it: PHP does not allow a userland class to implement\Traversabledirectly; it must implement\Iteratoror\IteratorAggregateinstead. The generated mock therefore additionally implements\IteratorAggregateand, if the mocked type does not declare it, agetIterator()method. That method behaves like any other mocked method and needs a matching expectation when called.
Please report if you find other restrictions / bugs.
2026 Dominik Zogg