Skip to content

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:

php
// 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 --bdd flag
  • Parse tag expressions
  • Launch BddRunner
  • Exit with appropriate code

BddRunner.php

Orchestrates the entire BDD test execution:

php
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:

php
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:

php
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:

php
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 scenarios
  • ParsedScenario - Scenario with steps
  • ParsedStep - Individual step
  • ParsedBackground - Background steps
  • ParsedExample - Scenario Outline examples

StepRegistry.php

Stores and retrieves step definitions:

php
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:

php
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:

php
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:

php
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:

php
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:

php
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:

php
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

gherkin
Feature: Calculator
  Scenario: Add numbers
    Given I have 5
    When I add 3
    Then the result is 8

2. Step Definitions

php
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:

php
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);
    }
}

Released under the MIT License.