gorghoa/scenariostate-behat-extension
A Behat extension for gorghoa/scenariostate that automatically wires shared scenario state into your step definitions. Keep acceptance tests clean and decoupled by injecting state objects instead of passing data around, making complex PHP test suites easier to maintain.
Install (dev only). This is a test-infrastructure package, so it belongs in require-dev:
composer require --dev gorghoa/scenariostate-behat-extension:^1.0.7
Use at least 1.0.7 if you're on Symfony 5 — that release added SF5 compatibility (see Gotchas for the constraint caveats).
Enable the extension in behat.yml. The extension class lives in the package's ServiceContainer namespace:
default:
extensions:
Gorghoa\ScenarioStateBehatExtension\ServiceContainer\ScenarioStateExtension: ~
suites:
default:
contexts: [ App\Behat\Context\FeatureContext ]
Look here first, in this order:
README.md in the repo — it is short, and the whole API is essentially one concept: a shared, per-scenario state object.src/ServiceContainer/ScenarioStateExtension.php — shows exactly which service ids get registered; this is what you'll type-hint.ScenarioState class/interface itself — that's the object your step definitions will read and write.First use case. You have two (or more) context classes — say ApiContext (HTTP calls) and WebContext (browser/assertions) — and a step in one produces data the other needs. Without this package you either cram everything into one giant context, use traits with static properties (leaks between scenarios), or reach into InitializedContextEnvironment to grab sibling contexts (brittle). With it, both contexts receive the same state instance for the duration of a scenario:
final class ApiContext implements Context
{
public function __construct(private ScenarioState $state) {}
/** @When I create a user named :name */
public function iCreateAUser(string $name): void
{
$this->state->user = $this->http->post('/users', ['name' => $name]);
}
}
final class AssertionContext implements Context
{
public function __construct(private ScenarioState $state) {}
/** @Then the user should be visible */
public function theUserShouldBeVisible(): void
{
Assert::assertSame('active', $this->state->user['status']);
}
}
That constructor injection is the whole payoff: no $this->userCreatedInAnotherContext, no static, no manual wiring in behat.yml.
Constructor injection, not setters. Behat instantiates contexts through its service container and autowires constructor arguments by type-hint. Type-hint the state service in every context that needs it — this is the intended integration path and the reason the extension exists. If a context is declared with explicit arguments in behat.yml, add the state service to that argument list explicitly; autowiring only covers the unregistered args.
Let the extension own the scenario lifecycle. The point of the extension over a hand-rolled shared object is that it hooks BeforeScenario/AfterScenario to hand you a fresh state per scenario. Do not cache state in static properties or in a singleton you registered yourself — you lose the reset guarantee and get order-dependent flakiness that only shows up on the second run of the suite.
Namespace your state deliberately. A single grab-bag object gets messy fast. Two workable conventions:
UserState, CartState), each type-hinted where needed. Most teams should do this.$state->set('cart', $cart)), which is simpler to start with but turns into an untyped bag of holding. If you go this route, add typed accessors on the state class so step definitions never touch raw keys.Use it with a Page Object layer, not instead of one. Page objects model the UI; scenario state models the facts established by previous steps. Keep the state to plain, assertable data (arrays, ids, value objects), not the page objects themselves — you'll want to serialize/dump it when debugging, and page objects usually hold a live session.
Cross-context data flow pattern. The canonical shape is arrange in context A, assert in context B:
Given ... (ApiContext) -> writes $state->order
When ... (ApiContext) -> reads $state->order, writes $state->receipt
Then ... (AssertionContext) -> reads $state->receipt
If you find yourself ping-ponging (A writes, B reads, A reads again), that's usually a sign the split between contexts is wrong, not a reason to add more fields.
Reset side-effecting collaborators in a hook. State objects often hold things that must be torn down (temp files, queues, fixtures). Add your own @BeforeScenario/@AfterScenario in a base context and clear those explicitly — the extension manages the state object's persistence, not the resources you put inside it.
If the package ever blocks you, the escape hatch is small. The underlying mechanic (InitializedContextEnvironment::getContext() in a BeforeScenario hook to share a hand-made state object) is ~30 lines. Since the package is archived, keep that migration in the back of your mind — see Gotchas.
It's archived (last release Oct 2020) — treat it as a pinned dependency. Pin the version in composer.json and expect nothing back from issues. 1.0.7 is the latest and only widened Symfony constraints to allow SF5; it did not refresh anything else. The practical risk is not the extension itself (it's tiny) but its composer.json constraints against newer Behat/PHPUnit/PHP versions. If Composer refuses to resolve on a modern stack, the fix is either an alias/--ignore-platform-reqs hack you should avoid, or a vendored copy of the ~30 lines of extension code. Vendoring is often the cleaner long-term answer than fighting constraints.
Symfony 5 is supported as of 1.0.7; Symfony 6+ is not. The bump only relaxed the symfony/* constraint range to include ^5.0. If you're on a newer framework, expect resolution failures and plan for the vendor-copy route — don't assume this release signals renewed maintenance.
Behat's container is booted once per run, not per scenario. The extension resets state via hooks; anything you attach to that state is your responsibility. Symptom of getting this wrong: suite green on a fresh run, red on --rerun or when scenarios are reordered. Fix: reset every mutable property you add in a @BeforeScenario hook, or make the state hold only primitives/arrays that get fully replaced.
Constructor signatures are a hard contract. Behat autowires by type-hint. A mistyped or too-loose type-hint (e.g. object $state or a subclass) silently fails to resolve and you get a container/service-not-found error that points at the context, not at the state registration. If a context won't instantiate, run behat --definitions / behat -dl — a broken context blows up there immediately, before any step runs.
Don't mix this with the "grab sibling contexts" trick. If some steps use the injected state and others mutate a different shared object obtained from InitializedContextEnvironment, you'll have two sources of truth and intermittent failures. Pick one and apply it uniformly.
Debugging state is deliberately boring. There's no inspector UI. Temporarily add:
/** @Then dump state */
public function dumpState(): void
{
var_dump($this->state); // or a json_encode for array-shaped state
}
and call it mid-scenario. Keep the dump step out of committed features. For anything more, --format=pretty --no-colors -vv plus var_dump beats hunting through Behat's own internals.
Beware @BeforeScenario ordering with @BeforeStep/transform hooks. If you transform step arguments into domain objects that you then stash in state, make sure the state reset runs before those hooks. Hook priority arguments (@BeforeScenario 100) are the lever.
Parallel test runs are fine but don't share state across processes. Each Behat process has its own container and its own state — which is what you want. Just don't persist state to a file/DB to "help" debugging or you'll reintroduce cross-scenario coupling.
Extension point: override the service registration. The extension is just a Behat ServiceContainer extension. If you need the state object to be a subclass, or want a different service id/alias, register your own extension and alias the state id to your class — no forking required. That's the cleanest way to add typed accessors to the state without editing vendor code.
Version-skew trap with step definition discovery. Contexts must be listed (or discovered via autoload paths) in behat.yml and have their constructor deps resolvable. Moving a context file into a new namespace without updating the contexts: list produces a "step not defined" error that looks like a regex problem and is not.
How can I help you explore Laravel packages today?