# Best Practices for Cucumber Tests
This 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
└── lib/ # Test modules with step definitions
├── authentication_test.exs
└── shopping_test.exs
```
### Naming Conventions
- Use snake_case for feature file names
- Group related features into subdirectories
- Name test modules with a descriptive suffix (e.g., `LoginTest`, `CheckoutTest`)
## Writing Good Scenarios
### Scenario Best Practices
1. **Keep scenarios focused**: Each scenario should test one specific behavior
2. **Be consistent**: Use consistent language across scenarios
3. **Use concrete examples**: Prefer specific, realistic values to abstract placeholders
4. **Avoid technical details**: Keep scenarios in business language
5. **Keep them short**: Aim for 3-7 steps per scenario
6. **Use backgrounds wisely**: Only for truly common setup steps
### Example: Bad vs. Good
Bad:
```gherkin
Scenario: User interaction
Given a user
When the user does stuff
Then the outcome is good
```
Good:
```gherkin
Scenario: Customer adds product to cart from product detail page
Given I am logged in as "john@example.com"
And I am viewing the product "Ergonomic Keyboard"
When I click the "Add to Cart" button
Then I should see "Product added to cart" message
And my cart should contain 1 item
```
## Step Definition Best Practices
### Organization
1. **Group related step definitions**: Keep related steps together in the same file
2. **Use helper functions**: Extract common functionality into helper functions
3. **Create reusable steps**: Design steps to be reused across scenarios
### Naming Steps
1. **Use the actor's perspective**: "I click the button" rather than "Button is clicked"
2. **Be specific**: "I submit the registration form" rather than "I submit the form"
3. **Avoid technical implementation details**: "I click Login" rather than "I click #login-button"
### Step Implementation
```elixir
# Good: Clear, focused, with good error messages
defstep "I click the {string} button", context do
button_text = List.first(context.args)
case find_button(button_text) do
{:ok, button} ->
click(button)
:ok
{:error, :not_found} ->
{:error, "Button with text '#{button_text}' not found on page"}
end
end
# Bad: Vague, doing too much, poor error handling
defstep "I interact with the page", _context do
find_element("#some-button") |> click()
:ok
end
```
## Context Management
### Passing Data Between Steps
```elixir
# First step establishes context
defstep "I submit an order for {string}", _context do
product_name = List.first(context.args)
order_id = create_order(product_name)
# Return context to be used in following steps
{:ok, %{product_name: product_name, order_id: order_id}}
end
# Second step uses that context
defstep "I should receive an order confirmation email", context do
# Access data from previous step
assert_email_sent(
to: context.user.email,
subject: "Order Confirmation for ##{context.order_id}",
containing: context.product_name
)
:ok
end
```
### Context Guidelines
1. **Be explicit**: Only store what's needed for subsequent steps
2. **Consider naming**: Use descriptive keys in the context map
3. **Clean up after yourself**: Don't let the context grow too large
## Testing Different Layers
### UI Testing
```elixir
defstep "I click the {string} button", context do
button_text = List.first(context.args)
click_button(button_text)
:ok
end
```
### API Testing
```elixir
defstep "I make a GET request to {string}", context do
endpoint = List.first(context.args)
response = HTTPoison.get!("#{context.base_url}#{endpoint}")
{:ok, Map.put(context, :response, response)}
end
defstep "the response status should be {int}", context do
expected_status = List.first(context.args)
assert context.response.status_code == expected_status
:ok
end
```
### Database Testing
```elixir
defstep "there should be a user in the database with email {string}", context do
email = List.first(context.args)
user = Repo.get_by(User, email: email)
assert user != nil
:ok
end
```
## Handling Test Data
### Using Factory Functions
```elixir
# Helper function to create test data
defp create_test_user(attrs \\ %{}) do
defaults = %{
username: "testuser",
email: "test@example.com",
password: "password123"
}
Map.merge(defaults, attrs)
|> User.changeset()
|> Repo.insert!()
end
# Use in step definitions
defstep "a user exists with email {string}", context do
email = List.first(context.args)
user = create_test_user(%{email: email})
{:ok, %{user: user}}
end
```
### Using Tags for Test Environment
```gherkin
@require_db_cleanup
Feature: User Management
Scenario: Create a new user
When I create a user with email "newuser@example.com"
Then the user should exist in the database
```
```elixir
setup context do
if "require_db_cleanup" in context.feature_tags do
on_exit(fn ->
# Clean up database after tests
Repo.delete_all(User)
end)
end
context
end
```
## Common Testing Patterns
### The Given-When-Then Formula
1. **Given**: Establishes the initial context
2. **When**: Describes the key action
3. **Then**: Specifies expected outcomes
### Table-Driven Scenarios
```gherkin
Scenario Outline: User registration with different passwords
When I try to register with email "user@example.com" and password "<password>"
Then registration should be "<result>"
And I should see message "<message>"
Examples:
| password | result | message |
| pass | failed | Password is too short |
| password123 | success | Registration successful |
| noUppercase1 | failed | Password needs an uppercase letter |
| NoNumbers | failed | Password needs a number |
```
### State-Based Testing
```gherkin
Scenario: Completed order cannot be modified
Given I have an order with id "12345"
And the order status is "completed"
When I attempt to modify the order
Then I should receive an error "Cannot modify completed order"
```
## Continuous Integration
### Running Tests in CI
```yaml
# Example GitHub Actions workflow
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: erlef/setup-beam@v1
with:
otp-version: '25'
elixir-version: '1.14'
- run: mix deps.get
- run: mix test
```
### Reporting Test Results
Consider tools for generating test reports:
```elixir
# In test_helper.exs
ExUnit.configure(
formatters: [ExUnit.CLIFormatter, JUnitFormatter]
)
```
## Advanced Techniques
### Custom Parameter Types
```elixir
# Custom parameter type for dates
defmodule DateParameterType do
def match("today") do
Date.utc_today()
end
def match("tomorrow") do
Date.add(Date.utc_today(), 1)
end
def match(date_string) do
case Date.from_iso8601(date_string) do
{:ok, date} -> date
_ -> raise "Invalid date format: #{date_string}. Use ISO-8601 format."
end
end
end
# Using in step definitions
defstep "I schedule an appointment for {date}", context do
date = List.first(context.args)
# date is already a Date struct
{:ok, %{appointment_date: date}}
end
```
### Sharing Steps Between Test Modules
```elixir
# In a shared module
defmodule CommonSteps do
defmacro __using__(_opts) do
quote do
defstep "I am logged in as {string}", context do
username = List.first(context.args)
# Login logic
{:ok, %{current_user: find_user(username)}}
end
defstep "I should be on the {string} page", context do
page_name = List.first(context.args)
assert current_page() == page_name
:ok
end
end
end
end
# In a test module
defmodule AuthenticationTest do
use Cucumber, feature: "authentication.feature"
use CommonSteps
# Additional step definitions specific to this module
end
```
### Performance Considerations
1. **Minimize external dependencies**: Mock third-party services
2. **Optimize database usage**: Use transactions, clean up test data
3. **Reuse browser sessions**: For UI tests, avoid creating new sessions for each scenario
4. **Parallelization**: Consider running tests in parallel when possible