Behave Step Definitions: Parameters, Reuse, and Context

Behave Step Definitions: Parameters, Reuse, and Context

Step definitions are where Gherkin meets Python. Writing them well is the difference between a test suite that's easy to maintain and one that becomes a tangled mess of duplicated code. This post covers everything about step definitions: parameter types, regex matching, the context object, reusing steps, and hooks.

How Behave Matches Steps

When Behave runs a scenario, it reads each step's text and finds a matching step definition. Matching works in two modes: parse (default) and regex.

Parse Mode (Default)

Parse mode uses a simplified pattern syntax. Named parameters in curly braces get extracted and passed to the function:

from behave import given, when, then

@when('I add "{product_name}" to the cart')
def step_add_to_cart(context, product_name):
    context.cart.add(product_name)

Quoted strings like "{product_name}" match anything inside quotes in the Gherkin text. Without quotes:

@then('the cart has {count} items')
def step_cart_count(context, count):
    # count is a string here
    assert len(context.cart.items) == int(count)

Typed Parameters

Behave's parse library supports type coercions out of the box:

@then('the cart has {count:d} items')
def step_cart_count(context, count):
    # count is already an int
    assert len(context.cart.items) == count

@then('the price is {price:f}')
def step_price(context, price):
    # price is a float
    assert context.cart.total() == pytest.approx(price)

@when('I wait {seconds:d} seconds')
def step_wait(context, seconds):
    import time
    time.sleep(seconds)

Built-in type formats:

  • {name:d} — integer (int)
  • {name:f} — float (float)
  • {name:e} — scientific notation float
  • {name:g} — general float
  • {name:w} — word (no spaces)
  • {name:S} — non-whitespace string (same as \S+ in regex)
  • {name} or {name:s} — any string

Custom Type Converters

Register custom types for domain-specific parsing:

# features/steps/type_registry.py
from behave import register_type
import parse

@parse.with_pattern(r'\d{4}-\d{2}-\d{2}')
def parse_date(text):
    from datetime import date
    year, month, day = text.split('-')
    return date(int(year), int(month), int(day))

register_type(Date=parse_date)

Then use it in steps:

@given('orders placed before {cutoff:Date} are archived')
def step_archived_orders(context, cutoff):
    context.archive_cutoff = cutoff

Matching Gherkin: Given orders placed before 2024-01-01 are archived

Regex Mode

For complex patterns, switch to regex matching with @step plus a raw string:

import re
from behave import step

@step(r'the (?P<color>red|green|blue) button (?:is|was) clicked')
def step_button_clicked(context, color):
    context.last_clicked = color

You can also use the use_step_matcher function to switch modes globally or per-file:

from behave import use_step_matcher

use_step_matcher("re")  # Switch to regex for this file

@given(r'the user "(?P<username>[^"]+)" has role "(?P<role>[^"]+)"')
def step_user_role(context, username, role):
    context.users[username].role = role

use_step_matcher("parse")  # Switch back

The Context Object

context is the central state object in Behave. Every step function receives it as the first argument. It persists across steps within a scenario and is reset between scenarios.

Setting and Reading State

@given('I am logged in as "{username}"')
def step_login(context, username):
    context.current_user = context.auth.login(username)
    context.session_token = context.current_user.token

@when('I view my profile')
def step_view_profile(context):
    context.response = context.api.get(
        '/profile',
        headers={'Authorization': f'Bearer {context.session_token}'}
    )

@then('I should see my username')
def step_see_username(context):
    data = context.response.json()
    assert data['username'] == context.current_user.username

Context Hierarchy

Context has three scopes, each isolated from the others:

  • Test run (before_all / after_all) — survives the entire run
  • Feature (before_feature / after_feature) — cleared between features
  • Scenario (before_scenario / after_scenario) — cleared between scenarios

Values set in before_all are available throughout. Values set in before_scenario are available only during that scenario.

Built-in Context Attributes

context.config          # Behave configuration object
context.feature         # Current Feature object
context.scenario        # Current Scenario object
context.step            # Current Step object
context.tags            # Tags on current scenario (list)
context.table           # Data table attached to current step
context.text            # Docstring attached to current step
context.failed          # True if scenario has a failed step

Access scenario name and tags in steps:

@given('I note the scenario name')
def step_note_name(context):
    print(f"Running: {context.scenario.name}")
    if 'slow' in context.scenario.tags:
        context.timeout = 60
    else:
        context.timeout = 10

Tables in Steps

When a step has a data table attached, access it via context.table:

Given the following users exist:
  | username | email              | role  |
  | alice    | alice@example.com  | admin |
  | bob      | bob@example.com    | user  |
@given('the following users exist')
def step_users_exist(context):
    for row in context.table:
        context.db.create_user(
            username=row['username'],
            email=row['email'],
            role=row['role']
        )

Tables have context.table.headings (list of column names) and iterate as row objects with dict-style access.

Docstrings in Steps

For multi-line text, use docstrings in Gherkin:

When I send the following JSON payload:
  """
  {
    "action": "place_order",
    "items": ["laptop", "mouse"],
    "shipping": "express"
  }
  """
@when('I send the following JSON payload')
def step_send_payload(context):
    import json
    payload = json.loads(context.text)
    context.response = context.api.post('/orders', json=payload)

Reusing Steps

Behave step functions are just Python functions. You can call them directly from other step functions:

@given('I have logged in and added {count:d} items to the cart')
def step_logged_in_with_items(context, count):
    # Reuse other step functions directly
    step_login(context, 'test_user')
    step_empty_cart(context)
    for i in range(count):
        step_add_to_cart(context, f'Product {i}')

Alternatively, use context.execute_steps() to run Gherkin text from within a step:

@given('I am a logged-in user with items in my cart')
def step_logged_in_with_cart(context):
    context.execute_steps('''
        Given I am logged in as "alice"
        And I have an empty cart
        When I add "Laptop" to the cart
        And I add "Headphones" to the cart
    ''')

execute_steps is convenient but has a cost: it's slower and makes debugging harder because failures inside it show up as errors in the outer step. Prefer direct function calls when the steps are in the same file.

Organizing Step Files

Group step definitions by domain, not by feature file:

features/steps/
├── auth_steps.py        # login, logout, session
├── cart_steps.py        # cart operations
├── checkout_steps.py    # payment, address, confirmation
├── api_steps.py         # generic API assertion steps
└── common_steps.py      # debug, wait, utility steps

Avoid duplicating step text across files. If two features need the same step, it belongs in common_steps.py. Behave will raise AmbiguousStep if the same text matches two step definitions.

Before and After Hooks

Hooks live in features/environment.py and run around scenarios, features, and the full test run.

# features/environment.py

def before_all(context):
    """Run once before any test."""
    context.config.setup_logging()
    context.api = APIClient(base_url=context.config.userdata.get('api_url'))
    context.db = DatabaseClient(context.config.userdata.get('db_url'))


def after_all(context):
    """Run once after all tests."""
    context.db.close()


def before_feature(context, feature):
    """Run before each feature file."""
    context.db.begin_transaction()


def after_feature(context, feature):
    """Run after each feature file."""
    context.db.rollback()


def before_scenario(context, scenario):
    """Run before each scenario."""
    context.db.savepoint('scenario_start')
    context.response = None
    context.last_error = None


def after_scenario(context, scenario):
    """Run after each scenario."""
    if scenario.status == 'failed':
        # Capture debug information on failure
        print(f"\nFailed scenario: {scenario.name}")
        if hasattr(context, 'response') and context.response:
            print(f"Last response: {context.response.status_code}")
            print(f"Response body: {context.response.text[:500]}")
    context.db.rollback_to_savepoint('scenario_start')


def before_step(context, step):
    """Run before each step."""
    pass


def after_step(context, step):
    """Run after each step."""
    if step.status == 'failed':
        # Log the step that failed
        print(f"\nFailed step: {step.name}")

Tag-Based Hook Logic

Run different setup based on tags:

def before_scenario(context, scenario):
    if 'browser' in scenario.tags:
        from selenium import webdriver
        context.driver = webdriver.Chrome()
        context.driver.implicitly_wait(10)
    
    if 'database' in scenario.tags:
        context.db.clean_test_data()
    
    if 'api' in scenario.tags:
        context.api.reset_rate_limits()


def after_scenario(context, scenario):
    if 'browser' in scenario.tags:
        if scenario.status == 'failed':
            context.driver.save_screenshot(
                f"reports/screenshots/{scenario.name}.png"
            )
        context.driver.quit()

Pending and Skipping Steps

Mark steps as pending during development:

from behave import pending

@when('I complete the checkout flow')
def step_checkout(context):
    context.scenario.skip("Checkout not implemented yet")
    # OR raise an exception to mark as failed:
    # raise NotImplementedError("Checkout not implemented")

Skip scenarios programmatically:

def before_scenario(context, scenario):
    if 'requires-vpn' in scenario.tags and not is_vpn_active():
        scenario.skip("VPN required for this test")

Step Argument Transforms

Register argument transforms to clean up common patterns:

# Convert "first", "second", "third" to indices
from behave import register_type
import parse

@parse.with_pattern(r'first|second|third|last')
def parse_ordinal(text):
    return {'first': 0, 'second': 1, 'third': 2, 'last': -1}[text]

register_type(Ordinal=parse_ordinal)

@then('the {position:Ordinal} item should be "{name}"')
def step_item_at_position(context, position, name):
    assert context.cart.items[position].name == name

Matching: Then the first item should be "Laptop"

Error Messages in Assertions

Good assertion messages make failures self-diagnosing:

@then('the API should return status {expected:d}')
def step_status_code(context, expected):
    actual = context.response.status_code
    assert actual == expected, (
        f"Expected HTTP {expected}, got {actual}\n"
        f"URL: {context.response.url}\n"
        f"Body: {context.response.text[:300]}"
    )

@then('the response should contain "{field}"')
def step_response_field(context, field):
    data = context.response.json()
    assert field in data, (
        f"Field '{field}' missing from response.\n"
        f"Available fields: {list(data.keys())}"
    )

When a test fails, Behave shows the assertion error message. Make it count.

Practical Patterns

Pattern: Building request context incrementally

@given('I make a {method} request to "{path}"')
def step_setup_request(context, method, path):
    context.pending_request = {
        'method': method.upper(),
        'path': path,
        'headers': {},
        'params': {},
        'body': None
    }

@when('I add header "{key}" with value "{value}"')
def step_add_header(context, key, value):
    context.pending_request['headers'][key] = value

@when('I send the request')
def step_send_request(context):
    req = context.pending_request
    context.response = context.api.request(
        method=req['method'],
        path=req['path'],
        headers=req['headers'],
        params=req['params'],
        json=req['body']
    )

Pattern: Soft assertions

@then('the response body should match')
def step_match_body(context):
    expected = json.loads(context.text)
    actual = context.response.json()
    errors = []
    for key, value in expected.items():
        if actual.get(key) != value:
            errors.append(f"{key}: expected {value!r}, got {actual.get(key)!r}")
    assert not errors, "Response mismatch:\n" + "\n".join(errors)

The core principle: steps should read like documentation and fail with enough information to diagnose the problem without re-running in a debugger. Keep logic out of step functions — delegate to helper classes, and use context to thread state through the scenario.

Read more

Start now free