Cucumberex
A full-featured Cucumber BDD framework for Elixir.
Write .feature files in Gherkin, bind them to Elixir step definitions,
and run them with mix cucumber.
Feature: Belly
Scenario: Eating cukes
Given I have 5 cukes in my belly
When I eat 3 cukes
Then I should have 2 cukesdefmodule BellySteps do
use Cucumberex.DSL
given_ "I have {int} cukes in my belly", fn world, count ->
Map.put(world, :cukes, count)
end
when_ "I eat {int} cukes", fn world, count ->
Map.update!(world, :cukes, &(&1 - count))
end
then_ "I should have {int} cukes", fn world, expected ->
if world.cukes != expected do
raise "Expected #{expected} cukes, got #{world.cukes}"
end
world
end
end$ mix cucumber
Feature: Belly
Scenario: Eating cukes
Given I have 5 cukes in my belly
When I eat 3 cukes
Then I should have 2 cukes
1 scenario, 1 passed
Finished in 3msTable of Contents
- Why Cucumberex
- Installation
- Quick Start
- Writing Feature Files
- Step Definitions
- Cucumber Expressions
- Parameter Types
- World (Scenario State)
- Data Tables
- Doc Strings
- Hooks
- Tag Expressions
- Running Tests
- Formatters
- Configuration
- Project Layout
- Tooling
- Compatibility
- Contributing
- License
Why Cucumberex
- Full Gherkin.
Feature,Rule,Scenario,Background,Scenario Outline/Examples, data tables, doc strings, 70+ languages. - Cucumber Expressions and regex. Use readable templates like
{int} cukesor drop down to~r/^…$/when you need full regex control. - Hooks.
before,after,before_step,after_step,before_all,after_all, with optional tag expression filters. - Tag expressions.
@smoke and not @slow, parens,or,not. - Formatters. Pretty, Progress, JSON, HTML, JUnit, and Rerun — plus a behaviour for writing your own.
- No Phoenix, no Ecto, no database. Cucumberex is a stateless library. You can plug it into any Elixir project.
- Stateful test runs are isolated. World state is a plain map threaded through each scenario — no globals, no leakage between scenarios.
Installation
Add Cucumberex to your mix.exs deps:
def deps do
[
{:cucumberex, "~> 0.1"}
]
endThen:
$ mix deps.get
$ mix cucumber --init--init creates:
features/
├── example.feature
├── step_definitions/
│ └── steps.ex
└── support/
└── env.exRun:
$ mix cucumberQuick Start
1. Write a feature (features/calculator.feature):
Feature: Calculator
Scenario: Adding two numbers
Given I have entered 50 into the calculator
And I have entered 70 into the calculator
When I press add
Then the result should be 120 on the screen2. Write step definitions (features/step_definitions/calculator_steps.ex):
defmodule CalculatorSteps do
use Cucumberex.DSL
given_ "I have entered {int} into the calculator", fn world, n ->
Map.update(world, :stack, [n], &[n | &1])
end
when_ "I press add", fn world ->
Map.put(world, :result, Enum.sum(world.stack))
end
then_ "the result should be {int} on the screen", fn world, expected ->
if world.result != expected do
raise "Expected #{expected}, got #{world.result}"
end
world
end
end3. Run:
$ mix cucumberWriting Feature Files
Cucumberex uses the official Gherkin parser, so you get the full spec:
@smoke
Feature: Authentication
Background:
Given the system is online
Rule: Users with valid credentials can sign in
Scenario: Successful sign-in
When I submit valid credentials
Then I should be signed in
Scenario Outline: Invalid credentials are rejected
When I submit "<username>" and "<password>"
Then I should see error "<message>"
Examples:
| username | password | message |
| alice | wrong | Invalid password |
| bob | | Password required |
Feature files live under features/ by default.
Step Definitions
Step definition modules use Cucumberex.DSL and get four macros:
| Macro | Gherkin keyword | Use for |
|---|---|---|
given_/2 | Given | Establish context |
when_/2 | When | Perform an action |
then_/2 | Then | Assert an outcome |
step/2 | (any keyword) | Steps that don't care about keyword |
Each macro takes a pattern (string or regex) and a function. The function
receives world as its first argument, then one argument per captured
parameter, and returns the new world.
defmodule AuthSteps do
use Cucumberex.DSL
given_ "I am a user named {string}", fn world, name ->
Map.put(world, :user, %{name: name})
end
when_ ~r/^I log in as "(\w+)"$/, fn world, name ->
token = MyApp.Auth.login(name)
Map.put(world, :token, token)
end
then_ "my token should be valid", fn world ->
assert MyApp.Auth.valid?(world.token)
world
end
step "debug world", fn world ->
IO.inspect(world, label: "world")
world
end
endPending Steps
Call pending() anywhere inside a step body to mark the scenario pending:
then_ "the report should be generated", fn world ->
pending()
world
endCucumber Expressions
Prefer Cucumber Expressions to regex — they're more readable and integrate with parameter types:
| Expression | Matches |
|---|---|
{int} | -?\d+ → integer |
{float} | -?\d*\.\d+ → float |
{word} | [^\s]+ → String.t() |
{string} | "..." or '...' → content without quotes |
(optional) | Optional literal group |
a/b/c | Alternation (simple words only) |
given_ "I have {int} cukes", fn world, n -> ... end
# matches "I have 5 cukes" → n = 5
given_ "I {word} the button", fn world, verb -> ... end
# matches "I click the button" or "I press the button"
given_ "I open(ed) the page", fn world -> ... end
# matches "I open the page" OR "I opened the page"
given_ "I am on the home/landing/start page", fn world -> ... end
# matches any of the three literal alternatives
For full regex control, pass a ~r/…/ sigil instead:
given_ ~r/^I have (\d+) cukes$/, fn world, n_str ->
Map.put(world, :cukes, String.to_integer(n_str))
endParameter Types
Built-in types register automatically:
| Type | Regex | Transform |
|---|---|---|
int | -?\d+ | String.to_integer/1 |
float | -?\d*\.\d+ | String.to_float/1 |
word | [^\s]+ | identity |
string | quoted strings | content without quotes |
bigdecimal | decimal or integer | Float or Integer |
double | decimal or integer | Float or Integer |
byte, short, long | -?\d+ | String.to_integer/1 |
Custom Parameter Types
Register inside a step module:
defmodule MoneySteps do
use Cucumberex.DSL
parameter_type "money", ~r/\$\d+(?:\.\d{2})?/, fn [capture] ->
capture
|> String.trim_leading("$")
|> Decimal.new()
end
given_ "I have {money}", fn world, amount ->
Map.put(world, :balance, amount)
end
endWorld (Scenario State)
The world is a plain map threaded through every step in a scenario. Each scenario gets a fresh world — no state leaks between scenarios.
given_ "a registered user", fn world ->
user = create_user!()
Map.put(world, :user, user)
end
when_ "they sign in", fn world ->
token = sign_in!(world.user)
Map.put(world, :token, token)
endWorld Factory
Provide a factory to supply default world fields:
# features/support/env.ex
Cucumberex.World.Registry.set_factory(fn ->
%{
started_at: DateTime.utc_now(),
db: :ets.new(:scenario_db, [:set])
}
end)Data Tables
Gherkin data tables become Cucumberex.DataTable structs:
Scenario: Signing up users
Given the following users:
| name | email | role |
| alice | alice@example.com | admin |
| bob | bob@example.com | user |given_ "the following users:", fn world, table ->
users = Cucumberex.DataTable.hashes(table)
# [%{"name" => "alice", ...}, %{"name" => "bob", ...}]
Map.put(world, :users, users)
endAccessors mirror cucumber-ruby:
| Function | Returns |
|---|---|
raw/1 | [[cell, ...], ...] including header row |
hashes/1 | [%{header => value}, ...] (one map per data row) |
rows/1 | Data rows without the header |
rows_hash/1 | First column → key, second column → value |
symbolic_hashes/1 |
Like hashes/1 but with atom keys (use only with bounded headers) |
transpose/1 | Swap rows and columns |
map_headers/2 | Rename headers via map or function |
map_column/3 | Apply a function to every value in a named column |
diff!/2 |
Raise on mismatch, :ok otherwise |
verify_column!/2 | Raise if column name missing |
verify_table_width!/2 | Raise if any row width differs |
Note:
symbolic_hashes/1callsString.to_atom/1on headers. Only use it with known, bounded header values (e.g. fixture tables whose headers match struct fields). For arbitrary user input, usehashes/1to avoid unbounded atom creation.
Doc Strings
Multi-line step arguments parse into Cucumberex.DocString:
Given a blog post with content:
"""markdown
# Hello
This is **bold**.
"""given_ "a blog post with content:", fn world, doc ->
# doc.content → "# Hello\n\nThis is **bold**."
# doc.content_type → "markdown"
Map.put(world, :post_body, doc.content)
endDocString implements String.Chars, so interpolation works directly:
"The body is: #{doc}".
Hooks
defmodule TestSupport do
use Cucumberex.Hooks.DSL
before_all fn ->
MyApp.start_test_environment()
end
before fn world ->
Map.put(world, :transaction_started_at, System.monotonic_time())
end
before "@db", fn world ->
Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)
world
end
after_ fn world ->
MyApp.TestHelper.cleanup()
world
end
after_ "@db", fn world ->
Ecto.Adapters.SQL.Sandbox.checkin(MyApp.Repo)
world
end
before_step fn world ->
Logger.metadata(step_started_at: System.monotonic_time())
world
end
after_all fn ->
MyApp.stop_test_environment()
end
endHook phases:
| Hook | When |
|---|---|
before_all | Once, before any scenario |
before | Before each scenario |
before_step | Before each step |
after_step | After each step |
after_ | After each scenario (passed or failed) |
after_all | Once, after all scenarios |
A string as the first argument becomes a tag expression filter:
before "@admin and not @guest", fn world -> ... endTag Expressions
Tag filters support boolean logic:
@smoke
@smoke and @fast
@smoke or @wip
not @slow
(@smoke or @wip) and not @flakyRun only tagged scenarios:
$ mix cucumber --tags @smoke
$ mix cucumber --tags "@smoke and not @slow"
$ mix cucumber --tags "(@api or @web) and @happy-path"The same expressions filter hooks:
before "@db", fn world -> ... end
after_ "not @readonly", fn world -> ... endRunning Tests
mix cucumber [options] [feature files or directories]Filtering
| Flag | Meaning |
|---|---|
-t, --tags EXPR | Tag expression |
-n, --name PATTERN | Scenario name substring |
-e, --exclude PATTERN | Skip paths matching pattern (repeatable) |
features/x.feature:42 | Run only the scenario at line 42 |
Execution
| Flag | Meaning |
|---|---|
-d, --dry-run | Parse and match without executing step bodies |
--fail-fast | Stop at the first failure |
--strict | Fail run if any scenario is undefined or pending |
--strict-undefined | Fail only on undefined steps |
--strict-pending | Fail only on pending steps |
--wip | Fail if any scenario passes (Work-In-Progress) |
--retry N |
Retry each failing scenario up to N times |
--order ORDER | defined (default), random, or reverse |
--random [SEED] |
Shortcut for --order random with seed |
--reverse |
Shortcut for --order reverse |
Reporting
| Flag | Meaning |
|---|---|
-f, --format FORMAT | pretty (default), progress, json, html, junit, rerun |
-o, --out FILE |
Write formatter output to FILE |
-c, --color / --no-color | Toggle ANSI color |
--no-source | Hide step source locations |
-i, --no-snippets | Hide snippets for undefined steps |
--no-duration | Hide scenario duration |
-x, --expand | Expand scenario outline tables |
-b, --backtrace | Show full backtraces |
-v, --verbose | Show loaded files |
-q, --quiet |
Shorthand for --no-snippets --no-source --no-duration |
--snippet-type TYPE | cucumber_expression (default) or regexp |
Examples
# Only smoke tests, JSON output
mix cucumber --tags @smoke --format json --out smoke.json
# Dry-run the whole suite to find undefined steps
mix cucumber --dry-run --strict-undefined
# Run last failure set
mix cucumber --format rerun --out tmp/rerun.txt
mix cucumber @tmp/rerun.txt
# Randomize with a fixed seed for CI reproducibility
mix cucumber --random 42Formatters
Formatters are GenServers that subscribe to the event bus. Cucumberex
ships six, and you can write your own by implementing the
Cucumberex.Formatter behaviour.
| Formatter | Output | Typical use |
|---|---|---|
pretty | Terminal | Local development (default) |
progress | Terminal | CI logs — one dot per step |
json | File / stdout | Consumed by cucumber-json tools |
html | File | Self-contained visual report |
junit | File | CI dashboards (Jenkins, CircleCI, etc.) |
rerun | File | file:line list of failed scenarios |
Multiple Formatters
Stack --format + --out pairs (via cucumber.yml or repeat on CLI) to
emit several reports at once:
# cucumber.yml
default: --format pretty --format json --out tmp/report.json --format html --out tmp/report.htmlCustom Formatters
defmodule MyFormatter do
use Cucumberex.Formatter
@impl GenServer
def init(_opts), do: {:ok, %{failures: 0}}
defp on_event(%Cucumberex.Events.TestStepFinished{result: %{status: :failed}}, state) do
%{state | failures: state.failures + 1}
end
defp on_event(%Cucumberex.Events.TestRunFinished{}, state) do
IO.puts("Total failures: #{state.failures}")
state
end
defp on_event(_event, state), do: state
end
Select it on the CLI or in cucumber.yml:
mix cucumber --format MyFormatterConfiguration
Cucumberex reads configuration from four sources; later sources override earlier ones:
- Built-in defaults
:cucumberex, :configinmix.exs/config/config.exscucumber.ymlprofile (looked up incucumber.yml,cucumber.yaml,.config/cucumber.yml,config/cucumber.yml)- CLI arguments
cucumber.yml Profiles
default: --format pretty --strict
ci: --format progress --format junit --out tmp/junit.xml --strict
smoke: --tags @smoke --fail-fast
wip: --tags @wip --wip
Select a profile with --profile:
mix cucumber --profile cimix.exs Config
# config/config.exs
import Config
config :cucumberex,
paths: ["features"],
exclude: ["deprecated"],
strict: trueProject Layout
A typical Cucumberex project:
my_app/
├── features/
│ ├── authentication.feature
│ ├── reporting.feature
│ ├── step_definitions/
│ │ ├── auth_steps.ex
│ │ └── reporting_steps.ex
│ └── support/
│ ├── env.ex # World factory, before_all/after_all
│ └── test_helpers.ex # Shared helpers
├── cucumber.yml # Optional profiles
├── config/
│ └── config.exs
└── mix.exs
Cucumberex auto-loads every .ex file under features/support and
features/step_definitions. Use --require PATH to load additional files
or directories.
Tooling
The project itself is a good reference for Elixir tooling conventions:
mix compile --warnings-as-errors
mix test # 78 doctests + 31 unit tests
mix format --check-formatted
mix credo --strict # 0 issues
mix dialyzer # 0 warnings
mix cucumber # 11 scenarios (this project's own features)Compatibility
| Dependency | Version |
|---|---|
| Elixir | ~> 1.14 |
cucumber_gherkin | ~> 39.0 |
cucumber_messages | ~> 32.0 |
jason | ~> 1.4 |
yaml_elixir | ~> 2.9 |
nimble_options | ~> 1.0 |
Contributing
Issues and PRs welcome at github.com/jeffreybaird/cucumberex.
This project adheres to the conventions in CLAUDE.md and
the skill files under .claude/:
-
Tagged-tuple error protocol (
{:ok, _}/{:error, :atom}) -
Narrow
rescue(specific exception types only) - Every public function has a doctest (exempting I/O and GenServer calls)
- Tests are specifications — never modify tests to make them pass
- Trunk-based development, atomic commits, linear history
Before opening a PR, run:
mix format
mix credo --strict
mix dialyzer
mix test
mix cucumberLicense
MIT © 2026 Jeffrey Baird