Architecture
Understanding Pest BDD's architecture helps you debug issues and extend its functionality. This guide explains how the major components work together.
High-Level Overview
┌─────────────────────────────────────────────────────────────────┐
│ pest --bdd │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Plugin.php │
│ Intercepts --bdd flag, starts BddRunner │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ BddRunner.php │
│ Orchestrates discovery and test generation │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ StepDiscovery │ │FeatureDiscovery │ │ FeatureParser │
│ Finds step │ │ Finds .feature │ │ Parses Gherkin │
│ classes │ │ files │ │ to objects │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────┼───────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ PestFeatureLoader.php │
│ Maps Features → describe(), Scenarios → it() │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ScenarioRunner.php │
│ Executes each scenario │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ StepRegistry │ │ PatternMatcher │ │ParameterResolver│
│ Stores & finds │ │ Matches step │ │ Resolves method │
│ definitions │ │ text to pattern │ │ parameters │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ScenarioContext.php │
│ Stores state (objects, aliases) per scenario │
└─────────────────────────────────────────────────────────────────┘Core Components
Plugin.php
The entry point that hooks into Pest:
// Intercepts --bdd argument before Pest processes it
public function handleOriginalArguments(array $argv): array
{
if (in_array('--bdd', $argv)) {
$this->runBdd();
exit(0);
}
return $argv;
}Responsibilities:
- Detect
--bddflag - Parse tag expressions
- Launch BddRunner
- Exit with appropriate code
BddRunner.php
Orchestrates the entire BDD test execution:
public function run(): int
{
// 1. Reset statistics
BddStatistics::reset();
// 2. Discover step classes
$stepClasses = StepDiscovery::discover();
// 3. Discover feature files
$featureFiles = FeatureDiscovery::discover();
// 4. Generate temporary test file
$tempFile = $this->generateTestFile($stepClasses, $featureFiles);
// 5. Run Pest on temp file
$result = $this->runPest($tempFile);
// 6. Cleanup
unlink($tempFile);
return $result;
}StepDiscovery.php
Finds all step definition classes:
public static function discover(): array
{
$classes = [];
// Get Composer's classmap
$classmap = require 'vendor/composer/autoload_classmap.php';
foreach ($classmap as $class => $file) {
// Skip vendor files
if (str_contains($file, '/vendor/')) {
continue;
}
// Check for step attributes
if (self::hasStepAttributes($class)) {
$classes[] = $class;
}
}
return $classes;
}Key insight: This is why composer dump-autoload --optimize is important—it generates the complete classmap.
FeatureDiscovery.php
Finds all .feature files:
public static function discover(): array
{
$root = self::findProjectRoot();
$path = $root . '/tests/Behaviors';
return glob($path . '/**/*.feature', GLOB_BRACE);
}FeatureParser.php
Wraps Behat's Gherkin parser:
public function parse(string $filePath): ParsedFeature
{
// Use Behat's Gherkin parser
$feature = $this->parser->parse(file_get_contents($filePath));
return new ParsedFeature(
name: $feature->getTitle(),
description: $feature->getDescription(),
scenarios: $this->parseScenarios($feature),
background: $this->parseBackground($feature),
tags: $feature->getTags(),
);
}Output Value Objects:
ParsedFeature- Feature with scenariosParsedScenario- Scenario with stepsParsedStep- Individual stepParsedBackground- Background stepsParsedExample- Scenario Outline examples
StepRegistry.php
Stores and retrieves step definitions:
class StepRegistry
{
private array $definitions = [];
private array $compiledDefinitions = [];
public function register(StepDefinition $definition): void
{
$type = $definition->type->value;
$this->definitions[$type][] = $definition;
// Pre-compile for performance
$this->compiledDefinitions[$type][] = CompiledStepDefinition::compile($definition);
}
public function find(StepType $type, string $text): ?MatchResult
{
foreach ($this->compiledDefinitions[$type->value] as $compiled) {
if ($match = $compiled->match($text)) {
return $match;
}
}
return null;
}
}PatternMatcher.php
Converts patterns to regex and extracts values:
class PatternMatcher
{
// Type to regex mapping
private const TYPE_PATTERNS = [
'string' => '"[^"]*"',
'int' => '-?\d+',
'float' => '-?\d*\.?\d+',
'bool' => 'true|false|yes|no|1|0',
];
public function buildRegex(string $pattern, array $typeHints): string
{
// 1. Escape special regex chars
$regex = preg_quote($pattern, '/');
// 2. Replace {placeholder} with named capture groups
foreach ($this->extractPlaceholders($pattern) as $name) {
$type = $typeHints[$name] ?? 'string';
$typeRegex = self::TYPE_PATTERNS[$type];
$regex = str_replace(
"\\{$name\\}",
"(?P<{$name}>{$typeRegex})",
$regex
);
}
return "/^{$regex}$/u";
}
}ParameterResolver.php
Resolves method parameters from context and extracted values:
class ParameterResolver
{
public function resolve(
ReflectionMethod $method,
array $extractedValues,
ScenarioContext $context
): array {
$resolved = [];
foreach ($method->getParameters() as $param) {
$name = $param->getName();
$type = $param->getType()?->getName();
// 1. Try alias injection
if ($context->hasAlias($name)) {
$resolved[] = $context->getByAlias($name);
continue;
}
// 2. Try type injection (for objects)
if ($type && !$this->isBuiltin($type) && $context->has($type)) {
$resolved[] = $context->get($type);
continue;
}
// 3. Try extracted values
if (isset($extractedValues[$name])) {
$resolved[] = $this->cast($extractedValues[$name], $type);
continue;
}
// 4. Use default value
if ($param->isDefaultValueAvailable()) {
$resolved[] = $param->getDefaultValue();
continue;
}
throw new ParameterResolutionException($param);
}
return $resolved;
}
}ScenarioContext.php
Manages state within a scenario:
class ScenarioContext
{
private array $typeStorage = []; // class => object
private array $aliasStorage = []; // alias => object
private FactoryChainManager $factoryManager;
public function store(object $value, ?string $alias = null): void
{
$class = get_class($value);
// Store by type (and parent classes/interfaces)
$this->typeStorage[$class] = $value;
foreach (class_parents($value) as $parent) {
$this->typeStorage[$parent] = $value;
}
foreach (class_implements($value) as $interface) {
$this->typeStorage[$interface] = $value;
}
// Store by alias if provided
if ($alias) {
$this->aliasStorage[$alias] = $value;
}
}
public function get(string $class): ?object
{
return $this->typeStorage[$class] ?? null;
}
public function getByAlias(string $alias): ?object
{
return $this->aliasStorage[$alias] ?? null;
}
}ScenarioRunner.php
Executes scenario steps:
class ScenarioRunner
{
public function run(ParsedScenario $scenario, ?ScenarioContext $context = null): void
{
$context ??= new ScenarioContext();
$previousType = StepType::Given;
foreach ($scenario->steps as $step) {
// Resolve And/But to previous concrete type
$effectiveType = $this->resolveStepType($step->type, $previousType);
// Check for Given → When/Then transition
if ($this->isTransitionFromGiven($previousType, $effectiveType)) {
$context->finalizePendingFactories();
}
// Execute the step
$this->executeStep($step, $effectiveType, $context);
$previousType = $effectiveType;
}
}
private function executeStep(ParsedStep $step, StepType $type, ScenarioContext $context): void
{
// Find matching definition
$match = $this->registry->find($type, $step->text);
if (!$match) {
throw new StepNotFoundException($step->text);
}
// Resolve parameters
$params = $this->resolver->resolve(
$match->definition->method,
$match->extractedValues,
$context
);
// Execute
$result = $match->definition->invoke($params);
// Store result in context
if ($result !== null) {
$context->store($result, $match->alias);
}
}
}Factory Integration
FactoryChainManager.php
Manages pending factory operations:
class FactoryChainManager
{
private array $chains = []; // key => FactoryChain
public function getOrCreateChain(string $factoryClass, ?string $alias): FactoryChain
{
$key = $alias ?? $factoryClass;
if (!isset($this->chains[$key])) {
$this->chains[$key] = new FactoryChain($factoryClass, $alias);
}
return $this->chains[$key];
}
public function finalize(): array
{
$models = [];
foreach ($this->chains as $chain) {
$model = $chain->create();
$models[] = ['model' => $model, 'alias' => $chain->alias];
}
$this->chains = [];
return $models;
}
}FactoryChain.php
Accumulates factory states before creation:
class FactoryChain
{
private Factory $factory;
public ?string $alias;
public function __construct(string $factoryClass, ?string $alias)
{
$this->factory = $factoryClass::new();
$this->alias = $alias;
}
public function applyState(string $method, array $params): void
{
$this->factory = $this->factory->$method(...$params);
}
public function create(): Model
{
return $this->factory->create();
}
}Data Flow Example
Let's trace a complete execution:
1. Feature File
Feature: Calculator
Scenario: Add numbers
Given I have 5
When I add 3
Then the result is 82. Step Definitions
class CalculatorSteps
{
private int $n = 0;
#[Given('I have {n}')]
public function have(int $n): void { $this->n = $n; }
#[When('I add {n}')]
public function add(int $n): void { $this->n += $n; }
#[Then('the result is {expected}')]
public function result(int $expected): void {
expect($this->n)->toBe($expected);
}
}3. Execution Trace
1. pest --bdd
└─ Plugin detects --bdd
2. BddRunner starts
├─ StepDiscovery finds CalculatorSteps
├─ FeatureDiscovery finds calculator.feature
└─ Generates temp test file
3. Pest runs temp file
└─ PestFeatureLoader processes feature
├─ describe('Feature: Calculator')
└─ it('Scenario: Add numbers', callback)
4. ScenarioRunner executes
├─ Step: "Given I have 5"
│ ├─ StepRegistry finds match: have({n})
│ ├─ PatternMatcher extracts: {n: "5"}
│ ├─ ParameterResolver casts: 5 (int)
│ └─ Invokes have(5)
│
├─ Step: "When I add 3"
│ ├─ StepRegistry finds match: add({n})
│ ├─ PatternMatcher extracts: {n: "3"}
│ ├─ ParameterResolver casts: 3 (int)
│ └─ Invokes add(3)
│
└─ Step: "Then the result is 8"
├─ StepRegistry finds match: result({expected})
├─ PatternMatcher extracts: {expected: "8"}
├─ ParameterResolver casts: 8 (int)
└─ Invokes result(8)
5. Test passes ✓Caching
StepCache.php
Caches compiled step definitions:
class StepCache
{
public function get(string $file): ?array
{
$cache = $this->load();
$hash = crc32(file_get_contents($file));
if (($cache['files'][$file] ?? null) === $hash) {
return $cache['definitions'][$file];
}
return null;
}
public function set(string $file, array $definitions): void
{
$cache = $this->load();
$cache['files'][$file] = crc32(file_get_contents($file));
$cache['definitions'][$file] = $definitions;
$this->save($cache);
}
}