This document provides an overview of the Cucumber implementation architecture, explaining the core components and how they interact.
Core Components
Cucumber
├── Discovery (Feature/Step/Hook Finding)
├── StepRegistry (Step Definitions & Matching)
├── Gherkin (Parser: .feature and .feature.md)
├── Gherkin.Pickles (Scenario Expansion)
├── Expression (Parameter Matching)
├── Compiler (Test Generation)
├── Runtime (Scenario Lifecycle & Step Execution)
├── RunCoordinator (Run-Wide State)
├── Messages (Cucumber Messages NDJSON)
└── StepError (Error Reporting)Discovery System
The discovery system automatically finds and loads feature files, step definitions, hooks, and custom parameter types:
Discovery.discover()
├── Scans for features (test/features/**/*.feature and **/*.feature.md)
├── Loads support files (test/features/support/**/*.exs — hooks, parameter types)
├── Loads step definitions (test/features/step_definitions/**/*.exs)
└── Returns: features, step registry, hooksStep Registry
Cucumber.StepRegistry is the complete set of step definitions and parameter types for a run, and the one place step text is matched against them. Discovery builds it from the loaded step and parameter-type modules; building merges the parameter types, compiles every pattern once, rejects duplicate definitions and types, drops (with a warning) definitions that use undefined parameter types, and orders definitions by file and line.
StepRegistry.new(step_modules, parameter_type_modules)
StepRegistry.match(registry, step_text)
# => {:ok, definition, args} | :undefined | {:ambiguous, [{definition, args}]}The runtime executes the matched definition and the Cucumber Messages emitter references the same definitions, so matching semantics live in exactly one module.
Gherkin Parser
The Gherkin parser uses NimbleParsec to parse .feature files into Elixir structs. A separate line-scanner parser (Gherkin.Markdown) parses .feature.md (Markdown with Gherkin) files into the same structs. Together they handle:
- Feature declarations with descriptions and tags
- Rules with their own backgrounds, tags, and descriptions
- Scenario outlines with Examples tables
- Backgrounds
- Steps (Given, When, Then, And, But, *)
- Data tables and doc strings (with media types)
- Tags at feature, rule, scenario, and examples levels
The parser is built using bottom-up combinator composition in six levels:
- Primitives (whitespace, newlines)
- Keywords (Given, When, Then, Feature:, etc.)
- Elements (tags, datatables, docstrings)
- Steps (keyword + text + attachments)
- Scenarios (Background, Scenario, ScenarioOutline, Examples)
- Feature (top-level parser)
# Parser flow
Feature File (Text) → NimbleParsec Parser → Elixir StructsExpression Engine
The Expression engine uses NimbleParsec to parse and match step text against step definitions. It supports:
- Cucumber expressions with parameter types (
{string},{int},{float},{word},{atom}) - Custom parameter types defined with
Cucumber.ParameterTypes(regexp + transform) - Optional parameters (
{int?}) - Optional text (
(s)for pluralization) - Alternation (
click/tap) - Escape sequences (
\{,\},\(,\),\/,\\) - Parameter conversion (string to typed values)
Step definitions can also use plain regular expressions (step ~r/.../), which participate in the same single matching path in Cucumber.StepRegistry — so ambiguity between a regex and a cucumber expression is detected like any other ambiguity and raises Cucumber.AmbiguousStepError.
defmodule Cucumber.Expression do
# Compiles a cucumber expression into a matchable AST
def compile(pattern) do
# Parses pattern using NimbleParsec
# Returns list of AST nodes with embedded parsers
end
# Matches text against a compiled expression
def match(text, compiled) do
# Uses recursive binary pattern matching
# Returns {:match, args} or :no_match
end
endCompiler
Features are first expanded into pickles — one concrete, runnable scenario per outline row, with rule backgrounds and tags folded in — by Gherkin.Pickles. The compiler then generates ExUnit test modules from them:
Compiler.compile_features!()
├── Expands features into pickles (outlines × example rows, rules flattened)
├── For each feature file:
│ ├── Generates a test module
│ ├── Creates one test case per pickle
│ └── Adds appropriate tags
└── Compiles modules into memoryRuntime
Each generated test body is a single call into the runtime, which owns the whole scenario lifecycle inside the test process — before hooks, background steps, scenario steps, and after hooks (which run on pass and on fail):
Runtime.run_scenario(context, ...)
├── Runs before-scenario hooks (and lazily before_all, once per run)
├── Executes background steps, then scenario steps:
│ ├── Matches the step against the step registry (0 → undefined, >1 → ambiguous)
│ ├── Prepares context with args, datatables, docstrings
│ ├── Brackets it with before_step/after_step hooks
│ └── Processes the return value (incl. pending/skipped, retry)
└── Runs after-scenario hooks in reverse orderRunCoordinator
A GenServer holding run-wide state: before_all once-guards and their shared context, attachments, retry bookkeeping, and the ordered Cucumber Messages sink. It serializes run-level effects so @async features stay safe.
Messages
With config :cucumber, messages: "path.ndjson", the run emits the standard Cucumber Messages stream — source, gherkinDocument, pickles, and testCase/testStep lifecycle events — as NDJSON, verified against the reference implementation by the Cucumber Compatibility Kit approval suite in this repo.
StepDefinition Macro
The StepDefinition module provides the DSL for defining steps:
defmodule MySteps do
use Cucumber.StepDefinition
step "pattern", context do
# implementation
end
endExecution Flow
Discovery Phase (at compile time)
Cucumber.compile_features!()is called in test_helper.exs- Discovery system finds all features and step definitions
- The step registry is built from step definition and parameter type modules
Compilation Phase
- Features are expanded into pickles (outline rows, rule flattening)
- For each feature, a test module is generated with one test per pickle
- Tags are added for filtering
Execution Phase (at runtime)
- ExUnit runs the generated test modules
- Each test runs its full scenario lifecycle (hooks, background, steps) via Runtime
- Context is passed between steps
- Errors are reported with helpful messages
Data Flow
Feature File → Parser → AST
↓
Step Files → Discovery → StepRegistry
↓
Compiler → Test Modules
↓
ExUnit → ResultsKey Design Decisions
Auto-Discovery
- Features and steps are automatically discovered
- No need to explicitly wire features to test modules
- Follows Ruby Cucumber's convention-over-configuration approach
ExUnit Integration
- Generated tests are standard ExUnit test modules
- Full support for ExUnit features (tags, setup, async)
- Works with existing test tooling
Runtime Compilation
- Tests are generated at runtime when
mix testruns - Allows for dynamic test generation
- No generated files to manage
Context Management
- ExUnit context is used directly
- Hooks and background steps build up the context before scenario steps run — all inside the test process
- Each step can read and modify context
Error Handling
Cucumber provides enhanced error messages with rich context:
- Undefined Steps: Shows the exact step text with clickable file:line references and suggests implementation
- Step Failures: Displays comprehensive error information including:
- The failing step text with proper formatting
- Clickable file:line reference to the scenario location (e.g.,
test/features/example.feature:25) - Visual step execution history with ✓ for passed and ✗ for failed steps
- Formatted assertion errors extracted from ExUnit
- Properly indented HTML output for PhoenixTest errors
- Full stack traces for debugging
- Duplicate Steps: Detected at load time with file/line information
The StepError module handles all error formatting, ensuring consistent and helpful error messages throughout the framework.
Extensibility
The architecture supports several extension points:
- Custom Parameter Types: Define your own
{type}parameters withCucumber.ParameterTypes - Cucumber Messages: Consume the NDJSON stream with any standards-based report tooling
- Hooks: Run-level, scenario, and step hooks with tags and names (via
Cucumber.Hooks) - Step Libraries: Create reusable step definition modules