Skip to content

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/.

tests/AppDemo/Fixture/
├── BookMother.php
├── UserMother.php
└── LoanMother.php

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.php
  • tests/Phexium/Fake/Plugin/Dispatcher/FakeDispatcher.php
  • tests/Phexium/Fake/Plugin/CommandBus/CommandBus.php
  • tests/Phexium/Fake/Plugin/CommandBus/CommandBusThatThrows.php

See Also