Cucumberex

Hex.pmHexDocsLicense: MIT

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 cukes
defmodule 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 3ms

Table of Contents

Why Cucumberex

Installation

Add Cucumberex to your mix.exs deps:

def deps do
  [
    {:cucumberex, "~> 0.1"}
  ]
end

Then:

$ mix deps.get
$ mix cucumber --init

--init creates:

features/
├── example.feature
├── step_definitions/
│   └── steps.ex
└── support/
    └── env.ex

Run:

$ mix cucumber

Quick 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 screen

2. 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
end

3. Run:

$ mix cucumber

Writing 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_/2Given Establish context
when_/2When Perform an action
then_/2Then 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
end

Pending Steps

Call pending() anywhere inside a step body to mark the scenario pending:

then_ "the report should be generated", fn world ->
  pending()
  world
end

Cucumber 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))
end

Parameter 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
end

World (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)
end

World 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)
end

Accessors 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/1 calls String.to_atom/1 on headers. Only use it with known, bounded header values (e.g. fixture tables whose headers match struct fields). For arbitrary user input, use hashes/1 to 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)
end

DocString 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
end

Hook 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 -> ... end

Tag Expressions

Tag filters support boolean logic:

@smoke
@smoke and @fast
@smoke or @wip
not @slow
(@smoke or @wip) and not @flaky

Run 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 -> ... end

Running 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 ORDERdefined (default), random, or reverse
--random [SEED] Shortcut for --order random with seed
--reverse Shortcut for --order reverse

Reporting

Flag Meaning
-f, --format FORMATpretty (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 TYPEcucumber_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 42

Formatters

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.html

Custom 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 MyFormatter

Configuration

Cucumberex reads configuration from four sources; later sources override earlier ones:

  1. Built-in defaults
  2. :cucumberex, :config in mix.exs / config/config.exs
  3. cucumber.yml profile (looked up in cucumber.yml, cucumber.yaml, .config/cucumber.yml, config/cucumber.yml)
  4. 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 ci

mix.exs Config

# config/config.exs
import Config

config :cucumberex,
  paths: ["features"],
  exclude: ["deprecated"],
  strict: true

Project 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/:

Before opening a PR, run:

mix format
mix credo --strict
mix dialyzer
mix test
mix cucumber

License

MIT © 2026 Jeffrey Baird