AUTOMATION FRAMEWORKS

Sharing Test Data Across a Behave Suite Safely

One of the first things teams run into when a Behave suite grows past a handful of feature files is the question of where does shared data live? You've got an auth token that every scenario needs, a base URL that changes per environment, and maybe a database seed record that a whole feature depends on. The temptation is to shove everything into context and call it done. In practice, that works fine until it doesn't — and when it breaks, it breaks in the most confusing ways: a scenario passes in isolation but fails in the full run, or worse, it passes non-deterministically depending on execution order.

The root cause is almost always the same: data that was meant to be scoped to one scenario quietly bleeds into the next one, or setup that was meant to run once per feature fires multiple times because nobody agreed on where it belonged. Behave gives you the tools to avoid all of this — before_all, before_feature, before_scenario, and the layered context object — but the tool only helps if you're deliberate about which scope you're writing into and why.

This article walks through the concrete patterns I reach for when I need to share data safely across a Behave suite: what goes in which hook, how to protect mutable state from leaking between scenarios, and how to structure your environment.py so that a new team member can read it top-to-bottom and understand exactly what's in scope at any point in the run.

Build an API Automation Framework With Node.js

Learn Node.js, Cucumber, GitHub Copilot, APIs, CI/CD, and modern automation by building a complete framework.

Learn more

Choosing the Right Behave Hook Scope for Each Kind of Shared Data

Behave's context object is hierarchical. When a new scope opens — suite, feature, scenario — Behave pushes a new layer onto the context stack. When that scope closes, the layer is popped. Anything you wrote into the lower layer is gone. Anything you wrote into a higher layer is still there but can be shadowed by a lower one. Understanding this is the single most important thing you can do to avoid data bleed.

Here's the mental model I use:

  • before_all / after_all — suite-wide constants only. Base URL, environment config, a shared HTTP session, a read-only auth token that never changes across the run. Nothing mutable goes here unless you're absolutely certain it's safe to share across parallel or sequential features.
  • before_feature / after_feature — feature-scoped setup. If a feature needs a specific database fixture or a dedicated test user, create it here and clean it up in after_feature. Don't rely on a previous feature having left something behind.
  • before_scenario / after_scenario — everything that must be isolated. API responses, request payloads, IDs created during a test — all of it belongs here. If it can change during a scenario, it lives in scenario scope.

A concrete example: suppose you're testing a REST API that requires a Bearer token. The token is valid for the whole test run. Put it in before_all:

# environment.py
def before_all(context):
    context.base_url = os.getenv("API_BASE_URL", "https://api.staging.example.com")
    context.auth_token = fetch_service_token()  # one call, reused everywhere

def before_scenario(context, scenario):
    context.response = None   # reset per scenario — never carry over a stale response
    context.created_ids = []  # track anything created so after_scenario can clean up

The key discipline: context.response and context.created_ids are explicitly reset in before_scenario. If you skip that reset and a scenario fails mid-way, the next scenario inherits whatever state the previous one left behind. That's the source of most "it only fails in CI" bugs I've seen.

When you're thinking about how this fits into a larger system, the same scoping principle applies to building scalable solutions with Behave and Requests — the data layer and the hook layer need to be designed together, not bolted on after the fact.

Protecting Mutable Shared State from Scenario-to-Scenario Bleed

Even with the right hook scope, mutable objects are a trap. Say you store a dictionary of headers in before_all because most scenarios use the same base headers. Then one scenario adds a custom header for a specific test. If you mutate the dictionary directly, every subsequent scenario in the run now has that extra header — whether it wants it or not.

The fix is to never mutate shared objects in place. Copy them at the scenario level instead:

def before_all(context):
    context.base_headers = {
        "Authorization": f"Bearer {fetch_service_token()}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }

def before_scenario(context, scenario):
    # Each scenario gets its own copy — mutations here don't touch the suite-level dict
    context.headers = dict(context.base_headers)
    context.response = None
    context.created_ids = []

This pattern — define the canonical version at suite scope, shallow-copy it into scenario scope — is something I use consistently. It's cheap, it's obvious when you read it, and it eliminates an entire class of intermittent failures.

The same logic applies to lists. If you're accumulating IDs or error messages during a scenario, initialize a fresh list in before_scenario, not once in before_all. A list that's never cleared is a list that grows forever and eventually causes a scenario to fail because it's acting on IDs from three scenarios ago.

Another common mistake: storing a requests Session object at suite scope and then letting step definitions modify its headers or cookies directly. The session itself is fine at suite scope (it handles connection pooling nicely), but any per-scenario state — cookies set during login, custom headers added mid-test — needs to be stripped in after_scenario. I usually do this explicitly:

def after_scenario(context, scenario):
    # Clear any session state that a scenario may have added
    context.session.cookies.clear()
    context.session.headers = dict(context.base_headers)

    # Clean up any resources created during the scenario
    for resource_id in context.created_ids:
        delete_test_resource(context, resource_id)

This kind of disciplined teardown is what separates a suite that stays reliable over hundreds of runs from one that starts failing mysteriously after a few weeks of new scenarios being added. It's also worth noting that solid test architecture decisions around data management pay off most in exactly this kind of long-running, multi-feature suite.

Structuring environment.py So the Whole Team Understands What's in Scope

A Behave suite's environment.py is effectively the contract for what data is available at each point in the run. When it's well-structured, any engineer on the team can open it, read it top-to-bottom, and know exactly what context contains in any given step. When it's a pile of ad-hoc assignments accumulated over months, it's a liability.

The structure I reach for organizes the file by scope, with a brief comment block at the top of each hook explaining what it's responsible for:

# environment.py

import os
from myapp.auth import fetch_service_token
from myapp.http import build_session
from myapp.cleanup import delete_test_resource


# ── Suite scope ──────────────────────────────────────────────────────────────
# Set once. Read-only after before_all completes.
# context.base_url       — API root for this environment
# context.auth_token     — service-level token, valid for the full run
# context.session        — shared requests.Session (connection pooling only)
# context.base_headers   — canonical headers; copy into context.headers per scenario

def before_all(context):
    context.base_url = os.environ["API_BASE_URL"]
    context.auth_token = fetch_service_token()
    context.session = build_session()
    context.base_headers = {
        "Authorization": f"Bearer {context.auth_token}",
        "Content-Type": "application/json",
    }

def after_all(context):
    context.session.close()


# ── Feature scope ─────────────────────────────────────────────────────────────
# context.feature_user   — a dedicated test user for this feature (if needed)

def before_feature(context, feature):
    if "requires_user" in feature.tags:
        context.feature_user = create_test_user(context)

def after_feature(context, feature):
    if hasattr(context, "feature_user"):
        delete_test_user(context, context.feature_user["id"])


# ── Scenario scope ────────────────────────────────────────────────────────────
# context.headers        — per-scenario copy of base_headers (safe to mutate)
# context.response       — last HTTP response object
# context.created_ids    — IDs to clean up in after_scenario

def before_scenario(context, scenario):
    context.headers = dict(context.base_headers)
    context.response = None
    context.created_ids = []

def after_scenario(context, scenario):
    context.session.cookies.clear()
    for resource_id in context.created_ids:
        delete_test_resource(context, resource_id)

A few things worth calling out in this structure. First, the comment block at the top of each scope section doubles as documentation — it's the canonical list of what context holds at that level. When someone adds a new attribute, they update the comment. This discipline is low-cost and prevents the "where did context.foo come from?" questions that slow down code reviews.

Second, feature-level setup is guarded by a tag check ("requires_user" in feature.tags). Not every feature needs a dedicated user, and running that setup unconditionally wastes time and creates cleanup debt. Tag-gating feature-level hooks is a pattern that scales well as the suite grows.

Third, step definitions should only read suite-scope and feature-scope attributes, never write to them. If a step needs to store something, it writes to scenario scope. Enforcing this as a team convention — even informally — prevents the kind of cross-feature coupling that makes suites fragile. When you're thinking about how these conventions fit into a broader strategy for robust automation, data scoping is one of the first things worth getting right, because fixing it later means touching every hook and half your step library.

The payoff for all of this structure is a suite you can actually trust. Scenarios run in any order. New team members can add features without accidentally breaking existing ones. And when something does fail, the failure is isolated to the scenario that caused it — not mysteriously distributed across the next five runs.