Structuring a Behave Project So It Scales Past 50 Scenarios
The first Behave project I set up for a team looked fine at 20 scenarios. One feature file, one steps file, a environment.py at the root — done. Then the suite grew. By the time we hit 60 scenarios spread across a dozen features, the steps file was over 800 lines, tag filtering was a guessing game, and every new contributor asked the same question: "Where does this go?" That's the scaling wall, and it's not a Behave problem — it's a structure problem.
The good news is that Behave's conventions are flexible enough to support a genuinely scalable layout, but it won't impose one on you. You have to make deliberate choices early: how features are grouped, how step definitions are split and shared, how tags are treated as a first-class organizational tool, and how fixtures are scoped so they don't become a tangled mess. None of these decisions are hard, but skipping them costs you later.
This article walks through the concrete structure I use when a project is expected to grow — the directory layout, the step-sharing patterns that don't create import chaos, and the tagging strategy that makes CI filtering actually work. Everything here is something you can retrofit into an existing suite or bake in from the start.
Learn Node.js, Cucumber, GitHub Copilot, APIs, CI/CD, and modern automation by building a complete framework.
The Directory Layout That Stops Breaking at Scale
The default Behave layout puts everything under a single features/ directory. That works until it doesn't. When you have authentication scenarios, order-management scenarios, search scenarios, and admin scenarios all sharing one steps/ folder, you end up with either one enormous steps file or a pile of files with no clear ownership. Here's the layout I reach for instead:
features/
auth/
login.feature
token_refresh.feature
orders/
create_order.feature
cancel_order.feature
search/
product_search.feature
steps/
auth_steps.py
orders_steps.py
search_steps.py
common_steps.py
environment.py
fixtures/
factories.py
payloads.py
A few things to call out here. Feature files live in domain-named subdirectories — Behave walks the tree recursively, so this works out of the box. Step files mirror those domains. common_steps.py is where genuinely shared steps live (things like "Given I am authenticated" or "Then the response status is 200"), but the rule I enforce is strict: a step only moves to common_steps.py when it is actually used in more than one domain. Premature consolidation is just as painful as duplication.
The fixtures/ directory is not a Behave convention — it's just a Python package I import from. factories.py holds functions that build request payloads or test data objects. payloads.py holds static JSON blobs or schema templates. Keeping these out of the steps files means your step definitions stay readable: they describe behavior, not data construction.
One mistake I see constantly is putting too much logic directly into environment.py. It becomes a dumping ground. Keep environment.py thin — it should call out to helper modules, not contain them. If your before_all hook is more than 20 lines, that's a sign something belongs in a dedicated setup module.
Step Definition Hygiene When You Have Dozens of Them
Step definitions are where Behave projects quietly rot. The pattern that causes the most pain is writing steps that are too specific — steps that embed data values instead of accepting them as parameters. When you have a suite that needs to cover edge cases and not just the happy path, rigid step definitions force you to write a new step for every variation, and suddenly you have 15 steps that all do the same thing with slightly different hardcoded values.
The fix is to parameterize aggressively from the start. Compare these two approaches:
# Brittle — too specific
@when('I submit an order for 3 items')
def step_submit_order_three(context):
context.response = context.client.post('/orders', json={'quantity': 3})
# Scalable — parameterized
@when('I submit an order for {quantity:d} items')
def step_submit_order(context, quantity):
context.response = context.client.post('/orders', json={'quantity': quantity})
The second version handles every quantity your feature files will ever need. Behave's built-in type coercions (:d for integer, :f for float) handle the parsing. Use them.
Another pattern that scales well is the context bag approach for passing state between steps. Instead of storing individual values on context directly (context.order_id, context.user_token, etc.), group related state into a small dataclass or dict:
# In environment.py before_scenario
context.order = {}
context.auth = {}
# In a step
context.order['id'] = response.json()['id']
context.order['status'] = response.json()['status']
This keeps the context namespace from becoming a flat soup of attributes, and it makes it obvious in a step definition which domain's state you're touching. When a scenario fails and you're reading the traceback, you'll thank yourself for this.
Step reuse across domains is where teams often introduce circular imports. The safe rule: common_steps.py imports from nothing else in the steps package. Domain step files can import from fixtures/ and from utility modules, but never from each other. If two domain step files need the same helper function, that function belongs in a shared utility module — not in another step file.
Tags as a First-Class Scaling Tool for CI and Local Runs
At 20 scenarios, running the full suite takes seconds and tags feel optional. At 100 scenarios hitting real or sandboxed APIs, a full run can take minutes — and that's when undisciplined tagging becomes a real problem. Tags are Behave's primary filtering mechanism, and treating them as an afterthought means you lose the ability to run meaningful subsets without editing files.
The tagging taxonomy I use has three layers:
- Domain tags —
@auth,@orders,@search. Applied at the feature level. These let you run a single domain in isolation:behave --tags=orders. - Criticality tags —
@smoke,@regression. Applied at the scenario level. Your CI pipeline runs@smokeon every pull request and@regressionnightly. - State tags —
@wip,@skip.@wipis for scenarios actively being written; your CI config excludes it with--tags=~wip.@skipis for known-broken scenarios that need a ticket attached.
A feature file using this system looks like this:
@orders
Feature: Order creation
@smoke
Scenario: Create a valid order with a single item
Given I am authenticated as a standard user
When I submit an order for 1 items
Then the response status is 201
And the response body contains an order id
@regression
Scenario: Create an order with the maximum allowed quantity
Given I am authenticated as a standard user
When I submit an order for 999 items
Then the response status is 201
The CI configuration then becomes explicit and readable. A smoke run is behave --tags=smoke. A full regression excluding in-progress work is behave --tags=regression --tags=~wip. No one has to remember which files to run or which to skip.
One discipline issue I've seen derail this system: teams add @smoke to too many scenarios because everything feels critical. A smoke suite that takes 8 minutes defeats its own purpose. I keep smoke to the absolute minimum — the scenarios that prove the system is alive and the core flows work. Everything else is regression. When you're dealing with flaky tests or authentication edge cases, having a fast, trustworthy smoke suite is what keeps the team from ignoring CI failures.
Finally, document your tag taxonomy in the repo. A short CONTRIBUTING.md section that lists the approved tags and their meaning prevents the inevitable drift where half the team uses @critical and the other half uses @smoke for the same concept. Structure only scales if the whole team understands it — and when you're debugging failures across a large suite, consistent tagging is what lets you isolate a problem in seconds instead of minutes.