Writing Step Definitions That Stay Reusable
Step definitions are where BDD either pays off or falls apart. I've seen teams write beautiful Gherkin — clear business language, well-structured scenarios, stakeholders nodding along — and then bury all that value under a pile of step functions that are so tightly coupled to a single feature file that they can never be used again. Six months later the suite has three hundred step definitions, half of them doing nearly identical things, and nobody wants to touch any of them.
The problem isn't Behave, and it isn't BDD. It's the habit of writing steps as if each scenario is an island. Reusable step definitions require a different design instinct: you're writing a small, composable vocabulary for your test suite, not a one-off script that happens to have a decorator on it. That shift in thinking changes almost every decision — how you phrase the step text, how you parameterize it, where you put shared state, and how you handle setup that multiple features need.
In this article I'll walk through the concrete patterns I reach for when building a Behave suite that's meant to grow. Everything here is practical and Python-specific — no abstract theory, just the patterns that prevent the rewrite conversation six months from now.
Learn Node.js, Cucumber, GitHub Copilot, APIs, CI/CD, and modern automation by building a complete framework.
Parameterize Aggressively, But Keep the Step Text Human
The single fastest way to kill reusability is to hard-code values directly into your step text. A step like Given the user sends a GET request to /api/v1/products is already dead on arrival — it can only ever test one endpoint. The fix is obvious in hindsight: parameterize it.
# features/steps/api_steps.py
import requests
from behave import given, when, then
@given('the client sends a {method} request to {endpoint}')
def step_send_request(context, method, endpoint):
context.response = requests.request(
method.upper(),
f"{context.base_url}{endpoint}"
)
Now that single step covers every HTTP method and every path in your API. Behave's built-in {placeholder} syntax handles the capture automatically. But here's where teams often overcorrect: they parameterize so aggressively that the step text becomes unreadable. A step like Given {actor} performs {action} on {resource} with {payload} expecting {status} is technically reusable and practically unreadable in a feature file. The sweet spot is parameterizing the things that vary between scenarios while keeping the words that describe the intent fixed.
Use Behave's typed parameters when the value has a clear type constraint. {status_code:d} tells Behave to parse an integer and gives you a cleaner step body with no manual casting:
@then('the response status code is {status_code:d}')
def step_check_status(context, status_code):
assert context.response.status_code == status_code, (
f"Expected {status_code}, got {context.response.status_code}"
)
This step works for every status assertion across every feature file in your suite. Write it once, use it everywhere. That's the goal.
One more pattern worth internalizing: avoid encoding data in the step text when a table or a docstring is the right tool. If a step needs five fields to describe a request body, use a Behave table rather than five parameters. The step definition stays clean, and the feature file stays readable:
# Feature file
When the client sends a POST request to /api/v1/orders with body:
| field | value |
| product | widget-42 |
| quantity | 3 |
# Step definition
@when('the client sends a POST request to {endpoint} with body')
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
)
The step definition is still a single, reusable function. The data lives in the feature file where it belongs.
Managing Shared State Without Turning context Into a Junk Drawer
Behave passes a context object between steps, and it's the right place to share state within a scenario. It's also the place where reusability goes to die if you're not deliberate about it. I've inherited suites where context had dozens of ad-hoc attributes — context.the_thing, context.last_response, context.temp_user_id — set in one step and consumed in another with no contract between them. When you try to reuse a step in a different scenario, you find out the hard way that it silently depends on four other steps having run first.
The discipline that fixes this is treating context like a typed interface, not a global variable bag. Define the attributes your steps use in environment.py with sensible defaults:
# features/environment.py
def before_scenario(context, scenario):
context.base_url = "https://api.example.com"
context.response = None
context.auth_token = None
context.request_headers = {"Content-Type": "application/json"}
Now every step that touches context.response knows it exists and knows what it means. A new team member reading the step definition doesn't have to trace back through five other steps to understand the preconditions. This also makes it immediately obvious when a step is trying to use something that hasn't been set up — you get a clear None rather than a mysterious AttributeError that only fires in certain scenario orderings.
For authentication, resist the temptation to write a step like Given I am logged in as admin that buries an HTTP call and sets a token. That step is doing two jobs: it's a precondition AND a side-effectful action. Split them. Put the token-fetching logic in a helper function in a utils module, and call it from both the step definition and from before_scenario when a tag like @authenticated is present. The step stays thin and the logic stays testable.
# features/steps/auth_steps.py
from myproject.test_utils import fetch_auth_token
@given('the client is authenticated as {role}')
def step_authenticate(context, role):
context.auth_token = fetch_auth_token(role)
context.request_headers["Authorization"] = f"Bearer {context.auth_token}"
This step is now reusable across every feature that needs authentication, regardless of role. The fetch_auth_token utility can be tested independently. And because the step only sets two well-known context attributes, any subsequent step that reads headers or tokens knows exactly what to expect.
This kind of deliberate architecture pays compounding dividends as the suite grows — a point worth keeping in mind when you think about testing architecture at scale, where undisciplined shared state is one of the first things that causes a suite to become unmaintainable.
Organizing Step Definition Files So Steps Are Actually Found and Reused
Even perfectly written step definitions fail at reusability if they live in the wrong file. Behave loads all Python files in the steps/ directory, which means a step defined in product_steps.py is technically available to any feature. But "technically available" and "actually reused" are different things. When steps are organized by feature rather than by concern, engineers instinctively write new steps instead of searching for existing ones, because the search is too expensive.
The organization pattern I come back to is grouping steps by the layer of the system they interact with, not by the feature they were first written for:
- api_steps.py — generic request/response steps (send request, check status, check headers)
- auth_steps.py — authentication and authorization preconditions
- schema_steps.py — response body structure assertions
- data_steps.py — test data setup and teardown
Feature-specific logic that genuinely can't be generalized goes into a file named for that feature — orders_steps.py, inventory_steps.py — but those files should be thin. If you find yourself writing request-sending logic in orders_steps.py, that's a signal the logic belongs in api_steps.py with a parameter.
Schema assertions deserve their own file precisely because they're among the most reusable steps in an API suite. A step like Then the response body matches the {schema_name} schema can cover every endpoint if your schema validation utility is wired up correctly — and if you're not already doing automated schema validation, it's worth looking at how to validate API response schemas automatically to see how that fits into a Behave workflow.
Similarly, pagination-related steps — checking for next-page tokens, iterating through pages, asserting total counts — belong together in a reusable file rather than scattered across feature-specific step modules. The edge cases that come up when testing paginated API responses are exactly the kind of thing you want to write once and reuse everywhere, not rediscover in every feature that touches a list endpoint.
Finally, enforce a team norm: before writing a new step definition, run grep -r "your step text" features/steps/. It takes five seconds and prevents the duplication that makes step files grow into unmanageable tangles. Some teams add a linting step to CI that flags duplicate step patterns — that's worth the setup cost once the suite is large enough that manual checks become unreliable. The goal is a step vocabulary that grows deliberately, not accidentally, and that any engineer on the team can read, find, and reuse without a tour guide.