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
StdHarnesspath 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 forscenarios!, 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 importedTokioAttributePolicy, emits Tokio current-thread test attributes for async scenario signatures,rstest_bdd_harness_gpui::GpuiAttributePolicy, or an importedGpuiAttributePolicy, 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::StdHarnessresolves to rstest-only emission,rstest_bdd_harness_tokio::TokioHarness, or an importedTokioHarness, resolves to the Tokio current-thread policy, andrstest_bdd_harness_gpui::GpuiHarness, or an importedGpuiHarness, 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:
- If
attributes = ...is present, that explicit policy wins. - Otherwise, if
harness = ...namesStdHarness,TokioHarness, orGpuiHarness, the macro uses that harness's first-party default policy. - Otherwise,
scenarios!keeps honouring the legacyruntime = "tokio-current-thread"compatibility alias when no explicit attributes or harness were selected. - Otherwise, if the
#[scenario]function isasync 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). - 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, implementingHarnessAdapter; and - an optional attribute policy, such as
BevyAttributePolicy, implementingAttributePolicy.
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_mutcontract inrstest-bdd0.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&mutparameters for distinct fixtures — including#[from(rstest_bdd_harness_context)] cx: &mut gpui::TestAppContextalongsideworld: &mut UiWorld— and the framework builds a freshStepContextfor each scenario and drops its owned, scenario-scoped cells at the scenario boundary, so no thread-local reset discipline is required. Fixtures supplied byrstestkeep their own scopes, so an#[once]fixture is still shared asrstestdefines it. New code should declare ordinary&mutfixture 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 anrstestfixture 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.rsare written against the vendored gpui undervendor/gpui. Downstream crates using the publishedgpui 0.2.2on crates.io encounter a different test API. The four shapes that differ are:
Operation Vendored gpui Published gpui 0.2.2add_window_viewone-argument closure; returns (Entity<T>, VisualTestContext)two-argument closure; returns (Entity<V>, &mut VisualTestContext)obtain window handle visual_cx.window_handle()(inherent method onVisualTestContext)vcx.window_handle()(same call, butwindow_handleis aVisualContexttrait method, so adduse gpui::VisualContext;)from_windowOption<VisualTestContext>VisualTestContextread/update Option/Resultwrappersdirect RTable: 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.rsis 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.featureshows 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());