Binding tests to scenarios

Version 0.6.0 Updated Sep 14, 2026

The #[scenario] macro is the entry point that ties a Rust test function to a scenario defined in a .feature file. It accepts six arguments:

Argument Purpose Status
path: &str Relative path to the feature file (required). Implemented: resolved and parsed at macro-expansion time.
index: usize Optional zero-based scenario index (defaults to 0). Implemented: selects the scenario by position.
name: &str Optional scenario title; resolves when unique. Implemented: errors when missing and directs duplicates to index.
tags: &str Optional tag-expression filter applied at expansion. Implemented: filters scenarios and outline example rows; errors when nothing matches.
harness: Path Optional harness adapter type implementing HarnessAdapter + Default. Implemented: emits trait-bound assertions and delegates execution when specified.
attributes: Path Optional attribute policy type implementing AttributePolicy. Implemented: emits a compile-time trait-bound assertion and resolves policy-backed test attributes.

Tag filters run at macro-expansion time against the union of tags on the feature, the matched scenario, and—when dealing with Scenario Outline—the Examples: block that produced each generated row. Expressions support the operators and, or, and not (case-insensitive) and respect the precedence not > and > or; parentheses may be used to override this ordering. Tags include the leading @. When the filter is combined with index or name, the macro emits a compile error if the selected scenario does not satisfy the expression.

Harness adapter and attribute policy

The harness and attributes parameters accept Rust type paths pointing to types that implement HarnessAdapter and AttributePolicy from the rstest-bdd-harness crate. These enable third-party framework integrations (per Architectural Decision Record (ADR-005)) without coupling the core crates to any specific runtime.

Use them in this order of preference:

  • Omit both when the default synchronous StdHarness path is sufficient.
  • For first-party integrations, prefer harness = ... on its own and let the macro infer the matching default attribute policy.
  • Add attributes = ... only when overriding the inferred default or selecting an attribute policy without harness delegation.
  • Treat runtime = "tokio-current-thread" as legacy compatibility syntax for scenarios!, not as the canonical configuration surface.

When harness is specified, the generated test body delegates scenario execution through the harness adapter. The macro wraps the runtime portion of the test (context setup, step executor loop, skip handler, and user block) in a ScenarioRunner closure, constructs a ScenarioRunRequest with metadata (feature path, scenario name, line number, and tags), and calls HarnessAdapter::run(). The harness is instantiated via <HarnessType as Default>::default(), so the harness type must implement both HarnessAdapter and Default. The built-in StdHarness satisfies both requirements and simply executes the closure directly. StdHarness behavioural tests cover three guarantees: metadata remains visible on the request passed to the harness boundary, the runner closure executes once, and runner panics propagate unchanged. If a harness cannot initialize its runtime or framework environment, it returns HarnessResult::Err(HarnessError::...); the generated test then panics with the concrete error detail because harness initialization is a fatal infrastructure error in this context.

When harness is omitted, the generated code executes steps inline without any delegation, preserving backward compatibility.

When attributes is specified, the macro resolves policy-backed test attributes. Currently this resolution is path-based:

  • rstest_bdd_harness_tokio::TokioAttributePolicy, or an imported TokioAttributePolicy, emits Tokio current-thread test attributes for async scenario signatures,
  • rstest_bdd_harness_gpui::GpuiAttributePolicy, or an imported GpuiAttributePolicy, emits #[gpui::test] for synchronous and async scenario signatures, and
  • default and unknown policy paths emit #[rstest::rstest] only.

Imported single-segment recognition only applies to unaliased first-party names. If the adapter crates are renamed or aliased, or the policy types are re-exported under different identifiers, use the canonical crate-root path ( rstest_bdd_harness_tokio::TokioAttributePolicy or rstest_bdd_harness_gpui::GpuiAttributePolicy) or add a direct rstest-bdd-harness dependency to get the same attribute recognition.

First-party adapter fallback diagnostics

The macros emit one diagnostic when an unresolved Tokio or GPUI adapter path preserves the canonical first-party crate identifier immediately before the adapter type, as in alias::rstest_bdd_harness_tokio::TokioHarness. This can happen when a first-party adapter is re-exported through another module. Nightly emits a native warning. Stable emits a deprecated-item warning by default, and #![deny(deprecated)] escalates that diagnostic to an error. Unrelated third-party paths such as custom::TokioHarness do not trigger the diagnostic, even when the Tokio adapter crate is also a dependency.

The macro resolves each supplied adapter path once and emits at most one diagnostic for that resolution. See the [developers' guide][developers-guide-adapter-fallback] for the nightly and stable implementation details.

Prefer the canonical paths:

  • rstest_bdd_harness_tokio::{TokioHarness, TokioAttributePolicy}
  • rstest_bdd_harness_gpui::{GpuiHarness, GpuiAttributePolicy}

If the adapter path must remain re-exported, add rstest-bdd-harness as a direct development dependency under its canonical crate resolution name, rstest_bdd_harness. Do not rename the dependency key: the generated fallback code resolves HarnessAdapter and AttributePolicy through that exact crate name rather than through the first-party adapter crate.

When attributes is omitted, known first-party harnesses infer matching default attribute policies:

  • rstest_bdd_harness::StdHarness resolves to rstest-only emission,
  • rstest_bdd_harness_tokio::TokioHarness, or an imported TokioHarness, resolves to the Tokio current-thread policy, and
  • rstest_bdd_harness_gpui::GpuiHarness, or an imported GpuiHarness, resolves to the GPUI test policy.

If the harness path is not one of those known first-party paths, the macro falls back to the existing RuntimeMode compatibility behaviour. This keeps the first-party Tokio compatibility path working while leaving third-party policy resolution explicit about its current limitation: arbitrary user-defined AttributePolicy::test_attributes() implementations are not evaluated during macro expansion.

In simple terms, attribute selection works like this:

  1. If attributes = ... is present, that explicit policy wins.
  2. Otherwise, if harness = ... names StdHarness, TokioHarness, or GpuiHarness, the macro uses that harness's first-party default policy.
  3. Otherwise, scenarios! keeps honouring the legacy runtime = "tokio-current-thread" compatibility alias when no explicit attributes or harness were selected.
  4. Otherwise, if the #[scenario] function is async fn, the macro emits #[rstest::rstest] and #[tokio::test(flavor = "current_thread")] (unless the user already supplies a #[tokio::test] attribute, in which case deduplication suppresses the second copy).
  5. If none of those apply, the macro emits the normal synchronous #[rstest::rstest] attribute set.

Third-party harness adapter cookbook

A separate harness crate is appropriate when a framework needs setup, teardown, runtime ownership, or typed context that should not become a dependency of the core rstest-bdd crates. A Bevy integration, for example, should live in an opt-in crate such as rstest-bdd-harness-bevy and expose two public types:

  • a harness adapter, such as BevyHarness, implementing HarnessAdapter; and
  • an optional attribute policy, such as BevyAttributePolicy, implementing AttributePolicy.

The adapter crate depends on the shared harness contract and on the framework it integrates. In a real crate, the framework version should match the application, and that configuration should stay out of rstest-bdd itself:

[package]
name = "rstest-bdd-harness-bevy"
version = "0.6.0"
edition = "2024"

[dependencies]
bevy = { version = "0.14", default-features = false }
rstest-bdd-harness = "0.6.0"

[dev-dependencies]
rstest-bdd = "0.6.0"
rstest-bdd-macros = "0.6.0"

The harness type must implement Default because generated scenario tests instantiate it with <Harness as Default>::default(). It must also set the associated Context type and run the scenario by passing a concrete context value into request.run(context). For a Bevy-like harness, that context would normally be a bevy::ecs::world::World or a small wrapper around it:

use bevy::ecs::world::World;
use bevy::prelude::Resource;
use rstest_bdd_harness::{HarnessAdapter, HarnessResult, ScenarioRunRequest};

#[derive(Default)]
pub struct BevyHarness;

impl HarnessAdapter for BevyHarness {
    type Context = World;

    fn run<T>(
        &self,
        request: ScenarioRunRequest<'_, Self::Context, T>,
    ) -> HarnessResult<T> {
        let mut world = World::new();

        // Register test-only resources, plugins, or schedules before steps run.
        world.insert_resource(TestClock::default());

        let result = request.run(world);

        // Run framework-specific cleanup here if the framework requires it.
        Ok(result)
    }
}

#[derive(Default, Resource)]
struct TestClock {
    ticks: u64,
}

Unit-context harnesses, where type Context = (), should instead call request.run_without_context(). Non-unit context harnesses must call request.run(context), otherwise step functions cannot borrow the context via the reserved fixture key. If a step requests the reserved fixture but the scenario did not run through a context-providing harness, the missing-fixture error reports rstest_bdd_harness_context, the requested context type, the fixtures that were inserted, and a hint to use a harness-backed scenario.

Step definitions request the harness context with the #[harness_context] marker. The parameter name is adapter-specific; the underlying fixture key is fixed:

use bevy::ecs::world::World;
use rstest_bdd_macros::{given, then, when};

#[given("the world starts empty")]
fn world_starts_empty(#[harness_context] world: &World) {
    assert_eq!(world.entities().len(), 0);
}

#[when("the app spawns one entity")]
fn app_spawns_one_entity(#[harness_context] world: &mut World) {
    world.spawn_empty();
}

#[then("the world contains one entity")]
fn world_contains_one_entity(#[harness_context] world: &World) {
    assert_eq!(world.entities().len(), 1);
}

Three spellings are accepted and generate identical code:

Spelling When to use
#[harness_context] ctx: &T The default. Reads as intent, hides the key.
ctx: &T named rstest_bdd_harness_context Rarely; the name is unwieldy.
#[from(rstest_bdd_harness_context)] ctx: &T Existing v0.6.0 code; still supported.

Table 2: Ways to request the harness context in a step definition.

The #[harness_context] marker takes no arguments, may appear once per parameter, and cannot be combined with #[from], #[datatable], or #[step_args].

The adapter may also publish an attribute policy. This keeps the framework's native test attribute beside the harness crate instead of teaching the macro crate about the framework dependency:

use rstest_bdd_harness::{AttributePolicy, TestAttribute};

pub struct BevyAttributePolicy;

const BEVY_TEST_ATTRIBUTES: [TestAttribute; 1] = [
    TestAttribute::new("rstest::rstest"),
];

impl AttributePolicy for BevyAttributePolicy {
    fn test_attributes() -> &'static [TestAttribute] {
        &BEVY_TEST_ATTRIBUTES
    }
}

At present, custom policy paths are checked for the AttributePolicy trait, but their test_attributes() methods are not evaluated by the procedural macros. Unknown third-party policy paths therefore fall back to #[rstest::rstest] during code generation. The attributes = ... configuration remains useful for the trait check and as a forward-compatible configuration point. If a framework requires a native test attribute, that attribute should be added explicitly until the policy resolver supports the adapter crate.

The harness and policy can then be used from scenario tests like any other macro configuration:

use rstest_bdd_macros::scenario;
use rstest_bdd_harness_bevy::{BevyAttributePolicy, BevyHarness};

#[scenario(
    path = "tests/features/bevy_world.feature",
    harness = BevyHarness,
    attributes = BevyAttributePolicy,
)]
fn bevy_world_scenario() {}

A minimal feature for the example above can stay framework-neutral:

Feature: Bevy world

  Scenario: Spawn an entity
    Given the world starts empty
    When the app spawns one entity
    Then the world contains one entity

The compile-checked mirror for this cookbook lives at crates/rstest-bdd/tests/fixtures_macros/scenario_third_party_harness_cookbook.rs. Maintainers should keep that fixture and the snippets above aligned when the third-party harness contract changes.

Harness adapters must faithfully propagate the runner's return value inside Ok(...). For fallible scenarios that return Result<(), E>, swallowing errors would cause tests to pass silently. Use Err(HarnessError::...) only for harness-level infrastructure failures, not for scenario assertion failures. When migrating pre-ADR-007 harness code, update type Context, the ScenarioRunner constructor signature, and the request execution helper together: unit-context harnesses (StdHarness, TokioHarness, and similar adapters using Context = ()) should call run_without_context(), while non-unit context harnesses should use request.run(context).

For complete first-party examples, compare the examples/tokio-reminders and examples/gpui-counter crates. They are not third-party templates, but they show how an adapter crate keeps runtime or framework dependencies outside the core runtime and macro crates.

Using the Tokio harness

The rstest-bdd-harness-tokio crate provides a ready-made Tokio integration. It is available as a dev-dependency:

[dev-dependencies]
rstest-bdd-harness-tokio = "0.6.0"

A direct rstest-bdd-harness dependency is not required when using TokioHarness through this first-party adapter crate. Add the base harness crate directly only when implementing a custom harness or importing base API types such as HarnessAdapter or ScenarioRunRequest.

Generated asynchronous step wrappers obtain their runtime through the hidden rstest_bdd::__rstest_bdd_tokio bridge. Consequently, a downstream crate does not need a direct tokio dependency solely for generated wrappers; add one when the crate's own code names Tokio APIs or attributes. The bridge is an internal generated-code interface and downstream code must not call it directly.

TokioHarness can then be used directly in scenarios. For this first-party adapter, the macro infers TokioAttributePolicy from the canonical harness path when attributes = ... is omitted:

# use rstest_bdd_macros::scenario;
#[scenario(
    path = "tests/features/reminders.feature",
    name = "Scheduling a reminder queues it for later delivery",
    harness = rstest_bdd_harness_tokio::TokioHarness,
)]
fn queues_a_scheduled_reminder() {}

Keep explicit attributes = ... only for overrides, attributes-only tests, or unrecognized paths where the default cannot be inferred.

Using the GPUI harness

The rstest-bdd-harness-gpui crate provides Graphical Processing User Interface (GPUI) integration for harness delegation and test attributes. Add it as a dev-dependency:

[dev-dependencies]
rstest-bdd-harness-gpui = "0.6.0"

A direct rstest-bdd-harness dependency is not required when using GpuiHarness through this first-party adapter crate. Add the base harness crate directly only when implementing a custom harness or importing base API types such as HarnessAdapter or ScenarioRunRequest.

GpuiHarness can then be used directly in scenarios. For this first-party adapter, the macro infers GpuiAttributePolicy from the canonical harness path when attributes = ... is omitted:

Fallible #[scenario] bodies are consumed behind a unit-returning GPUI test boundary. An Err fails the test by panicking with the fixed message scenario returned an error; the error value is not formatted and need not implement Debug. Std and Tokio scenarios retain their fallible signatures and propagate results through Rust's native Termination support.

# use rstest_bdd_macros::scenario;
# fn increment_counter() -> Result<(), std::io::Error> { Ok(()) }
#[scenario(
    path = "tests/features/counter.feature",
    name = "Increment a counter and observe GPUI context",
    harness = rstest_bdd_harness_gpui::GpuiHarness,
)]
fn increment_and_observe_gpui_context() -> Result<(), std::io::Error> {
    increment_counter()?;
    Ok(())
}

GPUI panic diagnostics carry scenario context

When a step running under GpuiHarness panics, the harness prepends the feature path, scenario name, and feature-file line number to the panic message before re-raising it through panic::resume_unwind. The same fields are emitted as a tracing::error! record (harness_type, feature_path, scenario_name, scenario_line) and as a matching stderr line, so test runners that do not collect tracing events still surface the scenario name on failure. This makes a failing GPUI scenario identifiable from the cargo test or cargo nextest output without cross-referencing libtest function names against feature files. For a concrete regression example, see crates/rstest-bdd-harness-gpui/tests/scenario_name_in_logs.rs.

Stateful GPUI scenarios with durable handles

Note: this workaround is superseded from v0.7.0.

The thread-local scenario-state pattern below worked around the StepContext::borrow_mut contract in rstest-bdd 0.6.x (selected by [ADR-007][adr-007]), which prevented one step from borrowing two mutable fixtures (such as mutable harness context plus mutable world state) at once. From v0.7.0, fixture borrowing is guard-based ([ADR-012][adr-012]): steps can take &mut parameters for distinct fixtures — including #[from(rstest_bdd_harness_context)] cx: &mut gpui::TestAppContext alongside world: &mut UiWorld — and the framework builds a fresh StepContext for each scenario and drops its owned, scenario-scoped cells at the scenario boundary, so no thread-local reset discipline is required. Fixtures supplied by rstest keep their own scopes, so an #[once] fixture is still shared as rstest defines it. New code should declare ordinary &mut fixture parameters instead of the pattern below. The playbook is retained only for projects still on 0.6.x; migrate by moving thread-local state into an rstest fixture and deleting the reset calls.

Which gpui does this playbook target?

The snippets below and the regression suite at crates/rstest-bdd-harness-gpui/tests/stateful_window.rs are written against the vendored gpui under vendor/gpui. Downstream crates using the published gpui 0.2.2 on crates.io encounter a different test API. The four shapes that differ are:

Operation Vendored gpui Published gpui 0.2.2
add_window_view one-argument closure; returns (Entity<T>, VisualTestContext) two-argument closure; returns (Entity<V>, &mut VisualTestContext)
obtain window handle visual_cx.window_handle() (inherent method on VisualTestContext) vcx.window_handle() (same call, but window_handle is a VisualContext trait method, so add use gpui::VisualContext;)
from_window Option<VisualTestContext> VisualTestContext
read/update Option/Result wrappers direct R

Table: Vendored-to-published gpui 0.2.2 API shape differences.

Adapt call sites when consuming the published crate. The harness itself (which only deals in TestAppContext) is not affected by this divergence.

When to reach for the stateful playbook

Stateful GPUI scenarios are those whose steps share durable resources, such as a typed view entity and the window that owns it, and need mutable access to the harness-provided gpui::TestAppContext as well. Scenarios that only read the harness context, or that share state through ordinary rstest fixtures without also borrowing TestAppContext mutably, should keep using plain fixtures and skip this playbook. The pattern below is needed precisely when a single step must borrow both &mut TestAppContext and shared mutable scenario state, which the v0.6 StepContext API cannot express in one borrow.

Durable handles versus visual context

gpui::TestAppContext::add_window_view creates a test window and returns the typed view entity plus a visual context. The vendored fork returns (Entity<T>, VisualTestContext) by value; published gpui 0.2.2 returns (Entity<T>, &mut VisualTestContext). Entity<T> is the typed, durable handle to the stored view, and VisualTestContext::window_handle() returns the AnyWindowHandle that identifies the window itself. Both handles remain valid across steps. VisualTestContext, by contrast, is tied to the TestAppContext it was created against and must not be stored across steps: a later step receives a fresh &mut TestAppContext from the harness. Stateful steps therefore store Entity<T> and AnyWindowHandle only, and rebuild a fresh VisualTestContext inside each step that needs visual interaction.

Reset protocol

Thread-local scenario state outlives any single scenario, so each scenario must observe a two-sided reset protocol to prevent handle leakage across serial scenarios on the same test thread:

  • Reset before assignment. The first #[given] that opens a window resets the thread-local state before storing fresh handles. This makes a reused thread observe a clean slate even if the previous scenario aborted in a way that bypassed unwinding.
  • Reset after teardown. A Drop-based fixture guard runs at scenario exit. Threading the guard through a #[fixture] ensures the reset runs on every unwind path: success, assertion failure, and panic alike.

Neither half is redundant. The constructor-side reset on the scenario_state_cleanup fixture covers the case where a previous scenario short-circuited (for example through skip!) or panicked in a way that suppressed Drop. The Drop reset covers the symmetric case where the current scenario panics and the next scenario's fixture is not yet constructed when teardown happens. Deleting either call is a correctness regression: the regression suite at crates/rstest-bdd-harness-gpui/tests/stateful_window.rs asserts stale_window_count == 0 after the constructor-side reset to make the ordering observable, and the second scenario in tests/features/stateful_window.feature ("Opening a second GPUI window starts from reset state") fails if the Drop reset is removed.

Each #[scenario] that participates in this protocol must carry #[serial] from the serial_test crate. GPUI scenarios share a process-wide TestAppContext slot, and the thread-local reset protocol assumes sequential execution; running stateful GPUI scenarios in parallel breaks both invariants.

See test-runner parallelism and scenario state for the full #[serial], cargo-nextest, #[file_serial], and nextest test-group matrix.

Worked example

The scenario-state and vendored call-site snippets below mirror the regression suite at crates/rstest-bdd-harness-gpui/tests/stateful_window.rs exactly. Treat that file as the executable reference: if a snippet here drifts from the suite, the suite wins and this section should be updated to match. Projects consuming published gpui 0.2.2 should use the parallel published call-site variants instead.

The first snippet declares the scenario-state container, the two reset helpers, the Drop-based cleanup type, and the two #[scenario] functions that bind to the feature file. Each scenario carries #[serial] and pulls in the scenario_state_cleanup fixture so its constructor-side reset runs before any step:

# use rstest::fixture;
# use rstest_bdd_macros::scenario;
# use serial_test::serial;
# use std::cell::RefCell;
#[derive(Clone, Debug, Default)]
struct CounterView {
    value: usize,
}

#[derive(Clone, Debug, Default)]
struct ScenarioState {
    entity: Option<gpui::Entity<CounterView>>,
    window: Option<gpui::AnyWindowHandle>,
    opened_window_count: usize,
}

thread_local! {
    static SCENARIO_STATE: RefCell<ScenarioState> =
        RefCell::new(ScenarioState::default());
}

fn reset_state_before_assignment() {
    // Reset before assigning the next scenario's handles so a reused
    // serial test thread cannot observe handles left by a failed or
    // skipped scenario.
    reset_state_after_scenario();
}

fn reset_state_after_scenario() {
    SCENARIO_STATE.with(|state| *state.borrow_mut() = ScenarioState::default());
}

#[derive(Clone, Debug)]
struct ScenarioStateCleanup;

impl Drop for ScenarioStateCleanup {
    fn drop(&mut self) {
        reset_state_after_scenario();
    }
}

#[fixture]
fn scenario_state_cleanup() -> ScenarioStateCleanup {
    reset_state_before_assignment();
    ScenarioStateCleanup
}

#[scenario(
    path = "tests/features/stateful_window.feature",
    name = "Reconstruct visual context from durable handles",
    harness = rstest_bdd_harness_gpui::GpuiHarness,
)]
#[serial]
fn scenario_reconstructs_visual_context_from_durable_handles(
    #[from(scenario_state_cleanup)] _cleanup: ScenarioStateCleanup,
) {
}

#[scenario(
    path = "tests/features/stateful_window.feature",
    name = "Opening a second GPUI window starts from reset state",
    harness = rstest_bdd_harness_gpui::GpuiHarness,
)]
#[serial]
fn scenario_opening_second_window_starts_from_reset_state(
    #[from(scenario_state_cleanup)] _cleanup: ScenarioStateCleanup,
) {
}

The second snippet shows the #[given] that opens a fresh window. It defensively re-runs the reset before storing handles and observes the stale_window_count invariant that the regression suite encodes:

# use rstest_bdd_macros::given;
# fn reset_state_before_assignment() {}
# fn with_state<R>(_: impl FnOnce(&mut ()) -> R) -> R { unimplemented!() }
#[given("a fresh GPUI window is opened")]
fn fresh_gpui_window_is_opened(
    #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext,
) {
    let stale_window_count = with_state(|state| usize::from(state.window.is_some()));
    reset_state_before_assignment();

    let (entity, visual_context) =
        context.add_window_view(|_context| CounterView::default());
    let window = visual_context.window_handle();

    with_state(|state| {
        state.entity = Some(entity);
        state.window = Some(window);
        state.opened_window_count = context.windows().len();
    });

    assert_eq!(
        stale_window_count, 0,
        "reset-before-assignment should remove stale scenario state"
    );
}

The third snippet shows a #[when] and a #[then] step that rebuild VisualTestContext from the stored window handle plus the harness-provided TestAppContext. VisualTestContext::from_window returns Option<VisualTestContext> because the window handle and the borrowed context must come from the same TestAppContext; the let … else { panic!(…) } shape is appropriate here because a None value means an invariant of the playbook has been violated, not a legitimate test outcome. This form also passes the repository's pedantic lint profile:

# use rstest_bdd_macros::{then, when};
# fn current_handles() -> (gpui::Entity<()>, gpui::AnyWindowHandle) { unimplemented!() }
#[when("the view is updated through a reconstructed visual context")]
fn view_is_updated_through_reconstructed_visual_context(
    #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext,
) {
    let (entity, window) = current_handles();
    let Some(mut visual_context) = gpui::VisualTestContext::from_window(window, context) else {
        panic!("stored window handle should reconstruct visual context");
    };
    assert_eq!(
        visual_context.update_entity(entity, |view| view.value += 1),
        Ok(())
    );
}

#[then("the durable handles still identify the updated view")]
fn durable_handles_identify_the_updated_view(
    #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext,
) {
    let (entity, window) = current_handles();
    let Some(visual_context) = gpui::VisualTestContext::from_window(window, context) else {
        panic!("stored window handle should reconstruct visual context");
    };

    assert_eq!(
        visual_context.read_entity(entity, |view| view.value),
        Some(1)
    );
}

The error shape is consistent across all three snippets: surfaces of infrastructure invariants (handle reconstruction, fixture-stored handles) panic, and step-level domain assertions use assert_eq!. Steps that need to distinguish a legitimate failure mode from a programming invariant should return StepResult<()> and propagate the failure with ?; mixing panic-on-invariant-violation let … else { panic!(…) } branches and StepResult within the same playbook reads ambiguously, so pick one shape per scenario.

Published gpui 0.2.2 stateful step variants

Projects that depend on gpui = "0.2.2" from crates.io can reuse the scenario-state container, reset helpers, cleanup fixture, and scenario functions above unchanged. Replace the vendored #[given], #[when], and #[then] call sites with the variants below. The published methods used here come from the AppContext and VisualContext traits, so both traits must be in scope. Anonymous imports keep their names from colliding with application types.

The published #[given] passes the window and view context to the view constructor. add_window_view binds visual_context as &mut VisualTestContext; copy the window handle before storing only the durable handles:

use gpui::{AppContext as _, VisualContext as _};
use rstest_bdd_macros::given;

#[given("a fresh GPUI window is opened")]
fn fresh_gpui_window_is_opened(
    #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext,
) {
    let stale_window_count = with_state(|state| usize::from(state.window.is_some()));
    reset_state_before_assignment();

    let (entity, visual_context) =
        context.add_window_view(|_window, view_cx| CounterView::new(view_cx));
    let window = visual_context.window_handle();

    with_state(|state| {
        state.entity = Some(entity);
        state.window = Some(window);
        state.opened_window_count = context.windows().len();
    });

    assert_eq!(
        stale_window_count, 0,
        "reset-before-assignment should remove stale scenario state"
    );
}

The published reconstruction constructor returns VisualTestContext by value. Its entity methods take the entity by reference: update_entity's callback receives &mut Context<T> as its second argument, whereas read_entity's callback receives &App. Their AppContext::Result<R> alias is R, so assertions compare the returned values directly rather than unwrapping Option or Result. Unlike the vendored helper, the published helper clones the stored entity because published Entity<T> is Clone, not Copy:

use gpui::AppContext as _;
use rstest_bdd_macros::{then, when};

fn current_handles() -> (gpui::Entity<CounterView>, gpui::AnyWindowHandle) {
    with_state(|state| {
        let Some(entity) = state.entity.clone() else {
            panic!("scenario should have stored an entity handle");
        };
        let Some(window) = state.window else {
            panic!("scenario should have stored a window handle");
        };
        (entity, window)
    })
}

#[when("the view is updated through a reconstructed visual context")]
fn view_is_updated_through_reconstructed_visual_context(
    #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext,
) {
    let (entity, window) = current_handles();
    let mut visual_context = gpui::VisualTestContext::from_window(window, context);
    visual_context.update_entity(&entity, |view, _view_cx| view.value += 1);
}

#[then("the durable handles still identify the updated view")]
fn durable_handles_identify_the_updated_view(
    #[from(rstest_bdd_harness_context)] context: &mut gpui::TestAppContext,
) {
    let (entity, window) = current_handles();
    let visual_context = gpui::VisualTestContext::from_window(window, context);

    assert_eq!(
        visual_context.read_entity(&entity, |view, _app| view.value),
        1
    );
}

These blocks use rust,ignore because this repository's doctest dependency is the vendored gpui shim. They reproduce the published gpui 0.2.2 signatures; replace CounterView::new with the downstream view's constructor when copying the pattern.

Fixture key versus parameter name

Steps request the GPUI context through the reserved fixture key rstest_bdd_harness_context. The key is part of the public contract: every first-party adapter (Tokio, GPUI, and any future harness) injects its typed context through the same key, so step authors can rely on it across adapters. The parameter name used on the receiving side (context in the snippets above and in the regression suite) is adapter-agnostic and chosen by the step author for readability. The #[from(rstest_bdd_harness_context)] attribute is what binds the key, so do not let parameter naming convince a reader the binding name is part of the contract.

Where to read more

  • [rstest-bdd design][rstest-bdd-design] §2.7.6.1 and §2.7.6.2 explain why the workaround took this shape under the 0.6.x borrow contract.
  • [ADR-012][adr-012] records the v0.7.0 guard-based borrowing redesign that supersedes the thread-local approach.
  • crates/rstest-bdd-harness-gpui/tests/stateful_window.rs is the executable reference suite. Read it to confirm that the snippet here still matches the regression coverage.
  • crates/rstest-bdd-harness-gpui/tests/features/stateful_window.feature shows the Gherkin shape the suite binds to.
  • The v0.6.0 migration guide's [Migrate a stateful GPUI test][gpui-migration] subsection (inside "Adopt GPUI harness configuration") walks readers through moving an existing scenario to the playbook.
  • Design-document §2.7.6.6 documents the feature-file rebuild-invalidation mechanism (.feature-only edits recompile the scenario binary).
  • Design-document §2.7.6.7 documents the full cargo test versus nextest matrix for #[serial] and thread-local state.

Pedantic lint profile

The snippets above are the lint-clean form used by the regression suite. The full Whitaker suite run by make lint includes no_unwrap_or_else_panic, so unwrap_or_else(|| panic!(…)) is rejected even when it encodes an infrastructure invariant. The workspace also denies clippy::expect_used and clippy::unwrap_used, so .expect(...) and .unwrap() are not acceptable replacements.

Clippy's allow-expect-in-tests escape hatch does not rescue these snippets. It is not enabled in this workspace, and step functions and their helpers are ordinary functions rather than #[test] functions, so Clippy does not treat them as test code even when they live in a tests/ binary. Downstream projects that do enable the option inherit the same constraint for their step definitions.

Use let … else { panic!(…) } with a fresh binding name:

# fn current_handles() -> Option<gpui::AnyWindowHandle> { unimplemented!() }
let Some(window) = current_handles() else {
    panic!("scenario should have stored a window handle");
};

Under Whitaker's shadow_reuse lint, avoid re-using the same name for a trimmed or borrowed binding. For example, prefer a fresh guard name such as world_guard over shadowing world. The full Whitaker suite is adopted as the current make lint gate; [ADR-013][adr-013] records the current compatibility contract.

Bulk-migration cookbook

When migrating a large test suite, factor the whole durable-handle step library — the #[given]/#[when]/#[then] steps together with the state scaffolding — into one shared module per consuming crate, rather than copying it into every test file. Under the v0.7 model, keep that shared module focused on step definitions and fixture constructors. The framework builds a fresh StepContext for each scenario and drops its owned, scenario-scoped cells at the scenario boundary, while ADR-012's guard-based borrowing permits concurrent guards for distinct mutable fixtures. Framework-owned scenario state therefore needs neither ScenarioStore<T> nor a cleanup-guard fixture macro. Fixtures supplied by rstest keep their own scopes, so an #[once] fixture is still shared as rstest defines it.

Why one shared module works

Steps register globally at binary link time through the inventory crate, so every step compiled into a test binary is discoverable by every scenario in that binary, whatever module defined it. Placing the steps in a module that each binding file compiles in (through a #[path] include) therefore makes one library serve many scenarios across many feature files. Each integration-test file is its own binary with its own registry, so the same step text in two binaries never collides.

Keep the shared module in the tests/common/ subdirectory form (tests/common/<name>.rs), not tests/<name>.rs: Cargo compiles files placed directly under tests/ as their own test binaries, but treats files in a subdirectory as ordinary modules. Mark every item a binding file references as pub, because a #[path]-included module is a real module boundary — the single-file worked example above never needed this.

Layout

tests/
  common/
    ledger_steps.rs   # shared: state scaffolding + fixture + given/when/then
  features/
    first.feature
    second.feature
  first_bdd.rs        # #[path] include + one #[scenario]; no steps here
  second_bdd.rs

Binding a scenario

Each binding file includes the shared library and binds a #[scenario] with no steps of its own. Bind the shared fixture with a module-qualified #[from(...)] path so its provenance stays visible at the binding site:

#[path = "common/ledger_steps.rs"]
mod ledger_steps;

use rstest_bdd_macros::scenario;

#[scenario(
    path = "tests/features/first.feature",
    name = "First scenario reuses the shared step library",
)]
fn first(
    #[from(ledger_steps::ledger_state)] _state: ledger_steps::LedgerState,
) {
}

The shared module keeps durable scenario state in a regular rstest fixture backed by Slot<T> — the clean shape recommended when steps do not also need mutable harness context — and defines the #[given]/#[when]/#[then] steps once. This keeps the library in one place, avoids copy-paste drift, and makes a consuming crate's growth cost one binding file per scenario rather than a fresh copy of the helpers.

The executable reference for this shape lives at crates/rstest-bdd/tests/bulk_migration_cookbook_a.rs and _b.rs, both sharing crates/rstest-bdd/tests/common/bulk_migration_steps.rs; the compile-checked mirror is the trybuild fixture crates/rstest-bdd/tests/fixtures_macros/scenario_bulk_migration_cookbook.rs. If a snippet here drifts from those, the suite wins.

Applying it to stateful GPUI scenarios

Under v0.7, the shared GPUI module defines a regular scenario fixture whose world value stores durable Entity<T> and AnyWindowHandle handles, together with the crate's #[given]/#[when]/#[then] steps. A step that needs both sources of mutable state requests distinct fixture parameters, for example #[from(rstest_bdd_harness_context)] cx: &mut gpui::TestAppContext and world: &mut UiWorld. Guard-based borrowing permits both parameters to coexist, and the framework creates and drops the fixture at the scenario boundary on success, failure, and skip. No thread-local state, reset helper, or cleanup fixture is required.

The durable-handle details remain the same: store Entity<T> and AnyWindowHandle, then rebuild VisualTestContext inside the step that needs it. The executable GPUI reference is crates/rstest-bdd-harness-gpui/tests/stateful_window.rs.

Historical v0.6.x context: the earlier worked example used thread-local state, explicit reset helpers, and a Drop cleanup guard because its StepContext could not hold both mutable borrows. That compatibility pattern is retained above for projects still on v0.6.x, but it is not the v0.7 model.

Those GPUI snippets are written against the vendored gpui. Adopters on the published gpui 0.2.2 — the audience migrating real suites — should adapt them using the vendored-to-published mapping table above (under "Durable handles versus visual context").

Editing only a .feature file triggers a rebuild of the scenario binary (see Feature file rebuild invalidation), so no touch or cargo clean workaround is needed after changing feature text.

Test-runner parallelism and scenario state

Historical v0.6.x scenarios that use thread-local state and the reset/cleanup protocol need different serialization tools depending on the test runner. The #[serial] attribute from the serial_test crate is required for cargo test compatibility, even though cargo-nextest runs each test in a separate operating-system process. The v0.7 framework-managed lifecycle for ordinary scenario fixtures does not require serialization solely for cleanup. A suite with a separate process-wide GPUI constraint still needs runner-appropriate cross-process exclusion independently of the fixture lifecycle.

Runner #[serial] effect Cross-process exclusivity
cargo test In-process mutex; required Not provided by #[serial]
nextest (process-per-test) Redundant-but-harmless #[file_serial] or test-group

Table: #[serial] behaviour by test runner.

Under cargo test, all tests in one integration-test binary run in a single process using multiple threads. #[serial] serializes tests that carry the same key, or all unkeyed #[serial] tests together, with an in-process mutex. Historical v0.6.x GPUI scenarios therefore keep #[serial] so the reset protocol is respected when the suite runs without nextest.

Under cargo-nextest (cargo nextest run, which this repository's make test target uses), each test is run in its own process. The #[serial] mutex is not contended across process boundaries, so the annotation is redundant-but-harmless for nextest runs. Keep it for the v0.6.x compatibility pattern under cargo test; do not remove it just because nextest already isolates per-process thread-local state. The design rationale is recorded in [design-document §2.7.6.7][design-runner-parallelism], and the maintainer convention is summarized in [the developer guide][developer-serial-nextest].

When separate test processes or separate test binaries must not overlap, use a cross-process mechanism instead of #[serial]. cargo-nextest [test-groups][nextest-test-groups] define logical mutexes across the whole nextest run. This example makes any test whose name contains stateful_gpui:: run one at a time:

[test-groups]
stateful-gpui = { max-threads = 1 }

[[profile.default.overrides]]
filter = 'test(stateful_gpui::)'
test-group = 'stateful-gpui'

Test-groups are available in cargo-nextest 0.9.48 and later. They apply only to nextest scheduling; they do not change cargo test behaviour.

The serial_test crate also provides #[file_serial], which uses a file lock instead of the in-process mutex. Enable the crate's file_locks feature before using it:

[dev-dependencies]
serial_test = { version = "2", features = ["file_locks"] }

The file_locks feature is available in the 2.x serial_test line used by this example.

# use serial_test::file_serial;
#[test]
#[file_serial(stateful_gpui)]
fn opens_shared_gpui_resource() {
    // Exercise the shared resource.
}

#[file_serial] defaults to a lock file under the operating system's temporary directory, and supports explicit path and key arguments. It does not mutually exclude tests annotated with #[serial]; the two attributes use different lock mechanisms. Use one cross-process convention consistently for tests that must exclude one another.

Skipping scenarios

Steps or hooks may call rstest_bdd::skip! to stop executing the remaining steps. The macro records a Skipped outcome and short-circuits the scenario so the generated test returns before evaluating the annotated function body. Invoke skip!() with no arguments to record a skipped outcome without a message. Pass an optional string to describe the reason, and use the standard format! syntax to interpolate values when needed. Set the RSTEST_BDD_FAIL_ON_SKIPPED environment variable to 1, or call rstest_bdd::config::set_fail_on_skipped(true), to escalate skipped scenarios into test failures unless the feature or scenario carries an @allow_skipped tag. (Example-level tags are not yet evaluated.)

The macro captures the current execution scope internally, so helper functions may freely call skip! as long as they eventually run within a step or hook. When code outside that context—for example, a unit test or module initialization routine—invokes the macro, it panics with the message rstest_bdd::skip! may only be used inside a step or hook generated by rstest-bdd. Each scope tracks the thread that entered it; issuing a skip from another thread panics with rstest_bdd::skip! may only run on the thread executing the step .... Keep skip requests on the thread that executes steps, so the runner can intercept the panic payload.

# use rstest_bdd as bdd;
# use rstest_bdd_macros::{given, scenario};

#[given("a dependent service is unavailable")]
fn service_unavailable() {
    bdd::skip!("service still provisioning");
}

#[given("a maintenance window is scheduled")]
fn maintenance_window() {
    let component = "billing";
    bdd::skip!(
        "{component} maintenance in progress",
        component = component,
    );
}

#[scenario(path = "features/unhappy_path.feature")]
fn resilience_test() {
    panic!("scenario body is skipped");
}

When fail_on_skipped is enabled, apply @allow_skipped to the feature or the scenario to document that the skip is expected:

@allow_skipped
Scenario: Behaviour pending external contract
  Given a dependent service is unavailable

To record the step definitions bypassed by a skip, build a BypassedScenario descriptor and pass the bypassed steps to record_bypassed_steps:

use rstest_bdd::{record_bypassed_steps, BypassedScenario, StepKeyword};

let tags = vec![String::from("@allow_skipped")];
let scenario = BypassedScenario::new(
    "features/unhappy_path.feature",
    "Behaviour pending external contract",
    12,
)
.with_tags(&tags)
.with_reason(Some("service still provisioning"));
record_bypassed_steps(
    scenario,
    [(StepKeyword::Given, "a dependent service is unavailable")],
);

with_tags and with_reason are optional. Recording is a no-op when the diagnostics feature is disabled.

Asserting skipped outcomes

Tests that exercise skip-heavy flows no longer need to match on enums to verify that a step or scenario stopped executing. Use rstest_bdd::assert_step_skipped! to unwrap a StepExecution::Skipped outcome, optionally constraining its message, and rstest_bdd::assert_scenario_skipped! to inspect [ScenarioStatus][scenario-status] records. Both macros accept message_absent = true to assert that no message was provided and substring matching to confirm that a message contains the expected reason.

use rstest_bdd::{assert_scenario_skipped, assert_step_skipped, StepExecution};
use rstest_bdd::reporting::{ScenarioMetadata, ScenarioRecord, ScenarioStatus, SkippedScenario};

let outcome = StepExecution::skipped(Some("maintenance pending".into()));
let message = assert_step_skipped!(outcome, message = "maintenance");
assert_eq!(message, Some("maintenance pending".into()));

let metadata = ScenarioMetadata::new(
    "features/unhappy.feature",
    "pending work",
    12,
    vec!["@allow_skipped".into()],
);
let record = ScenarioRecord::from_metadata(
    metadata,
    ScenarioStatus::Skipped(SkippedScenario::new(None, true, false)),
);
let details = assert_scenario_skipped!(
    record.status(),
    message_absent = true,
    allow_skipped = true,
    forced_failure = false,
);
assert!(details.allow_skipped());