Behave + Python BDD Tutorial: From Setup to CI
Behave is Python's most established BDD framework. It reads Gherkin .feature files, maps each step to a Python function, and executes them against your application. If you work in Python and want to practise behaviour-driven development, Behave is the natural starting point.
This tutorial takes you from installation through a working test suite with Selenium, fixtures, context passing, and a GitHub Actions workflow.
Installation
pip install behave selenium webdriver-managerFor a project, pin versions in requirements-test.txt:
behave==1.2.6
selenium==4.18.1
webdriver-manager==4.0.1
assertpy==1.1Project Structure
project/
├── features/
│ ├── login.feature ← Gherkin scenarios
│ ├── cart.feature
│ ├── steps/
│ │ ├── login_steps.py ← step definitions
│ │ └── cart_steps.py
│ └── environment.py ← hooks (before/after)
└── requirements-test.txtThe features/ directory is the entry point. Behave discovers everything from there.
Your First Feature File
Create features/login.feature:
Feature: User Authentication
As a registered user
I want to log into my account
So that I can access protected features
Background:
Given the browser is open
Scenario: Successful login
When I navigate to the login page
And I enter email "alice@example.com" and password "secret123"
And I click login
Then I should be redirected to the dashboard
And I should see a welcome message
Scenario: Login with wrong password
When I navigate to the login page
And I enter email "alice@example.com" and password "wrongpassword"
And I click login
Then I should see the error "Invalid credentials"
And I should remain on the login page
@wip
Scenario: Account locked after 5 failures
When I navigate to the login page
And I fail to login 5 times
Then my account should be locked
And I should see instructions to reset my passwordEnvironment File (Hooks)
Create features/environment.py to manage browser setup and teardown:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
def before_all(context):
"""Runs once before all tests."""
context.base_url = "https://your-app.example.com"
def before_scenario(context, scenario):
"""Runs before each scenario — fresh browser."""
options = Options()
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1920,1080")
context.driver = webdriver.Chrome(
service=Service(ChromeDriverManager().install()),
options=options
)
context.driver.implicitly_wait(10)
def after_scenario(context, scenario):
"""Runs after each scenario — quit browser."""
if scenario.status == "failed":
# Save screenshot on failure
context.driver.save_screenshot(
f"screenshots/{scenario.name.replace(' ', '_')}.png"
)
context.driver.quit()
def after_all(context):
"""Runs once after all tests."""
passThe context object is Behave's shared state — it flows through all hooks and step definitions in a scenario. Anything you attach to context is available in every step.
Step Definitions
Create features/steps/login_steps.py:
from behave import given, when, then, step
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from assertpy import assert_that
@given("the browser is open")
def step_browser_open(context):
# Browser already opened in before_scenario hook
assert context.driver is not None
@when("I navigate to the login page")
def step_navigate_login(context):
context.driver.get(f"{context.base_url}/login")
WebDriverWait(context.driver, 10).until(
EC.presence_of_element_located((By.ID, "email"))
)
@when('I enter email "{email}" and password "{password}"')
def step_enter_credentials(context, email, password):
context.driver.find_element(By.ID, "email").send_keys(email)
context.driver.find_element(By.ID, "password").send_keys(password)
@when("I click login")
def step_click_login(context):
context.driver.find_element(
By.CSS_SELECTOR, "button[type='submit']"
).click()
@then("I should be redirected to the dashboard")
def step_verify_dashboard(context):
WebDriverWait(context.driver, 10).until(
EC.url_contains("/dashboard")
)
assert_that(context.driver.current_url).contains("/dashboard")
@then("I should see a welcome message")
def step_verify_welcome(context):
welcome = WebDriverWait(context.driver, 5).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".welcome-message"))
)
assert_that(welcome.text).is_not_empty()
@then('I should see the error "{message}"')
def step_verify_error(context, message):
error = WebDriverWait(context.driver, 5).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, ".error-alert"))
)
assert_that(error.text).contains(message)
@then("I should remain on the login page")
def step_verify_login_page(context):
assert_that(context.driver.current_url).ends_with("/login")String parameters in Gherkin steps (quoted text like "Invalid credentials") are captured by surrounding with double quotes and passed as function arguments. Behave uses the same {parameter} notation as Cucumber.
Data Tables in Step Definitions
When a scenario passes a table, Behave gives you context.table:
Given the following test users exist:
| username | email | role |
| alice | alice@example.com | admin |
| bob | bob@example.com | user |@given("the following test users exist")
def step_create_users(context):
for row in context.table:
# row["username"], row["email"], row["role"]
create_user(
username=row["username"],
email=row["email"],
role=row["role"]
)Multi-line Text (Doc Strings)
When I send a POST request to "/api/items" with body:
"""
{
"name": "Widget",
"price": 9.99
}
"""@when('I send a POST request to "{endpoint}" with body')
def step_post_request(context, endpoint):
payload = context.text # the doc string content
import json
context.response = requests.post(
f"{context.base_url}{endpoint}",
json=json.loads(payload)
)Using Tags
Run specific subsets of tests with --tags:
# Run only smoke tests
behave --tags=smoke
<span class="hljs-comment"># Exclude WIP tests
behave --tags=<span class="hljs-string">"~wip"
<span class="hljs-comment"># Smoke AND not regression
behave --tags=<span class="hljs-string">"smoke,~regression"
<span class="hljs-comment"># Specific feature file
behave features/login.featureUse tags in environment hooks for conditional setup:
def before_scenario(context, scenario):
if "database" in scenario.tags:
context.db = setup_test_database()
if "api" in scenario.tags:
context.api_client = APIClient(base_url=context.base_url)Fixtures (Behave 1.2.6+)
Fixtures are reusable setup/teardown blocks. Define them in environment.py:
from behave.fixture import fixture, use_fixture_by_tag
@fixture
def browser_chrome(context, **kwargs):
"""Fixture: launch Chrome, quit after scenario."""
options = Options()
options.add_argument("--headless")
context.driver = webdriver.Chrome(options=options)
yield context.driver
context.driver.quit()
@fixture
def api_server(context, **kwargs):
"""Fixture: start a local test server."""
import subprocess
proc = subprocess.Popen(["python", "-m", "flask", "run", "--port=5001"])
context.api_base = "http://localhost:5001"
yield
proc.terminate()
FIXTURE_REGISTRY = {
"fixture.browser.chrome": browser_chrome,
"fixture.api.server": api_server,
}
def before_tag(context, tag):
if tag.startswith("fixture."):
return use_fixture_by_tag(tag, context, FIXTURE_REGISTRY)Then tag scenarios that need the fixture:
@fixture.browser.chrome
Scenario: Visual login test
When I navigate to the login page
Then the page should look correctGenerating Reports
Behave supports multiple output formatters:
# Pretty (default — human-readable terminal)
behave
<span class="hljs-comment"># JSON report for CI integration
behave -f json -o reports/results.json
<span class="hljs-comment"># JUnit XML for Jenkins/GitLab
behave -f junit -o reports/
<span class="hljs-comment"># Allure (install behave-allure-formatter)
behave -f allure_behave.formatter:AllureFormatter -o allure-resultsGitHub Actions CI
name: BDD Tests
on: [push, pull_request]
jobs:
behave-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -r requirements-test.txt
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
- name: Run Behave tests
run: |
mkdir -p reports screenshots
behave --tags="~wip" -f json -o reports/results.json
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: behave-report
path: |
reports/
screenshots/Testing APIs with Behave (No Browser)
Behave isn't only for browser tests. API tests are leaner and faster:
Feature: User API
Scenario: Create a new user
Given the API is available
When I POST to "/api/users" with:
| field | value |
| name | Alice |
| email | alice@example.com |
Then the response status should be 201
And the response should include "id"import requests
from behave import given, when, then
from assertpy import assert_that
@given("the API is available")
def step_api_available(context):
response = requests.get(f"{context.base_url}/health")
assert_that(response.status_code).is_equal_to(200)
@when('I POST to "{endpoint}" with')
def step_post_with_table(context, endpoint):
payload = {row["field"]: row["value"] for row in context.table}
context.response = requests.post(
f"{context.base_url}{endpoint}",
json=payload
)
@then("the response status should be {status:d}")
def step_verify_status(context, status):
assert_that(context.response.status_code).is_equal_to(status)
@then('the response should include "{field}"')
def step_response_has_field(context, field):
data = context.response.json()
assert_that(data).contains_key(field)Common Pitfalls
Forgetting environment.py: Without hooks, you'll repeat setup code in every step. Create the environment file early and move all setup there.
Using time.sleep(): Unreliable on slow CI machines. Always use WebDriverWait or Expected Conditions for browser tests, and retry logic for API tests.
Overly specific step text: When I click the blue button labelled "Submit" breaks when the button colour changes. Write steps at the intent level: When I submit the form.
Sharing state through global variables: Use context attributes. Global state makes parallel execution impossible and causes subtle test pollution.
Next Steps
Once your Behave suite is running:
- Add parallel execution with
behave-parallelorpytest-bdd(which integrates with pytest's concurrency plugins) - Use page objects to encapsulate selectors: a
LoginPageclass withlogin(email, password)method keeps step definitions clean - Integrate with Allure for rich visual reporting
- Add contract tests using Pact for API boundary verification
Behave is a mature, stable framework that fits naturally into Python projects. Its Gherkin files serve as living documentation that any team member — technical or not — can read and contribute to.