Behave + Python BDD Tutorial: From Setup to CI

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-manager

For a project, pin versions in requirements-test.txt:

behave==1.2.6
selenium==4.18.1
webdriver-manager==4.0.1
assertpy==1.1

Project 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.txt

The 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 password

Environment 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."""
    pass

The 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.feature

Use 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 correct

Generating 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-results

GitHub 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-parallel or pytest-bdd (which integrates with pytest's concurrency plugins)
  • Use page objects to encapsulate selectors: a LoginPage class with login(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.

Read more

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB delivers Cassandra-compatible APIs with a rewritten Seastar-based engine that achieves dramatically higher throughput. Testing ScyllaDB applications requires validating both Cassandra compatibility and ScyllaDB-specific behaviors like shard-per-core data distribution. This guide covers both angles. ScyllaDB Testing Landscape ScyllaDB is a drop-in replacement for Cassandra at the API level—which means

By HelpMeTest