Test Doubles
Phexium provides pre-built test doubles (fakes and spies) for testing without external dependencies.
Overview
Test doubles in tests/Phexium/Fake/ implement framework interfaces with additional assertion methods. They record interactions for verification during tests.
Directory Structure
tests/
├── Phexium/Fake/ # Framework test doubles
│ ├── Domain/
│ │ ├── Entity.php, IntValueObject.php, StringValueObject.php
│ │ ├── Event/DomainEvent.php, EventListener.php
│ │ └── Specification/IdSpecification.php, NameSpecification.php
│ ├── Plugin/
│ │ ├── CommandBus/CommandBus.php, CommandBusThatThrows.php
│ │ ├── Dispatcher/FakeDispatcher.php
│ │ ├── Logger/Logger.php
│ │ ├── Session/Session.php, OdanSession.php
│ │ └── Transaction/Transaction.php
│ ├── Application/
│ │ ├── Command/Command.php, Handler.php
│ │ └── Query/Query.php, Handler.php
│ └── Presentation/
│ └── Presenter.php, QueryResponse.php
└── AppDemo/Fixture/ # Application Object Mothers
├── BookMother.php
├── UserMother.php
└── LoanMother.php
FakeLogger (Spy Pattern)
Records all log calls with level, message, and context for later verification.
Usage
$logger = new Tests\Phexium\Fake\Plugin\Logger\Logger();
$handler = new MyHandler($logger);
$handler->handle($command);
expect($logger->hasLog('info', 'Book created', ['bookId' => '123']))->toBeTrue();
Assertion Methods
| Method | Description |
|---|---|
hasLog($level, $message, $context) | Exact match on level, message, and context |
hasLogContaining($level, $messageFragment) | Level match with partial message |
hasLogWithContext($level, $messageEnd, $contextSubset) | Level + message ending + context subset |
getLogs() | Returns all recorded logs |
clear() | Resets recorded logs |
Example: Verify Logging in Handler
test('CreateBookHandler logs book creation', function (): void {
$logger = new \Tests\Phexium\Fake\Plugin\Logger\Logger();
$handler = new CreateBookHandler($repository, $eventBus, $logger);
$handler->handle($command);
expect($logger->hasLogContaining('info', 'Book created'))->toBeTrue();
});
FakeDispatcher (Spy Pattern)
Records dispatched events and supports subscriber registration for testing event flows.
Usage
$dispatcher = new Tests\Phexium\Fake\Plugin\Dispatcher\FakeDispatcher();
$eventBus = new SyncEventBus($dispatcher);
$eventBus->dispatch(new BookCreatedEvent($bookId));
expect($dispatcher->wasDispatched(BookCreatedEvent::class))->toBeTrue();
Assertion Methods
| Method | Description |
|---|---|
wasDispatched($eventClass) | Check if event type was dispatched |
getDispatchedEvents() | Returns all dispatched events |
getDispatchCount() | Total number of dispatched events |
getLastDispatchedEvent() | Returns the most recent event |
getSubscriberCount($eventClass) | Count subscribers for event type |
Example: Verify Event Publishing
test('Handler dispatches BookCreatedEvent', function (): void {
$dispatcher = new \Tests\Phexium\Fake\Plugin\Dispatcher\FakeDispatcher();
$eventBus = new SyncEventBus($dispatcher);
$handler = new CreateBookHandler($repository, $eventBus);
$handler->handle($command);
expect($dispatcher->wasDispatched(BookCreatedEvent::class))->toBeTrue();
expect($dispatcher->getDispatchCount())->toBe(1);
});
FakeCommandBus (Spy Pattern)
Records dispatched commands for verifying controller behavior.
Usage
$commandBus = new Tests\Phexium\Fake\Plugin\CommandBus\CommandBus();
$controller = new CreateBookController($commandBus);
$controller->create($request, $response);
expect($commandBus->hasDispatched(CreateBookCommand::class))->toBeTrue();
Assertion Methods
| Method | Description |
|---|---|
hasDispatched($commandClass) | Check if command type was dispatched |
getDispatchedCommands() | Returns all dispatched commands |
getLastDispatchedCommand() | Returns the most recent command |
getDispatchedCommandsByType($class) | Filter commands by type |
count() | Total dispatched commands |
clear() | Resets recorded commands |
CommandBusThatThrows (Stub Pattern)
Throws a configured exception on dispatch for testing error handling.
Usage
$exception = new BookNotFoundException::withId('123');
$commandBus = new Tests\Phexium\Fake\Plugin\CommandBus\CommandBusThatThrows($exception);
$controller = new CreateBookController($commandBus);
expect(fn () => $controller->create($request, $response))
->toThrow(BookNotFoundException::class);
Example: Test Error Handling
test('Controller handles command failure gracefully', function (): void {
$commandBus = new CommandBusThatThrows(new InvalidIsbnException('Invalid ISBN'));
$controller = new CreateBookController($commandBus, $twig, $session);
$response = $controller->create($request, new Response());
expect($response->getStatusCode())->toBe(200); // Re-renders form
});
Fake vs Mock Guidelines
Use Fakes When
- Verifying method calls (spy pattern)
- Testing integration between components
- Need realistic behavior without external dependencies
- InMemory implementations for repositories
Use Mocks (Mockery) When
- Stubbing specific return values
- Verifying call order
- Partial mocking existing classes
- Complex expectation scenarios
Example Comparison
// Fake (spy) - verifies what happened
$logger = new \Tests\Phexium\Fake\Plugin\Logger\Logger();
$handler->handle($command);
expect($logger->hasLogContaining('info', 'created'))->toBeTrue();
// Mock - controls and verifies
$logger = Mockery::mock(LoggerInterface::class);
$logger->shouldReceive('info')->once()->with('Book created', Mockery::any());
$handler->handle($command);
InMemory Adapters
For repository testing, use InMemory implementations from the application layer rather than fakes:
// Use InMemory repository for integration tests
$repository = new InMemoryBookRepository();
$repository->save($book);
$handler = new CreateBookHandler($repository, $eventBus);
InMemory adapters provide full repository behavior without database overhead, making integration tests fast and reliable.
Test Fixtures
Test fixtures provide consistent, reusable test data. Phexium supports three patterns for creating fixtures.
Builder Pattern
Builders provide a fluent interface for creating entities with custom configurations:
final class BookBuilder
{
private string $title = 'Default Title';
private BookStatus $status = BookStatus::Available;
public static function aBook(): self
{
return new self();
}
public function withTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function borrowed(): self
{
$this->status = BookStatus::Borrowed;
return $this;
}
public function build(): Book
{
return Book::reconstitute(
$this->id,
Title::fromString($this->title),
// ... other properties
$this->status,
);
}
}
Usage:
test('Borrowed book cannot be borrowed again', function (): void {
$book = BookBuilder::aBook()
->borrowed()
->build();
expect(fn () => $book->borrow())
->toThrow(BookNotAvailableException::class);
});
Object Mother Pattern
Object Mothers provide pre-configured entity instances for common test scenarios. They are located in tests/AppDemo/Fixture/.
BookMother example:
final class BookMother
{
public static function available(int $id = 1): Book
{
return self::create(id: $id, status: BookStatus::Available);
}
public static function borrowed(int $id = 1): Book
{
return self::create(id: $id, status: BookStatus::Borrowed);
}
public static function create(
int $id = 1,
string $title = 'Clean Code',
string $author = 'Robert C. Martin',
string $isbn = '9780134494166',
BookStatus $status = BookStatus::Available
): Book {
return new Book(
new TimestampId($id),
Title::fromString($title),
Author::fromString($author),
ISBN::fromString($isbn),
$status
);
}
}
Usage:
use Tests\AppDemo\Fixture\BookMother;
test('Available book can be marked as borrowed', function (): void {
$book = BookMother::available();
$book->markAsBorrowed();
expect($book->getStatus())->toBe(BookStatus::Borrowed);
});
test('Custom book with specific title', function (): void {
$book = BookMother::create(id: 123, title: 'Domain-Driven Design');
expect($book->getTitle()->toString())->toBe('Domain-Driven Design');
});
Helper Functions
Helper functions delegate to Object Mothers and add test-specific setup logic (like saving to repositories):
use Tests\AppDemo\Fixture\BookMother;
function givenBookExists(object $repository, int $id, string $isbn): void
{
$repository->save(
BookMother::create(id: $id, title: 'Title#'.$id, author: 'Author#'.$id, isbn: $isbn)
);
}
Usage:
test('Book exists in repository', function (): void {
givenBookExists($this->repository, id: 1, isbn: '9780134494166');
$book = $this->repository->findById(new TimestampId(1));
expect($book)->not->toBeNull();
});
Helper functions are useful for integration tests where entities need to be persisted. They keep the given* naming convention for BDD-style readability while delegating entity creation to Object Mothers.
When to Use Each Pattern
| Pattern | Use Case |
|---|---|
| Object Mothers | Entity creation reused across test files (recommended default) |
| Helper functions | Integration tests needing repository setup (given* style) |
| Builders | Complex entities with many optional variations |
Best Practices
- Use Object Mothers as the default for entity creation
- Helper functions delegate to Mothers, adding persistence logic
- Use meaningful default values that represent valid entities
- Introduce Builders only when Mothers become unwieldy (many optional parameters)
- Keep the
create()method for full customization, named methods for common scenarios
Source Files
tests/Phexium/Fake/Plugin/Logger/Logger.phptests/Phexium/Fake/Plugin/Dispatcher/FakeDispatcher.phptests/Phexium/Fake/Plugin/CommandBus/CommandBus.phptests/Phexium/Fake/Plugin/CommandBus/CommandBusThatThrows.php
See Also
- Logger - FakeLogger implementation
- Event Bus - FakeDispatcher for event testing
- Command Bus - FakeCommandBus for controller testing
- Controllers - Testing with fake buses
- Repository Implementations - InMemory as test double