Current section

Files

Jump to
cucumber lib cucumber compiler.ex
Raw

lib/cucumber/compiler.ex

defmodule Cucumber.Compiler do
@moduledoc """
Compiles discovered features and steps into ExUnit test modules.
"""
alias Cucumber.Discovery
alias Gherkin.{Examples, Scenario, ScenarioOutline, Step}
@doc """
Compiles all discovered features into ExUnit test modules.
"""
def compile_features!(opts \\ []) do
# Discover features and steps
%Discovery.DiscoveryResult{
features: features,
step_registry: step_registry,
hook_modules: hook_modules
} = Discovery.discover(opts)
# Generate a test module for each feature
for feature <- features do
compile_feature(feature, step_registry, hook_modules)
end
end
defp compile_feature(feature, step_registry, hook_modules) do
# Generate module name from feature file path
module_name = generate_module_name(feature.file)
# Check if feature has @async tag
async = "async" in feature.tags
# Collect all hooks
all_hooks = Cucumber.Hooks.collect_hooks(hook_modules)
# Generate test module AST
ast =
quote do
defmodule unquote(module_name) do
use ExUnit.Case, async: unquote(async)
# Tag with cucumber and feature name
@moduletag :cucumber
@moduletag unquote(feature_tag(feature.name))
# Add feature tags as module tags (except reserved ones)
unquote_splicing(
for tag <- feature.tags, tag != "async" do
quote do: @moduletag(unquote(String.to_atom(tag)))
end
)
# Store the step registry for runtime access
def __step_registry__ do
unquote(Macro.escape(Map.new(step_registry)))
end
# If there's a background or feature-level tags, create setup block
unquote(generate_setup(feature.background, step_registry, feature, all_hooks))
# Expand scenario outlines and generate test for each scenario
unquote_splicing(
for scenario <- expand_all_scenarios(feature.scenarios) do
generate_scenario_test(scenario, step_registry, all_hooks, feature, async)
end
)
end
end
# Compile the module
[{^module_name, _}] = Code.compile_quoted(ast, feature.file)
module_name
end
defp generate_module_name(file_path) do
# Convert path like "test/features/authentication.feature"
# to Test.Features.AuthenticationTest
file_path
|> Path.rootname()
|> Path.split()
|> Enum.map_join(".", &Macro.camelize/1)
|> Kernel.<>("Test")
|> String.to_atom()
end
defp feature_tag(name) do
# Convert "User Authentication" to :feature_user_authentication
tag_name =
name
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "_")
|> String.trim("_")
:"feature_#{tag_name}"
end
defp generate_setup(nil, _step_registry, feature, all_hooks) do
# Always generate setup to run hooks for all scenarios
build_setup_block([], feature, all_hooks)
end
defp generate_setup(background, step_registry, feature, all_hooks) do
background_steps =
for step <- background.steps do
generate_step_execution(step, step_registry)
end
build_setup_block(background_steps, feature, all_hooks)
end
# Shared helper to build the setup block with hooks and optional background steps
defp build_setup_block(background_steps, feature, all_hooks) do
async = "async" in feature.tags
quote do
setup context do
# ExUnit puts @tag values directly in context as keys
# Filter out standard ExUnit keys to get scenario tags
exunit_keys = [
:async,
:line,
:module,
:registered,
:file,
:test,
:describe,
:describe_line,
:test_type,
:test_pid,
:test_group
]
scenario_tags =
context
|> Map.keys()
|> Enum.filter(&is_atom/1)
|> Enum.reject(&(&1 in exunit_keys))
|> Enum.map(&to_string/1)
# Combine feature tags + scenario tags for hook matching
all_tags = Enum.uniq(unquote(feature.tags) ++ scenario_tags)
# Initialize cucumber context
context =
Map.merge(context, %{
step_history: [],
feature_file: unquote(feature.file),
feature_tags: unquote(feature.tags),
scenario_tags: scenario_tags,
async: unquote(async)
})
# Run ALL matching hooks (global + feature + scenario tags)
result =
Cucumber.Hooks.run_before_hooks(
unquote(Macro.escape(all_hooks)),
context,
all_tags
)
case result do
{:ok, context} ->
# Execute background steps if any
unquote_splicing(background_steps)
# Register cleanup for after hooks
on_exit(fn ->
Cucumber.Hooks.run_after_hooks(
unquote(Macro.escape(all_hooks)),
context,
all_tags
)
end)
{:ok, context}
{:error, reason} ->
{:error, reason}
end
end
end
end
defp generate_scenario_test(scenario, step_registry, _all_hooks, feature, _async) do
# Generate tags for the scenario
tags =
scenario.tags
|> Enum.map(&String.to_atom/1)
|> Enum.map(fn tag -> quote do: @tag(unquote(tag)) end)
quote do
unquote_splicing(tags)
@tag unquote(scenario_tag(scenario.name))
@tag scenario_line: unquote((scenario.line || 0) + 1)
test unquote(scenario.name), context do
# Add scenario-specific context (hooks already ran in setup)
context =
Map.merge(context, %{
scenario_name: unquote(scenario.name),
feature_file: unquote(feature.file),
scenario_line: unquote((scenario.line || 0) + 1)
})
# Execute scenario steps
unquote_splicing(
for step <- scenario.steps do
generate_step_execution(step, step_registry)
end
)
end
end
end
defp scenario_tag(name) do
# Convert "User logs in successfully" to :scenario_user_logs_in_successfully
tag_name =
name
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "_")
|> String.trim("_")
:"scenario_#{tag_name}"
end
defp generate_step_execution(step, _step_registry) do
quote do
context =
Cucumber.Runtime.execute_step(
context,
unquote(Macro.escape(step)),
__MODULE__.__step_registry__()
)
end
end
# Scenario Outline expansion functions
@doc false
# Public for testing - expands scenario outlines into concrete scenarios
def expand_all_scenarios(scenarios) do
Enum.flat_map(scenarios, fn
%Scenario{} = scenario -> [scenario]
%ScenarioOutline{} = outline -> expand_outline(outline)
end)
end
defp expand_outline(%ScenarioOutline{examples: []} = outline) do
raise """
Scenario Outline '#{outline.name}' has no Examples section.
Every Scenario Outline must have at least one Examples block with data rows.
"""
end
defp expand_outline(%ScenarioOutline{} = outline) do
outline.examples
|> Enum.flat_map(fn %Examples{} = examples ->
examples.table_body
|> Enum.with_index(1)
|> Enum.map(fn {row, row_num} ->
substitutions = Enum.zip(examples.table_header, row) |> Map.new()
%Scenario{
name: generate_test_name(outline.name, examples.name, row_num),
steps: substitute_steps(outline.steps, substitutions),
tags: Enum.uniq(outline.tags ++ examples.tags),
line: outline.line
}
end)
end)
end
defp generate_test_name(outline_name, "", row_num) do
"#{outline_name} (row #{row_num})"
end
defp generate_test_name(outline_name, examples_name, row_num) do
"#{outline_name} (#{examples_name}: row #{row_num})"
end
defp substitute_steps(steps, substitutions) do
Enum.map(steps, fn %Step{} = step ->
%{
step
| text: substitute_placeholders(step.text, substitutions),
docstring: substitute_placeholders(step.docstring, substitutions),
datatable: substitute_datatable(step.datatable, substitutions)
}
end)
end
defp substitute_placeholders(nil, _substitutions), do: nil
defp substitute_placeholders(text, substitutions) do
Enum.reduce(substitutions, text, fn {key, value}, acc ->
String.replace(acc, "<#{key}>", value)
end)
end
defp substitute_datatable(nil, _substitutions), do: nil
defp substitute_datatable(table, substitutions) do
Enum.map(table, fn row ->
Enum.map(row, &substitute_placeholders(&1, substitutions))
end)
end
end