Best Practices for Cucumber Tests
View SourceThis guide outlines best practices for writing and organizing your Cucumber tests to ensure they remain maintainable, readable, and effective.
Feature File Organization
Directory Structure
test/
├── features/ # Feature files
│ ├── authentication/ # Feature grouping by domain
│ │ ├── login.feature
│ │ └── registration.feature
│ ├── shopping/ # Another domain
│ │ ├── cart.feature
│ │ └── checkout.feature
│ └── step_definitions/ # Step definition files
│ ├── authentication_steps.exs
│ ├── shopping_steps.exs
│ └── common_steps.exs
Naming Conventions
- Use snake_case for feature file names
- Group related features into subdirectories
- Name step definition modules descriptively (e.g.,
AuthenticationSteps
,ShoppingSteps
) - Use
.exs
extension for step definition files
Writing Good Scenarios
Scenario Best Practices
- Keep scenarios focused: Each scenario should test one specific behavior
- Be consistent: Use consistent language across scenarios
- Use concrete examples: Prefer specific, realistic values to abstract placeholders
- Avoid technical details: Keep scenarios in business language
- Keep them short: Aim for 3-7 steps per scenario
- Use backgrounds wisely: Only for truly common setup steps
Example: Bad vs. Good
Bad:
Scenario: User interaction
Given a user
When the user does stuff
Then the outcome is good
Good:
Scenario: Customer adds product to shopping cart
Given I am logged in as "alice@example.com"
And I am viewing the "iPhone 15 Pro" product page
When I click "Add to Cart"
Then I should see "iPhone 15 Pro" in my shopping cart
And the cart total should be $999.00
Step Definition Best Practices
Keep Steps Reusable
Write generic steps that can be reused across features:
# Good - reusable
step "I click {string}", %{args: [button_text]} = context do
click_button(context, button_text)
end
# Less reusable - too specific
step "I click the red submit button on the login form", context do
click_specific_button(context)
end
Use the Context Effectively
Pass data between steps using the context:
step "I create a product named {string}", %{args: [name]} = context do
product = create_product(name: name)
Map.put(context, :product, product)
end
step "I should see the product in my catalog", context do
assert product_in_catalog?(context.product)
context
end
Organize Step Definitions
Group related steps in the same module:
# test/features/step_definitions/authentication_steps.exs
defmodule AuthenticationSteps do
use Cucumber.StepDefinition
import ExUnit.Assertions
# Login steps
step "I am logged in as {string}", %{args: [email]} = context do
user = login_user(email)
Map.put(context, :current_user, user)
end
# Registration steps
step "I register with email {string}", %{args: [email]} = context do
user = register_user(email: email)
Map.put(context, :new_user, user)
end
end
Common Patterns
Data Setup Pattern
Create helper functions for common data setup:
defmodule TestHelpers do
def create_user(attrs \\ %{}) do
default_attrs = %{
email: "test@example.com",
name: "Test User"
}
attrs = Map.merge(default_attrs, attrs)
# Create user logic
end
end
# In your steps
step "a user exists with email {string}", %{args: [email]} do
user = TestHelpers.create_user(email: email)
%{user: user}
end
Assertion Helpers
Create custom assertion helpers for cleaner steps:
defmodule AssertionHelpers do
import ExUnit.Assertions
def assert_logged_in(context) do
assert context[:current_user] != nil
assert context[:session_token] != nil
end
def assert_product_visible(context, product_name) do
assert product_name in get_visible_products(context)
end
end
Testing Tips
Use Tags for Organization
Tag your scenarios for easy filtering:
@authentication @smoke
Scenario: Successful login
Given I am on the login page
When I enter valid credentials
Then I should be logged in
@wip @slow
Scenario: Complex data processing
Given a large dataset
When I process the data
Then the results should be accurate
Run specific tags:
mix test --only authentication
mix test --exclude wip
Background vs. Helper Steps
Use backgrounds for truly common setup that applies to all scenarios:
Background:
Given the system is initialized
And default products exist
Scenario: View product catalog
When I visit the catalog page
Then I should see all products
Handling Asynchronous Operations
For operations that might take time:
step "I wait for the email to arrive", context do
# Poll for the email with a timeout
email = wait_for_email(context.current_user.email, timeout: 5_000)
Map.put(context, :received_email, email)
end
defp wait_for_email(email, opts) do
timeout = Keyword.get(opts, :timeout, 5_000)
poll_interval = 100
wait_until(timeout, poll_interval, fn ->
check_email_arrived(email)
end)
end
Debugging Tips
- Leverage Enhanced Error Messages: The framework now provides clickable file:line references in error messages that take you directly to the failing scenario
- Review Step Execution History: Error messages include a visual history (✓ for passed, ✗ for failed) showing which steps executed before the failure
- Use IO.inspect in steps: Temporarily add
IO.inspect(context)
to see the current state - Run single scenarios: Focus on one test at a time during debugging
- Use meaningful assertions: Include context in assertion messages
- Take advantage of formatted HTML output: When debugging PhoenixTest failures, the error messages now display HTML elements with proper indentation
step "the order should be completed", context do
order = context.order
assert order.status == "completed",
"Expected order #{order.id} to be completed, but was #{order.status}"
context
end
When an error occurs, you'll see output like:
** (Cucumber.StepError) Step failed:
Then the order should be completed
in scenario "Order Processing" at test/features/orders.feature:25
matching pattern: "the order should be completed"
Expected order 12345 to be completed, but was pending
Step execution history:
✓ Given I have items in my cart
✓ When I submit the order
✗ Then the order should be completed
The file reference test/features/orders.feature:25
is clickable in most editors and terminals.