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
# 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}])
# Find a matching pattern and extract args
case find_matching_pattern(module, text) do
{pattern, args} ->
step_info = %{
module: module,
context: updated_context,
step: step,
pattern: pattern,
args: args,
docstring: docstring,
datatable: datatable,
feature_file: feature_file,
scenario_name: scenario_name
}
execute_step(step_info)
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
defp execute_step(%{
module: module,
context: context,
step: step,
pattern: pattern,
args: args,
docstring: docstring,
datatable: datatable,
feature_file: feature_file,
scenario_name: scenario_name
}) do
# Build context with all extras
context_with_extras = build_context_with_extras(context, args, docstring, datatable)
# Call the step function with the enhanced context
step_result = module.step(context_with_extras, pattern)
# Handle the result
handle_step_result(step_result, context, step, pattern, feature_file, scenario_name)
rescue
error ->
# If any other error occurs during step execution, wrap it in a StepError
current_history = Map.get(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
# Private helper functions for apply_step/3
defp find_matching_pattern(module, text) do
patterns = module.__cucumber_patterns__()
Enum.find_value(patterns, fn {pattern_text, _} ->
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)
end
defp build_context_with_extras(context, args, docstring, datatable) do
context
|> Map.put(:args, args)
|> add_docstring(docstring)
|> add_datatable(datatable)
end
defp add_docstring(context, nil), do: context
defp add_docstring(context, docstring), do: Map.put(context, :docstring, docstring)
defp add_datatable(context, nil), do: context
defp add_datatable(context, datatable) do
datatable_map =
if length(datatable) > 1 do
[headers | rows] = datatable
table_maps =
Enum.map(rows, fn row ->
Enum.zip(headers, row) |> Enum.into(%{})
end)
%{
headers: headers,
rows: rows,
maps: table_maps,
raw: datatable
}
else
%{
headers: [],
rows: datatable,
maps: [],
raw: datatable
}
end
Map.put(context, :datatable, datatable_map)
end
defp update_step_history(context, status, step) do
current_history = Map.get(context, :step_history, [])
updated_history = List.delete_at(current_history, -1) ++ [{status, step}]
Map.put(context, :step_history, updated_history)
end
defp handle_step_result(step_result, context, step, pattern, feature_file, scenario_name) do
case step_result do
:ok ->
update_step_history(context, "passed", step)
{:ok, value} when is_map(value) or is_list(value) ->
merge_context(context, value)
|> update_step_history("passed", step)
%{} = map ->
Map.merge(context, map)
|> update_step_history("passed", step)
keyword_list when is_list(keyword_list) and keyword_list != [] ->
handle_list_result(keyword_list, context, step, pattern, feature_file, scenario_name)
{:error, reason} ->
current_history = Map.get(context, :step_history, [])
failed_history = List.delete_at(current_history, -1) ++ [{"failed", step}]
raise Cucumber.StepError.failed_step(
step,
pattern,
reason,
feature_file,
scenario_name,
failed_history
)
other ->
raise_invalid_return_value(other, step, pattern, feature_file, scenario_name)
end
end
defp handle_list_result(list, context, step, pattern, feature_file, scenario_name) do
if Keyword.keyword?(list) do
merge_context(context, list)
|> update_step_history("passed", step)
else
raise_invalid_return_value(list, step, pattern, feature_file, scenario_name)
end
end
defp merge_context(context, keyword_list) when is_list(keyword_list) do
Map.merge(context, Map.new(keyword_list))
end
defp merge_context(context, map) when is_map(map) do
Map.merge(context, map)
end
defp raise_invalid_return_value(value, step, pattern, feature_file, scenario_name) do
message = """
Invalid return value from step implementation.
Step: #{step.keyword} #{step.text}
Pattern: "#{pattern}"
Location: #{feature_file}:#{step.line + 1}
Scenario: #{scenario_name}
Got: #{inspect(value)}
Valid return values are:
- :ok (keeps context unchanged)
- a map (merged into context)
- a keyword list (merged into context)
- {:ok, map_or_keyword_list} (merged into context)
- {:error, reason} (fails the step)
"""
raise Cucumber.StepError, message: message
end
@doc """
Defines a step pattern and its implementation.
The `defstep` 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 must return one of the following values (matching ExUnit's setup behavior):
- `:ok` - Keeps the context unchanged
- A map - Merged into the existing context
- A keyword list - Merged into the existing context
- `{:ok, map_or_keyword_list}` - Merged into the existing context
- `{:error, reason}` - Fails the step with the given reason
Any other return value will raise an error with a helpful message.
## 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", %{args: [username]} do
{: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
"""
# Handle the 2-arity version (pattern + block)
defmacro defstep(pattern, do: block) do
# Check if block uses 'context' variable
context_used? = ast_uses_var?(block, :context)
quote do
# Register the pattern in a module attribute for lookup
@cucumber_patterns {unquote(pattern), unquote(Macro.escape(block))}
# Generate a step/2 function - use _context if context not used to avoid warnings
def step(
unquote(if context_used?, do: quote(do: context), else: quote(do: _context)),
unquote(pattern)
) do
unquote(block)
end
end
end
# Handle the 3-arity version (pattern + context_var + block)
defmacro defstep(pattern, context_var, 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 custom context variable name
def step(unquote(context_var), unquote(pattern)) do
unquote(block)
end
end
end
# Helper to check if AST uses a specific variable
defp ast_uses_var?(ast, var_name) do
{_, found?} =
Macro.prewalk(ast, false, fn
{^var_name, _, nil}, _acc -> {{var_name, [], nil}, true}
node, acc -> {node, acc}
end)
found?
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