Current section
Files
Jump to
Current section
Files
lib/cucumber.ex
defmodule Cucumber do
@moduledoc """
A behavior-driven development (BDD) testing framework for Elixir using Gherkin syntax.
Cucumber is a testing framework that allows you to write executable specifications
in natural language. It bridges the gap between technical and non-technical stakeholders
by allowing tests to be written in plain language while being executed as code.
## Usage
To use Cucumber in your test file:
defmodule UserAuthenticationTest do
use Cucumber, feature: "user_authentication.feature"
defstep "I am on the sign in page", context do
# Step implementation
Map.put(context, :current_page, :sign_in)
end
# More step definitions
end
You can also filter scenarios by tags:
# Only run scenarios tagged with "smoke" or "auth"
use Cucumber, feature: "user_authentication.feature", tags: ["smoke", "auth"]
## Key Features
* Gherkin Support - Write tests in familiar Given/When/Then format
* Parameter Types - Define step patterns with typed parameters like `{string}`, `{int}`
* Data Tables - Pass structured data to your steps
* DocStrings - Include multi-line text blocks in your steps
* Background Steps - Define common setup steps for all scenarios
* Tag Filtering - Run subsets of scenarios using tags
* Context Passing - Share state between steps with a simple context map
* Rich Error Reporting - Clear error messages with step execution history
"""
defmacro __using__(opts) do
feature_file = Keyword.fetch!(opts, :feature)
filter_tags = Keyword.get(opts, :tags, [])
feature_path = Path.join(["test", "features", feature_file])
feature = Gherkin.Parser.parse(File.read!(feature_path))
# Filter scenarios based on tags if filter_tags is provided
filtered_scenarios =
if filter_tags == [] do
feature.scenarios
else
# Keep scenarios that have at least one matching tag
Enum.filter(feature.scenarios, fn scenario ->
Enum.any?(scenario.tags, &(&1 in filter_tags)) ||
Enum.any?(feature.tags, &(&1 in filter_tags))
end)
end
# Generate setup block
setup_block =
if feature.background do
quote do
setup context do
# Add feature file path to context
context = Map.put(context, :feature_file, unquote(feature_path))
# Add feature name to context
context = Map.put(context, :feature_name, unquote(feature.name))
# Initialize step history
context = Map.put(context, :step_history, [])
Enum.reduce(unquote(Macro.escape(feature.background.steps)), context, fn step, ctx ->
Cucumber.apply_step(__MODULE__, ctx, step)
end)
end
end
else
quote do
setup context do
# Add feature file path to context even without background
context = Map.put(context, :feature_file, unquote(feature_path))
# Add feature name to context
context = Map.put(context, :feature_name, unquote(feature.name))
# Initialize step history
context = Map.put(context, :step_history, [])
context
end
end
end
# Generate test blocks for each filtered scenario
test_blocks =
for scenario <- filtered_scenarios do
quote do
test unquote(scenario.name), context do
# Add scenario name to context
context = Map.put(context, :scenario_name, unquote(scenario.name))
Enum.reduce(unquote(Macro.escape(scenario.steps)), context, fn step, ctx ->
Cucumber.apply_step(__MODULE__, ctx, step)
end)
end
end
end
quote do
use ExUnit.Case, async: true
# Import only the defstep macros that we actually define
import Cucumber, only: [defstep: 2, defstep: 3]
# Register module attribute for cucumber patterns
Module.register_attribute(__MODULE__, :cucumber_patterns, accumulate: true)
@before_compile Cucumber
describe unquote(feature.name) do
unquote(setup_block)
unquote_splicing(test_blocks)
end
end
end
# Helper function to call step/2 in the test module with merged args and context
@doc """
Applies a step from a feature file to a matching step definition.
This function is used internally by the Cucumber framework to execute steps.
It handles the pattern matching, parameter extraction, and context management.
## Parameters
- `module` - The test module containing step definitions
- `context` - The current context map
- `step` - The `Gherkin.Step` struct to execute
## Returns
Returns the updated context map if the step succeeds, or raises a `Cucumber.StepError`
if the step fails or no matching step definition is found.
"""
def apply_step(
module,
context,
%Gherkin.Step{text: text, docstring: docstring, datatable: datatable} = step
) do
# Extract parameters using the Expression module
patterns = module.__cucumber_patterns__()
# Find a matching pattern and extract args
result =
Enum.find_value(patterns, fn pattern_info ->
{pattern_text, _} = pattern_info
compiled_pattern = Cucumber.Expression.compile(pattern_text)
case Cucumber.Expression.match(text, compiled_pattern) do
{:match, args} -> {pattern_text, args}
:no_match -> nil
end
end)
# Get feature file and scenario name from context for error reporting
feature_file = Map.get(context, :feature_file, "unknown_feature.feature")
scenario_name = Map.get(context, :scenario_name, "Unknown Scenario")
# Update step history with this step (pending)
step_history = Map.get(context, :step_history, [])
updated_context = Map.put(context, :step_history, step_history ++ [{"pending", step}])
case result do
{pattern, args} ->
try do
# Merge args into context and add docstring and datatable when present
context_with_args = Map.put(updated_context, :args, args)
# Add docstring if present
context_with_docstring =
if docstring,
do: Map.put(context_with_args, :docstring, docstring),
else: context_with_args
# Add datatable if present
context_with_extras =
if datatable do
# If first row looks like headers, convert to a list of maps
if length(datatable) > 1 do
[headers | rows] = datatable
# Convert rows to maps using headers as keys
table_maps =
Enum.map(rows, fn row ->
Enum.zip(headers, row) |> Enum.into(%{})
end)
Map.put(context_with_docstring, :datatable, %{
headers: headers,
rows: rows,
maps: table_maps,
raw: datatable
})
else
# Single row or empty table
Map.put(context_with_docstring, :datatable, %{
headers: [],
rows: datatable,
maps: [],
raw: datatable
})
end
else
context_with_docstring
end
# Call the step function with the enhanced context
step_result = apply(module, :step, [context_with_extras, pattern])
# Update step history to mark this step as passed
current_history = Map.get(context_with_extras, :step_history, [])
# Enhanced return value handling
case step_result do
# When step returns {:ok, map}, merge map into context
{:ok, value} when is_map(value) ->
new_context = Map.merge(context, value)
# Update step history in the new context
Map.put(
new_context,
:step_history,
List.delete_at(current_history, -1) ++ [{"passed", step}]
)
# When step returns a map directly, use it as the new context
%{} = new_context ->
# Make sure the step_history is preserved and updated
history = Map.get(new_context, :step_history, current_history)
Map.put(
new_context,
:step_history,
List.delete_at(history, -1) ++ [{"passed", step}]
)
# When step returns :ok or nil, keep existing context
result when result == :ok or result == nil ->
# Update step history in the existing context
Map.put(
context,
:step_history,
List.delete_at(current_history, -1) ++ [{"passed", step}]
)
# When step returns {:error, reason}, raise a StepError
{:error, reason} ->
# Update step history to mark this step as failed
failed_history = List.delete_at(current_history, -1) ++ [{"failed", step}]
raise Cucumber.StepError.failed_step(
step,
pattern,
reason,
feature_file,
scenario_name,
failed_history
)
# For any other return value, just keep the current context
_other ->
# Update step history in the existing context
Map.put(
context,
:step_history,
List.delete_at(current_history, -1) ++ [{"passed", step}]
)
end
rescue
error ->
# If any other error occurs during step execution, wrap it in a StepError
current_history = Map.get(updated_context, :step_history, [])
failed_history = List.delete_at(current_history, -1) ++ [{"failed", step}]
reraise Cucumber.StepError.failed_step(
step,
pattern,
error,
feature_file,
scenario_name,
failed_history
),
__STACKTRACE__
end
nil ->
# No matching pattern found, raise a helpful error with suggestions
raise Cucumber.StepError.missing_step_definition(
step,
feature_file,
scenario_name,
step_history
)
end
end
@doc """
Defines a step pattern and its implementation.
The `defstep/3` macro is used to define step implementations that match steps in feature files.
It supports pattern parameters like `{string}`, `{int}`, `{float}`, and `{word}`.
## Parameters
- `pattern` - The step pattern to match (e.g., "I click {string} button")
- `context` - The variable name to bind the context to (optional)
- `do` - The block of code to execute when the step matches
## Return Values
Step implementations can return values in several ways:
- `:ok` - For steps that perform actions but don't need to update context
- A map - To directly replace the context
- `{:ok, map}` - To merge new values into the context
- `{:error, reason}` - To indicate a step failure with a reason
## Examples
# Simple step with no parameters
defstep "I am on the login page" do
# Setup logic
%{page: :login}
end
# Step with string parameter
defstep "I enter {string} in the username field", context do
username = List.first(context.args)
{:ok, %{username: username}}
end
# Step with docstring
defstep "I submit the following comment:", context do
# Access the docstring
comment_text = context.docstring
{:ok, %{comment: comment_text}}
end
"""
defmacro defstep(pattern, context \\ nil, do: block) do
quote do
# Register the pattern in a module attribute for lookup
@cucumber_patterns {unquote(pattern), unquote(Macro.escape(block))}
# Generate a step/2 function with pattern as second parameter and merged context+args
def step(context_value, unquote(pattern)) do
# Bind context to the actual value (already contains args)
unquote(context || quote(do: context)) = context_value
unquote(block)
end
end
end
# __before_compile__ generates the function to return cucumber patterns
defmacro __before_compile__(env) do
patterns = Module.get_attribute(env.module, :cucumber_patterns) || []
quote do
# Helper function to get defined patterns for lookup
def __cucumber_patterns__ do
unquote(Macro.escape(patterns))
end
# Fallback step function for unmatched patterns
def step(_context, _pattern) do
raise "No matching step definition found"
end
end
end
end