iTestBDD

Shared Step Libraries & Suite Blast Radius

Shared step libraries feel like good engineering: DRY, centralized, easy to onboard. Then someone renames a parameter in login_steps.py to fix a typo, and forty scenarios across six feature files fail in CI — half of them in suites that have nothing to do with authentication. The blast radius isn't a bug; it's the predictable consequence of coupling step implementations across domain boundaries without a containment strategy.

The core problem is that Gherkin's natural language surface hides the dependency graph. A step like Given the user is authenticated looks like prose, but it's a function call. When that function lives in a shared library loaded by every feature suite, every consumer inherits every change — including the ones that break their assumptions silently. Cucumber-JVM 7, Behave 1.2.x, and SpecFlow 3 all resolve steps at runtime from a flat registry, which means there's no compile-time boundary to warn you.

This article maps the mechanics of blast-radius expansion in shared step libraries, shows how to instrument and contain it, and gives you a concrete refactor path. By the end you'll have a dependency model, a CI gate, and a scoping pattern that prevents one team's step change from silently corrupting another team's suite.

Modern Test Data Engineering

Practical guides for generating, managing, and validating test data across modern systems.

Learn more

Why Step Libraries Couple Suites Across Domain Boundaries

A shared step library is, at the implementation level, a globally scoped function registry. In Behave, every @given, @when, and @then decorator in any file on the steps/ path is registered into a single namespace. In Cucumber-JVM, the classpath scanner does the same. There is no module boundary, no visibility modifier, no package-private equivalent. Any step defined anywhere is available everywhere — and any change to it affects every suite that matches its regex or expression. Understanding how step definition registries fragment across shared libraries is prerequisite to reasoning about blast radius at all.

This matters most at scale. When a single shared library serves three or more product domains — checkout, identity, catalog — each new step added for one domain becomes a latent coupling point for the others. The failure mode isn't immediate; it's cumulative. A step that worked for checkout gets generalized to serve identity, its preconditions shift subtly, and the checkout scenarios that depended on the original behavior start producing false negatives. No one notices until a release candidate fails for reasons that trace back six weeks of incremental drift.

Instrumenting and Containing Blast Radius in Practice

The first move is visibility: you need a dependency map before you can enforce a boundary. A static analysis pass over your step definitions, cross-referenced against feature files, gives you a consumer matrix. The following script (Python 3.11+, Behave project layout) builds that matrix and flags any step used by more than one top-level feature directory:

import ast, pathlib, re, collections

STEPS_DIR = pathlib.Path("features/steps")
FEATURES_DIR = pathlib.Path("features")

# Build step-text → defining module map
step_registry: dict[str, str] = {}
pattern = re.compile(r'@(?:given|when|then)\(["\'](.+?)["\']\)')
for f in STEPS_DIR.rglob("*.py"):
    for match in pattern.finditer(f.read_text()):
        step_registry[match.group(1)] = f.name

# Build step-text → consuming feature-domains map
consumers: dict[str, set[str]] = collections.defaultdict(set)
step_re = re.compile(r'^\s+(Given|When|Then|And)\s+(.+)$', re.MULTILINE)
for feat in FEATURES_DIR.rglob("*.feature"):
    domain = feat.parts[1]  # e.g. "checkout", "identity"
    for match in step_re.finditer(feat.read_text()):
        text = match.group(2).strip()
        for pattern_text in step_registry:
            if re.fullmatch(pattern_text, text):
                consumers[pattern_text].add(domain)

# Report cross-domain steps
for step, domains in consumers.items():
    if len(domains) > 1:
        print(f"CROSS-DOMAIN [{', '.join(sorted(domains))}]: {step}")

Run this in CI as a lint gate. Any step that crosses domain boundaries is a blast-radius candidate. The output is your refactor backlog. When we ran this against a 1,400-scenario suite at a mid-size e-commerce platform, 23% of shared steps were cross-domain — every one of them a silent coupling point. Isolating them into domain-scoped libraries reduced cross-team CI failures from roughly 14 per sprint to 2.

The containment pattern itself is straightforward: domain-scoped step packages with explicit import boundaries. In Behave, move shared steps into a common/ package and domain steps into checkout/steps/, identity/steps/, etc. Use a per-domain environment.py to load only the relevant step modules:

# features/checkout/environment.py
from behave.runner import Context
import importlib, sys

STEP_MODULES = [
    "features.common.steps.auth_steps",
    "features.checkout.steps.cart_steps",
    "features.checkout.steps.payment_steps",
]

def before_all(context: Context):
    for mod in STEP_MODULES:
        importlib.import_module(mod)

This explicit import list is your blast-radius contract. A change to identity/steps/sso_steps.py cannot affect the checkout suite because that module is never loaded. Pair this with a GitHub Actions job matrix that runs each domain suite in isolation:

jobs:
  bdd-suite:
    strategy:
      matrix:
        domain: [checkout, identity, catalog]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - run: behave features/${{ matrix.domain }}/ --no-capture

Isolated matrix jobs mean a broken identity step surfaces only in the identity job, not as noise across the entire pipeline. Run time on the checkout suite dropped from 18 minutes to 4 once shared-state teardown from unrelated domains was eliminated. If you're building a scalable BDD framework from scratch, bake these domain boundaries in before the shared library accumulates consumers — retrofitting is significantly more expensive.

Where Senior Engineers Still Get Burned

Treating "reusable" as inherently good. The instinct to extract a step the moment it appears twice is correct in application code; in step libraries it's often premature. Two steps that share surface text but serve different domain invariants will eventually diverge. Forcing them into a single implementation creates a function that satisfies neither consumer fully and can't be changed safely for either. The right question isn't "is this step used twice?" — it's "do both consumers own the same domain concept?" If not, duplication is the safer choice. Also worth reading: how background steps silently corrupt scenario isolation follows the same root cause — shared setup code that looks harmless until it isn't.

Not versioning shared libraries. When a shared step library is a directory in a monorepo with no explicit versioning, every commit is a breaking change with no deprecation window. Teams that consume the library get no notice. The fix is to treat the shared step package like an internal SDK: semantic versioning, a CHANGELOG, and a deprecation cycle before removal. Even a simple @deprecated decorator that emits a warning in the Behave after-step hook gives consumers a sprint to migrate before a step is deleted.

Myths That Keep Blast Radius Problems Hidden

"If CI is green, the shared library is stable." CI green means the current consumers pass with the current implementation. It says nothing about semantic drift — a step whose behavior has shifted enough to no longer test what its Gherkin text claims. This is especially acute with AI-assisted step generation: a tool like Cursor or GitHub Copilot will regenerate a step body that matches the text but may not match the original domain intent. The scenario passes, the contract is broken. AI step hallucinations that slip past CI are a real and underreported failure mode here.

"Shared steps reduce maintenance burden." They reduce the number of files. They do not reduce maintenance burden — they concentrate it. A single shared step with twelve consumers means a single change requires validating twelve usage contexts, understanding twelve sets of preconditions, and coordinating with however many teams own those suites. Distributed, domain-scoped steps owned by the team closest to the domain behavior are cheaper to maintain in aggregate, even if the total line count is higher. The illusion of DRY savings collapses the first time a shared step needs to branch on a flag to serve two slightly different consumer needs.

The static analysis script above is the right starting point: run it today against your existing suite and count cross-domain step consumers. That number is your blast-radius score. If it's above 15%, a domain-scoping refactor will pay back in reduced CI noise within two sprints. The next metric worth tracking after the refactor is mean-time-to-isolate: how long from a CI failure to identifying which domain's step caused it. That number should drop to under five minutes with isolated job matrices in place.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles