friends-of-ddd / context-arranger
Framework for creating context for integration tests
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Forks: 0
pkg:composer/friends-of-ddd/context-arranger
Requires
- php: >=8.2
- friends-of-ddd/event-driven: ^0.1
- psr/container: ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.52
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5.15
README
A powerful library for creating complex test contexts in integration tests using command templates, automatic dependency resolution, and event-driven flows.
Table of Contents
Installation
Install the library using Composer as a development dependency:
composer require --dev friends-of-ddd/context-arranger
Introduction
Context Arranger is a framework designed to simplify the setup of complex test contexts in integration tests. It enables you to:
- Define reusable command templates with configurable parameters
- Automatically resolve dependencies between commands using events
- Create prerequisite flows that trigger automatically when needed
- Refer to IDs that will be created in the future using closures
- Customize command behavior with pre-dispatch callbacks
- Manage multiple isolated contexts within the same test
Core Concepts
Command Templates
A Command Template is a blueprint for creating commands with predefined or dynamic parameters. It implements the CommandTemplateInterface:
interface CommandTemplateInterface
{
/**
* Creates the actual command with resolved parameters
*/
public function create(array $resolvedParams): CommandInterface;
/**
* Returns all parameters (both resolved and unresolved)
*/
public function getAllUnresolvedParams(): array;
}
Unresolved Parameters
Unresolved parameters are values that will be determined at runtime. They can be:
- Static values: Simple strings, integers, etc.
- Closures: Functions that will be executed to resolve the value dynamically
- Event-based closures: Functions that extract values from events produced by other commands
Closures and Dynamic Resolution
Closures in command templates can:
- Generate unique values (e.g., auto-incrementing IDs, unique emails)
- Extract values from events produced by prerequisite commands
- Access services from the application container
- Reference results from specific contexts
Basic Usage
Creating Command Templates
Here's how to create a command template for a CreateClientCommand:
use Closure;
use FriendsOfDdd\ContextArranger\CommandTemplateInterface;
final readonly class CreateClientCommandTemplate implements CommandTemplateInterface
{
public function __construct(
private string $name = 'John Doe',
private null|string|Closure $email = null,
) {
}
public function create(array $resolvedParams): CreateClientCommand
{
return new CreateClientCommand(...$resolvedParams);
}
public function getAllUnresolvedParams(): array
{
return [
'name' => $this->name,
'email' => $this->email ?? static function (): string {
static $counter = 1;
$email = "john.doe$counter@company.com";
$counter++;
return $email;
},
];
}
}
Key Points:
- Properties can be static values or
Closuretypes - In
getAllUnresolvedParams(), return closures as unresolved parameters - Closures with no arguments generate values dynamically (e.g., auto-increment)
- Static counters inside closures maintain state across multiple calls
Setting Up the Context Arranger
use FriendsOfDdd\ContextArranger\Context;
use FriendsOfDdd\ContextArranger\ContextArranger;
use FriendsOfDdd\ContextArranger\EventBus;
use FriendsOfDdd\ContextArranger\Result\ResultCollection;
$contextArranger = new ContextArranger(
context: new Context(
application: $container, // PSR-11 container
results: new ResultCollection(),
),
commandBus: $commandBus, // Your command bus
eventBus: new EventBus(), // Event bus to track dispatched events
);
Arranging Commands
// Execute a single command
$contextArranger->arrange(
new CreateClientCommandTemplate(name: 'Alice')
);
// Execute multiple commands
$contextArranger->arrange(
new CreateClientCommandTemplate(name: 'Alice'),
new CreateClientCommandTemplate(name: 'Bob'),
);
Advanced Features
Referring to Future IDs in Closures
One of the most powerful features is the ability to reference IDs that will be created by prerequisite commands using event parameter names.
Event-Based Parameter Resolution
When a closure has a parameter with an event type, the library will:
- Look for events of that type in the context
- Extract the required property from the event
- Pass it to the command being created
Example:
final readonly class CreateTicketCommandTemplate implements CommandTemplateInterface
{
public function __construct(
private string $title = 'Fix my laptop',
private null|int|Closure $clientId = null,
private null|int|Closure $topicId = null,
) {
// Default closure extracts clientId from ClientCreatedEvent
$this->clientId = $clientId ?? static fn (ClientCreatedEvent $event): int => $event->clientId;
}
public function create(array $resolvedParams): CreateTicketCommand
{
return new CreateTicketCommand(...$resolvedParams);
}
public function getAllUnresolvedParams(): array
{
return [
'title' => $this->title,
'clientId' => $this->clientId,
'topicId' => $this->topicId,
];
}
}
Special Parameter Names for Flow-Specific and Named Results:
You can reference events from specific flows or named results using special parameter naming:
// Reference event from a specific flow named "Email"
static fn (TicketCreatedEvent $inEmail) => $inEmail->ticketId
// Reference event from "Default" flow
static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId
// Reference the most recent result named "TheResult"
static fn (TicketCreatedEvent $theResult) => $theResult->ticketId
The prefix before the event type name determines the source:
$in{FlowName}or${randomText}In{FlowName}- Gets event from a specific flow (e.g.,$inDefault,$ticketInEmail)$the{ResultName}or${randomText}The{ResultName}- Gets event from a specific named result (e.g.,$theTicket1,$eventTheFirstTicket)
Defining Flows as Prerequisites
Prerequisites define flows that trigger automatically when their required events are missing.
use FriendsOfDdd\ContextArranger\Prerequisite\Prerequisite;
use FriendsOfDdd\ContextArranger\Prerequisite\PrerequisiteCollection;
$prerequisites = new PrerequisiteCollection(
// Define "Default" flow for creating a client
new Prerequisite(
commandTemplate: new CreateClientCommandTemplate(),
triggersEventTypes: [ClientCreatedEvent::class],
flowName: 'Default',
),
// Define "Default" flow for creating a ticket
new Prerequisite(
commandTemplate: new CreateTicketCommandTemplate(
clientId: static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId,
),
triggersEventTypes: [TicketCreatedEvent::class],
flowName: 'Default',
),
// Define "Default" flow for closing a ticket
new Prerequisite(
commandTemplate: new CloseTicketCommandTemplate(
ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
),
triggersEventTypes: [TicketClosedEvent::class],
flowName: 'Default',
),
);
$contextArranger = new ContextArranger(
context: $context,
commandBus: $commandBus,
eventBus: $eventBus,
prerequisites: $prerequisites,
);
Automatic Dependency Chains
When you arrange a command that requires events that don't exist yet, the library automatically:
- Analyzes the command's closures to find required event types
- Finds prerequisites that produce those events
- Executes the prerequisite commands recursively
- Continues with the original command once dependencies are satisfied
Example:
// You want to close a ticket, but no ticket exists yet
$contextArranger->arrange(
new CloseTicketCommandTemplate(
ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
)
);
// The library automatically:
// 1. Detects that TicketCreatedEvent is missing
// 2. Finds the prerequisite that produces TicketCreatedEvent
// 3. Executes CreateTicketCommand
// 4. Detects that ClientCreatedEvent is missing (required by CreateTicketCommand)
// 5. Finds the prerequisite that produces ClientCreatedEvent
// 6. Executes CreateClientCommand
// 7. Now executes CreateTicketCommand with the clientId
// 8. Finally executes CloseTicketCommand with the ticketId
Redefining Commands in Flow Chains
You can override specific commands in a prerequisite chain without redefining the entire flow:
use FriendsOfDdd\ContextArranger\CommandTemplateMetadata;
// Override just the CreateTicketCommand in the "Default" flow
$contextArranger->arrange(
new CommandTemplateMetadata(
realTemplate: new CreateTicketCommandTemplate(
title: 'Custom ticket title',
clientId: static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId,
),
resultName: 'customTicket',
)
);
// Or override the client creation while keeping the rest of the flow
$contextArranger->arrange(
new CommandTemplateMetadata(
realTemplate: new CloseTicketCommandTemplate(
ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
),
)
);
// This will still trigger CreateClientCommand and CreateTicketCommand from prerequisites,
// but you can wrap any of them with CommandTemplateMetadata to customize them
Customizing prerequisite commands inline:
$prerequisites = new PrerequisiteCollection(
new Prerequisite(
commandTemplate: new CommandTemplateMetadata(
realTemplate: new CreateTicketCommandTemplate(
clientId: static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId,
),
resultName: 'regularTicket', // Custom result name
),
triggersEventTypes: [TicketCreatedEvent::class],
flowName: 'Default',
),
);
Custom Context Names
Understanding Flow Names vs Context Names:
These are two separate concepts that serve different purposes:
Flow Names - Defined in prerequisites, determine which prerequisite chain to use
- Set in
Prerequisiteconstructor:flowName: 'Default'orflowName: 'Email' - Referenced in closure parameter names:
$inDefault,$inEmail - Controls which dependency chain is triggered when events are missing
- Set in
Context Names - Defined in
CommandTemplateMetadata, namespace for storing results- Set in
CommandTemplateMetadataconstructor:contextName: 'AdminScenario' - Keeps results in separate namespaces to avoid duplicates
- Multiple contexts can use the same flow or different flows
- Set in
Context names allow you to:
- Keep ResultCollection results in separate namespaces within the same test
- Create multiple instances of the same event without conflicts
- Run parallel scenarios that may reuse the same flows
Example: Same flow, different contexts
// Create two separate admin scenarios using the same "Default" flow
$contextArranger->arrange(
new CommandTemplateMetadata(
realTemplate: new CloseTicketCommandTemplate(
ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
),
contextName: 'AdminScenario1', // First isolated context
),
new CommandTemplateMetadata(
realTemplate: new CloseTicketCommandTemplate(
ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
),
contextName: 'AdminScenario2', // Second isolated context
),
);
// Both commands will:
// - Use the "Default" flow (determined by $inDefault in closures)
// - Store results in separate contexts (AdminScenario1 and AdminScenario2)
// - Create separate instances of ClientCreatedEvent and TicketCreatedEvent
Example: Different flows, different contexts
$prerequisites = new PrerequisiteCollection(
// "Default" flow
new Prerequisite(
new CreateClientCommandTemplate(),
triggersEventTypes: [ClientCreatedEvent::class],
flowName: 'Default',
),
new Prerequisite(
new CreateTicketCommandTemplate(
clientId: static fn (ClientCreatedEvent $inDefault) => $inDefault->clientId,
),
triggersEventTypes: [TicketCreatedEvent::class],
flowName: 'Default',
),
// "Email" flow - different prerequisite chain
new Prerequisite(
new CreateTicketFromEmailCommandTemplate(),
triggersEventTypes: [ClientCreatedEvent::class, TicketCreatedEvent::class],
flowName: 'Email',
),
);
// Use different flows and contexts
$contextArranger->arrange(
// Uses "Default" flow, stores in "RegularTicketing" context
new CommandTemplateMetadata(
realTemplate: new CloseTicketCommandTemplate(
ticketId: static fn (TicketCreatedEvent $inDefault) => $inDefault->ticketId,
),
contextName: 'RegularTicketing',
),
// Uses "Email" flow, stores in "EmailTicketing" context
new CommandTemplateMetadata(
realTemplate: new CloseTicketCommandTemplate(
ticketId: static fn (TicketCreatedEvent $inEmail) => $inEmail->ticketId,
),
contextName: 'EmailTicketing',
),
);
Key Points:
- Flow name (in closures like
$inDefault,$inEmail) selects which prerequisite chain to trigger - Context name (in
CommandTemplateMetadata) namespaces the results to prevent duplicates - Same flow can be used with different contexts to create isolated result sets
- Different flows can be used with different contexts for completely independent scenarios
Pre-Dispatch Callbacks
Pre-dispatch callbacks allow you to execute custom logic before a command is dispatched. This is useful for:
- Setting up custom clocks for time-sensitive tests
- Configuring mocked external services
- Preparing test data that the command will use
Example: Configuring a mocked external service
use FriendsOfDdd\ContextArranger\CommandTemplateMetadata;
$contextArranger->arrange(
new CommandTemplateMetadata(
realTemplate: new CreateTicketFromEmailCommandTemplate(
emailId: new GmailEmailId('inbox@service.com', '123123123')
),
resultName: 'emailTicket',
preDispatchCallback: static function (
CreateTicketFromEmailCommand $command,
MockedGmailApiClient $client, // Injected from container
): void {
// Configure the mock before the command is executed
$client->addMockedResponse($command->emailId, new GmailHistory(
id: '123123123',
messagesAdded: [
new GmailMessage(
id: '2234234',
threadId: '345345345',
internalDate: new DateTimeImmutable(),
payload: new GmailMessagePart(
partId: '34343453',
mimeType: 'text/plain',
headers: [],
body: 'Fix my stuff',
),
),
],
));
},
)
);
Example: Setting up a custom clock
$contextArranger->arrange(
new CommandTemplateMetadata(
realTemplate: new CreateTicketCommandTemplate(
clientId: static fn (ClientCreatedEvent $event) => $event->clientId,
),
preDispatchCallback: static function (
CreateTicketCommand $command,
ClockInterface $clock,
): void {
// Set a specific time for this command
$clock->setCurrentTime(new DateTimeImmutable('2025-01-15 10:00:00'));
},
)
);
Pre-dispatch callback features:
- Can access the command being dispatched
- Can inject services from the application container
- Can access results from the context using event type hints
- Executed just before
$commandBus->dispatch($command)
License
MIT License. See LICENSE file for details.