Behave — Advanced Playbook
§1 Project Setup & Configuration
Installation
pip install behave
pip install behave-html-formatter # HTML reports
pip install selenium # Web UI testing
pip install requests # API testing
pip install allure-behave # Allure reports
pip install PyHamcrest # Better assertions
Project Structure
features/
├── login.feature
├── checkout.feature
├── api/
│ ├── users.feature
│ └── products.feature
├── steps/
│ ├── login_steps.py
│ ├── checkout_steps.py
│ ├── api_steps.py
│ └── common_steps.py
├── pages/
│ ├── base_page.py
│ ├── login_page.py
│ └── dashboard_page.py
├── environment.py
├── behave.ini
└── fixtures.py
behave.ini
[behave]
paths = features
format = pretty
json
html
outfiles =
reports/results.json
reports/report.html
color = true
show_timings = true
junit = true
junit_directory = reports/junit
log_capture = true
logging_level = INFO
default_tags = ~@wip ~@skip
setup.cfg (Alternative Config)
[behave]
paths = features
format = progress3
junit = true
junit_directory = reports/junit
show_timings = true
§2 Feature Files — Gherkin Patterns
UI Feature with Scenario Outline
# features/login.feature
@ui @smoke
Feature: User Login
As a registered user
I want to log in to my account
So that I can access my dashboard
Background:
Given the test database is ready
And I am on the login page
@critical
Scenario: Successful login
When I enter "user@test.com" as email
And I enter "ValidPass123" as password
And I click the login button
Then I should be redirected to the dashboard
And I should see "Welcome back, Test User"
@negative
Scenario Outline: Login with invalid credentials
When I enter "<email>" as email
And I enter "<password>" as password
And I click the login button
Then I should see error message "<error>"
Examples: Invalid credentials
| email | password | error |
| wrong@test.com | ValidPass123 | Invalid email or password |
| user@test.com | wrong | Invalid email or password |
| | ValidPass123 | Email is required |
| user@test.com | | Password is required |
API Feature with Data Tables
# features/api/users.feature
@api
Feature: Users API
As an API consumer
I want to manage users via REST API
Background:
Given I am authenticated as admin
Scenario: Create a new user
When I send a POST request to "/api/users" with:
| name | Test User |
| email | newuser@test.com |
| role | viewer |
Then the response status should be 201
And the response should contain "id"
And the response field "name" should be "Test User"
Scenario: List users with pagination
When I send a GET request to "/api/users?page=1&limit=10"
Then the response status should be 200
And the response should contain at most 10 items
Scenario: Delete user returns 204
Given a user exists with email "delete-me@test.com"
When I send a DELETE request to the user endpoint
Then the response status should be 204
§3 Step Definitions — Production Patterns
Common Steps with Type Registration
# features/steps/common_steps.py
from behave import given, when, then, register_type, step
from hamcrest import assert_that, equal_to, contains_string, greater_than
import parse
def parse_boolean(text):
return text.strip().lower() in ('true', 'yes', '1')
register_type(Boolean=parse_boolean)
@given('the test database is ready')
def step_setup_db(context):
context.db_helper.reset_and_seed()
@given('I am on the "{page_name}" page')
def step_navigate(context, page_name):
pages = {
'login': '/login',
'register': '/register',
'dashboard': '/dashboard',
'products': '/products',
}
url = pages.get(page_name, f'/{page_name}')
context.browser.get(f"{context.base_url}{url}")
@then('I should see "{text}"')
def step_see_text(context, text):
assert_that(context.browser.page_source, contains_string(text))
@then('I should see error message "{message}"')
def step_see_error(context, message):
error_el = context.browser.find_element("css selector", ".error-message")
assert_that(error_el.text, contains_string(message))
API Steps
# features/steps/api_steps.py
from behave import given, when, then
from hamcrest import assert_that, equal_to, has_key, has_length, less_than_or_equal_to
import requests
import json
@given('I am authenticated as {role}')
def step_auth(context, role):
credentials = {
'admin': ('admin@test.com', 'AdminPass123'),
'user': ('user@test.com', 'UserPass123'),
}
email, password = credentials[role]
response = requests.post(f"{context.api_url}/auth/login", json={
'email': email, 'password': password,
})
context.auth_token = response.json()['token']
context.auth_headers = {
'Authorization': f'Bearer {context.auth_token}',
'Content-Type': 'application/json',
}
@given('a user exists with email "{email}"')
def step_create_user(context, email):
response = requests.post(f"{context.api_url}/users",
json={'name': 'Temp User', 'email': email, 'role': 'viewer'},
headers=context.auth_headers)
context.created_user_id = response.json()['id']
context.created_user_url = f"/api/users/{context.created_user_id}"
@when('I send a {method} request to "{url}"')
def step_send_request(context, method, url):
full_url = f"{context.api_url}{url.replace('/api', '')}"
context.response = requests.request(
method, full_url, headers=context.auth_headers)
@when('I send a {method} request to "{url}" with')
def step_send_request_with_table(context, method, url):
data = dict(context.table.rows)
full_url = f"{context.api_url}{url.replace('/api', '')}"
context.response = requests.request(
method, full_url, json=data, headers=context.auth_headers)
@when('I send a DELETE request to the user endpoint')
def step_delete_user(context):
url = f"{context.api_url}/users/{context.created_user_id}"
context.response = requests.delete(url, headers=context.auth_headers)
@then('the response status should be {status:d}')
def step_check_status(context, status):
assert_that(context.response.status_code, equal_to(status))
@then('the response should contain "{field}"')
def step_response_has_field(context, field):
body = context.response.json()
assert_that(body, has_key(field))
@then('the response field "{field}" should be "{value}"')
def step_response_field_value(context, field, value):
body = context.response.json()
assert_that(str(body[field]), equal_to(value))
@then('the response should contain at most {count:d} items')
def step_response_count(context, count):
body = context.response.json()
data = body.get('data', body)
assert_that(data, has_length(less_than_or_equal_to(count)))
§4 Environment Hooks — Lifecycle
# features/environment.py
import os
import logging
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
logger = logging.getLogger('behave')
def before_all(context):
"""Global setup — runs once before everything."""
context.base_url = os.environ.get('BASE_URL', 'http://localhost:3000')
context.api_url = os.environ.get('API_URL', 'http://localhost:3000/api')
context.screenshots_dir = 'reports/screenshots'
os.makedirs(context.screenshots_dir, exist_ok=True)
# Database helper
from features.fixtures import DatabaseHelper
context.db_helper = DatabaseHelper()
def before_feature(context, feature):
"""Runs before each feature file."""
logger.info(f"Starting feature: {feature.name}")
def before_scenario(context, scenario):
"""Runs before each scenario."""
# Start browser for UI tests
if 'ui' in scenario.effective_tags:
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--window-size=1920,1080')
remote_url = os.environ.get('SELENIUM_URL')
if remote_url:
context.browser = webdriver.Remote(
command_executor=remote_url,
options=chrome_options)
else:
context.browser = webdriver.Chrome(options=chrome_options)
context.browser.implicitly_wait(10)
# Database transaction for isolation
context.db_helper.begin_transaction()
def after_scenario(context, scenario):
"""Runs after each scenario."""
# Screenshot on failure
if scenario.status == 'failed' and hasattr(context, 'browser'):
safe_name = scenario.name.replace(' ', '_')[:50]
filepath = f"{context.screenshots_dir}/{safe_name}.png"
context.browser.save_screenshot(filepath)
logger.error(f"Screenshot saved: {filepath}")
# Close browser
if hasattr(context, 'browser'):
context.browser.quit()
del context.browser
# Rollback database
context.db_helper.rollback()
def after_all(context):
"""Global teardown."""
context.db_helper.close()
§5 Page Objects
Base Page
# features/pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
class BasePage:
def __init__(self, browser, base_url):
self.browser = browser
self.base_url = base_url
self.wait = WebDriverWait(browser, 15)
def navigate(self, path=''):
self.browser.get(f"{self.base_url}{path}")
def find(self, by, value):
return self.wait.until(EC.visibility_of_element_located((by, value)))
def click(self, by, value):
self.wait.until(EC.element_to_be_clickable((by, value))).click()
def type_text(self, by, value, text):
element = self.find(by, value)
element.clear()
element.send_keys(text)
def get_text(self, by, value):
return self.find(by, value).text
class LoginPage(BasePage):
URL = '/login'
EMAIL_INPUT = (By.ID, 'email')
PASSWORD_INPUT = (By.ID, 'password')
SUBMIT_BTN = (By.ID, 'login-submit')
ERROR_MSG = (By.CSS_SELECTOR, '.error-message')
def open(self):
self.navigate(self.URL)
def login(self, email, password):
self.type_text(*self.EMAIL_INPUT, email)
self.type_text(*self.PASSWORD_INPUT, password)
self.click(*self.SUBMIT_BTN)
def get_error(self):
return self.get_text(*self.ERROR_MSG)
§6 Fixtures & Test Data
# features/fixtures.py
import os
import json
import psycopg2
from contextlib import contextmanager
class DatabaseHelper:
def __init__(self):
self.conn = psycopg2.connect(os.environ.get(
'DATABASE_URL', 'postgresql://test:test@localhost/testdb'))
self.conn.autocommit = False
def begin_transaction(self):
self.conn.rollback() # Clear any pending
def rollback(self):
self.conn.rollback()
def reset_and_seed(self):
with self.conn.cursor() as cur:
cur.execute("DELETE FROM orders")
cur.execute("DELETE FROM cart_items")
cur.execute("DELETE FROM users WHERE email LIKE '%@test.com'")
cur.execute("""
INSERT INTO users (name, email, password_hash, role)
VALUES
('Admin', 'admin@test.com', '$hash', 'admin'),
('Test User', 'user@test.com', '$hash', 'user')
""")
self.conn.commit()
def close(self):
self.conn.close()
class TestDataLoader:
@staticmethod
def load_json(filename):
path = os.path.join('features', 'data', filename)
with open(path) as f:
return json.load(f)
@staticmethod
def load_users():
return TestDataLoader.load_json('test_users.json')
§7 LambdaTest Integration
# features/environment.py — LambdaTest section
def _create_lambdatest_browser(context, scenario):
"""Create browser on LambdaTest cloud."""
lt_options = {
'name': scenario.name,
'build': os.environ.get('BUILD_NAME', 'Behave-Build'),
'project': 'MyProject',
'platformName': 'Windows 11',
'w3c': True,
'video': True,
'network': True,
'console': True,
}
chrome_options = Options()
chrome_options.set_capability('LT:Options', lt_options)
username = os.environ['LT_USERNAME']
access_key = os.environ['LT_ACCESS_KEY']
hub_url = f'https://{username}:{access_key}@hub.lambdatest.com/wd/hub'
context.browser = webdriver.Remote(
command_executor=hub_url, options=chrome_options)
§8 CI/CD Integration
GitHub Actions
name: Behave Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
behave:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 10s
selenium:
image: selenium/standalone-chrome:latest
ports: ['4444:4444']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install behave selenium requests PyHamcrest allure-behave behave-html-formatter
- name: Run Behave Tests
run: |
behave \
--format=pretty \
--format=allure_behave.formatter:AllureFormatter \
--outfile=reports/allure \
--junit \
--junit-directory=reports/junit \
--tags="~@wip" \
--no-capture
env:
BASE_URL: http://localhost:3000
API_URL: http://localhost:3000/api
SELENIUM_URL: http://localhost:4444/wd/hub
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- uses: actions/upload-artifact@v4
if: always()
with:
name: behave-results
path: reports/
§9 Debugging Table
| # | Problem | Cause | Fix |
|---|
| 1 | NotImplementedError for step | Step definition not found | Run behave --snippets-type=parse to generate stubs |
| 2 | context.browser not available | Missing @ui tag on scenario | Add @ui tag; check before_scenario creates browser for tag |
| 3 | StaleElementReferenceException | Page reloaded after finding element | Re-find element after page changes; use explicit waits |
| 4 | Tests pass alone, fail together | Shared state via context leaking | Reset context attributes in before_scenario; use transaction rollback |
| 5 | Scenario Outline shows wrong data | Mismatch between placeholder and Examples column | Column names must match <placeholder> names exactly |
| 6 | before_scenario not firing | Wrong function signature | Must be def before_scenario(context, scenario): with both args |
| 7 | Screenshots not saved | Directory doesn't exist or wrong path | Use os.makedirs(dir, exist_ok=True) in before_all |
| 8 | context.table is None | Step pattern doesn't expect table data | Use table in step text: When I send a POST with: (colon triggers table) |
| 9 | Allure report empty | Formatter not installed or wrong output path | Verify allure-behave installed; check --outfile path |
| 10 | CI fails with selenium.common.exceptions | Chrome/ChromeDriver version mismatch | Use selenium/standalone-chrome:latest Docker image |
| 11 | Tags not filtering | Wrong tag syntax in behave.ini | Use --tags="@smoke" not --tags="smoke" (include @) |
| 12 | Import errors in steps | Step files not in features/steps/ directory | All step files must be in features/steps/ subdirectory |
§10 Best Practices Checklist
- Use Background for shared Given steps — avoid repetition across scenarios
- Use Scenario Outline with Examples — parameterize without duplicating
- Use
context for sharing state — never use global variables
- Use
environment.py hooks — setup/teardown at correct scope
- Use transaction rollback — fastest isolation between scenarios
- Use Page Objects — abstract browser interaction from step definitions
- Use
register_type() for custom step parsers — {count:d}, {Boolean}
- Use PyHamcrest matchers — readable assertions:
assert_that(x, equal_to(y))
- Use tags for organization —
@smoke, @api, @ui, @wip, @slow
- Use
--tags="~@wip" in CI — exclude incomplete scenarios
- Use screenshot on failure — attach to Allure or HTML reports
- Use JUnit output for CI — standard format for test report publishing
- Keep step definitions reusable — one step per action, not per scenario
- Write features in business language — domain experts should review Gherkin