Developers implement the behaviour described in a feature by writing step
definition functions in Rust. Each step definition is an ordinary function
annotated with one of the attribute macros #[given], #[when] or #[then].
The annotation takes a single string literal that must match the text of the
corresponding step in the feature file. Placeholders in the form {name} or
{name:Type} are supported. The framework extracts matching substrings and
converts them using FromStr; type hints constrain the match using specialized
regular expressions. If the step text does not supply a capture for a declared
argument, the wrapper panics with
pattern '<pattern>' missing capture for argument '<name>', making the
mismatch explicit.
For cucumber-rs migration compatibility notes, see [Migration and async patterns][migration-async-patterns].
The procedural macro implementation expands the annotated function into two
parts: the original function and a wrapper function that registers the step in
a global registry. The wrapper captures the step keyword, pattern string and
associated fixtures and uses the inventory crate to publish them for later
lookup.
Fixtures and implicit injection
rstest‑bdd builds on rstest’s fixture system rather than using a monolithic
“world” object. Fixtures are defined using #[rstest::fixture] in the usual
way. When a step function parameter does not correspond to a placeholder in the
step pattern, the macros treat it as a fixture and inject the value
automatically. The optional #[from(name)] attribute remains available when a
parameter name must differ from the fixture. Importing a symbol of the same
name is not required; do not alias a function or item just to satisfy the
compiler. Only the key stored in StepContext must match.
Implicit fixture keys derived from scenario and step parameter names normalize
at most one leading underscore. In practice, world and _world both resolve
to the implicit fixture key world, while __world resolves to _world.
Explicit #[from(...)] names remain exact and bypass this normalization, so
#[from(_world)] still requests the literal _world fixture key.
Supported #[from] forms
For the harness context specifically, prefer the #[harness_context] marker
described under "Harness adapter and attribute policy" — it names intent rather
than the internal fixture key. #[from(rstest_bdd_harness_context)] remains
supported.
#[from] selects which fixture key a parameter binds to. It accepts either no
arguments or exactly one identifier in parentheses:
#[given("the catalogue is loaded")]
fn catalogue_loaded(
// Named: binds the `db_pool` fixture to a differently named parameter.
#[from(db_pool)] pool: DbPool,
// Bare: binds the fixture named after the parameter itself, `cache`.
#[from] cache: Cache,
) {}
#[from(name)] requests the fixture key name exactly. Use it when the
parameter cannot carry the fixture's own name — because that name is already
taken at the call site, or because a shorter local name reads better.
Bare #[from] requests the parameter's own normalized fixture name, which is
what an unannotated parameter already does. It changes no lookup, so its
purpose is documentary: it marks a parameter as a deliberate fixture injection
at the binding site, rather than leaving a reader to infer that from the
absence of a matching placeholder in the step pattern.
Two spellings are rejected when the macro expands.
The name-value form is invalid, because from takes either no arguments or one
parenthesized identifier and never = value:
error: invalid step function signature: #[from] expects an identifier or no arguments
Rewrite #[from = "db_pool"] as #[from(db_pool)].
More than one #[from] on the same parameter is also invalid, including a
mixture of the bare and named forms, because the second would silently displace
the first:
error: invalid step function signature: duplicate `#[from]` attribute
= help: Remove one of the duplicate `#[from]` attributes.
Keep the single attribute naming the intended fixture and delete the others.
Internally, the step macros record the fixture names and generate wrapper code
that, at runtime, retrieves references from a StepContext. This context is a
key–value map of fixture names to type‑erased references. When a scenario runs,
the generated test inserts its arguments (the rstest fixtures) into the
StepContext before invoking each registered step.
If a required fixture is not present, the runtime diagnostic lists the missing
fixture name, the Rust type requested by the step, and the fixture keys already
inserted into the scenario context. For harness context specifically, a missing
rstest_bdd_harness_context fixture also includes a hint to select a
harness-backed scenario so the reserved context fixture is inserted.
Mutable world fixtures
Each scenario owns its fixtures, so value fixtures are stored with exclusive
access. Step parameters declared as &mut FixtureType receive mutable
references, making “world” structs ergonomic without sprinkling Cell or
RefCell wrappers through the fields. Immutable references continue to work
exactly as before; mutability is an opt‑in convenience.
When to use `&mut Fixture`
- Prefer
&mutwhen the world has straightforward owned fields and the steps mutate them directly. - Prefer
Slot<T>(fromrstest_bdd::Slot) when state is optional, when a need to reset between steps, or when a step may override values conditionally without holding a mutable borrow. - Combine both: keep the primary world mutable and store optional or late‑bound values in slots to avoid borrow checker churn inside complex scenarios.
Simple mutable world
use rstest::fixture;
use rstest_bdd_macros::{given, scenario, then, when};
#[derive(Default)]
struct CounterWorld {
count: usize,
}
#[fixture]
fn world() -> CounterWorld {
CounterWorld::default()
}
#[given("the world starts at {value}")]
fn seed(world: &mut CounterWorld, value: usize) {
world.count = value;
}
#[when("the world increments")]
fn increment(world: &mut CounterWorld) {
world.count += 1;
}
#[then("the world equals {expected}")]
fn check(world: &CounterWorld, expected: usize) {
assert_eq!(world.count, expected);
}
#[scenario(path = "tests/features/mutable_world.feature", name = "Steps mutate shared state")]
fn mutable_world(world: CounterWorld) {
assert_eq!(world.count, 3);
}
Recording overrides directly with insert_value
The automatic injection above is implemented on top of a lower-level API that
advanced tests may call directly. StepContext::insert_value records a
step-return override and returns an InsertOutcome, which distinguishes the
three results a bare Option previously conflated:
InsertOutcome::Inserted(previous)— a fixture uniquely matched the value's type and the override was recorded.previousisSome(_)when it displaced an earlier override for that fixture, orNoneotherwise.InsertOutcome::NoMatch— no fixture uses the value's type, so the value was dropped.InsertOutcome::AmbiguousIgnored— more than one fixture matches the value's type, so the value was dropped to avoid an ambiguous override (a warning is emitted).
The warning is emitted as a tracing::warn! event under the target
rstest_bdd::context. A tracing subscriber receives it when it accepts
WARN; otherwise, if no tracing dispatcher was ever installed and a log
logger (such as env_logger) accepts WARN for that target, the warning is
delivered through tracing's log bridge. If neither route has a listener, the
message is mirrored to stderr so it remains visible in a test binary with no
logging configured.
InsertOutcome is #[must_use], so the compiler warns when a direct caller
implicitly discards it and might miss a dropped step return; an explicit
let _ = ctx.insert_value(…) still suppresses that warning, as the generated
scenario runner does. Callers that need only the displaced previous override —
the value the old Option<Box<dyn Any>>-returning API yielded — can call
InsertOutcome::into_previous(), which returns the previous override for the
Inserted case and None for NoMatch and AmbiguousIgnored.
InsertOutcome::is_inserted() reports whether the override was recorded
without consuming the outcome.
Slot‑based state (unchanged and still useful)
use rstest::fixture;
use rstest_bdd::{ScenarioState as _, Slot};
use rstest_bdd_macros::{given, scenario, then, when, ScenarioState};
#[derive(Default, ScenarioState)]
struct CartState {
total: Slot<i32>,
}
#[fixture]
fn cart_state() -> CartState { CartState::default() }
#[when("I record {value:i32}")]
fn record(cart_state: &CartState, value: i32) { cart_state.total.set(value); }
#[then("the recorded value is {expected:i32}")]
fn check(cart_state: &CartState, expected: i32) {
assert_eq!(cart_state.total.get(), Some(expected));
}
#[scenario(path = "tests/features/scenario_state.feature", name = "Recording a single value")]
fn keeps_value(_cart_state: CartState) {}
Mixed approach
#[derive(Default, ScenarioState)]
struct ReportWorld {
total: usize,
last_input: Slot<String>,
}
#[when("the total increases by {value:usize}")]
fn bump(world: &mut ReportWorld, value: usize) {
world.total += value;
world.last_input.set(format!("+{value}"));
}
#[then("the last input was recorded")]
fn last_input(world: &ReportWorld) {
assert_eq!(world.last_input.get(), Some("+1".to_string()));
}
Best practices
- Keep world structs small and focused; extract helper methods when mutation requires validation or cross‑field consistency.
- Prefer immutable references in assertions to make read‑only intent obvious.
- Reserve
Slot<T>for optional or resettable state; avoid mixing it in when a plain field would do. - Add comments where mutation order matters between steps.
Troubleshooting
- A rustc internal compiler error (ICE) affected some nightly compilers when
expanding macro‑driven scenarios with
&mutfixtures. Seecrates/rstest-bdd/tests/mutable_world_macro.rsfor a guarded example andcrates/rstest-bdd/tests/mutable_fixture.rsfor the context‑level regression test used until the upstream fix lands. Tracking details live indocs/known-issues.md#rustc-internal-compiler-error-ice-with-mutable-world-macro. - For advanced cases—custom fixture injection or manual borrowing—use
StepContext::insert_ownedandStepContext::borrow_mutdirectly; the examples above cover most scenarios.
Step return values
#[when] steps may return a value. The scenario runner scans the available
fixtures for ones whose TypeId matches the returned value. When exactly one
fixture uses that type, the override is recorded under that fixture’s name and
subsequent steps receive the most recent value (last write wins). Ambiguous or
missing matches leave fixtures untouched, keeping scenarios predictable while
still allowing a functional style without mutable fixtures.
Steps may also return Result<T, E>. An Err aborts the scenario, while an
Ok value is injected as above.
Unhinted non-unit steps are classified by their concrete return type. This
includes local aliases: an alias of Result<T, E> propagates Err and injects
the T from Ok(T), while a genuine value alias remains a payload. A result
containing wrapper such as Box<Result<T, E>>, Option<Result<T, E>>, or
&Result<T, E> remains a value; the framework does not unwrap user types.
Use #[when(result)] only to force a fallible interpretation for an ambiguous
form. It is rejected for primitive return types. When used with an alias, the
compiler verifies that the alias is ultimately Result-like. Use
#[when(value)] only when a real Result is intentionally the payload,
because it suppresses that result's Err.
Use #[when("...", value)] (or #[when(value)] when using the inferred
pattern) to force treating the return value as a payload even when it is
Result<..>.
Returning () or Ok(()) produces no stored value, so fixtures of () are
not overwritten.
use rstest::fixture;
use rstest_bdd_macros::{given, when, then, scenario};
#[fixture]
fn number() -> i32 { 1 }
#[when("it is incremented")]
fn increment(number: i32) -> i32 { number + 1 }
#[then("the result is 2")]
fn check(number: i32) { assert_eq!(number, 2); }
#[scenario(path = "tests/features/step_return.feature")]
fn returns_value(number: i32) { let _ = number; }
Struct-based step arguments
When a step pattern contains several placeholders, the corresponding function
signatures quickly become unwieldy. Derive the StepArgs trait for a struct
whose fields mirror the placeholders and annotate the relevant parameter with
#[step_args] to request struct-based parsing. The attribute tells the macro
to consume every placeholder for that parameter, while fixtures and other
special arguments (datatable/docstring) continue to work as usual.
Fields must implement FromStr, and the derive macro enforces the bounds
automatically. Placeholders and struct fields must appear in the same order.
During expansion the macro inserts a compile-time check to ensure the field
count matches the pattern, producing a trait-bound error if the struct does not
implement StepArgs.
use rstest::fixture;
use rstest_bdd_macros::{given, scenario, then, when, StepArgs};
#[derive(StepArgs)]
struct CartInput {
quantity: u32,
item: String,
price: f32,
}
#[derive(Default)]
struct Cart {
quantity: u32,
item: String,
price: f32,
}
impl Cart {
fn set(&mut self, details: &CartInput) {
self.quantity = details.quantity;
self.item = details.item.clone();
self.price = details.price;
}
}
#[fixture]
fn cart() -> Cart { Cart::default() }
#[given("a cart containing {quantity:u32} {item} at ${price:f32}")]
fn seed_cart(#[step_args] details: CartInput, cart: &mut Cart) {
cart.set(&details);
}
#[then("the cart summary shows {quantity:u32} {item} at ${price:f32}")]
fn cart_summary(#[step_args] expected: CartInput, cart: &Cart) {
assert_eq!(cart.quantity, expected.quantity);
assert_eq!(cart.item, expected.item);
assert!((cart.price - expected.price).abs() < f32::EPSILON);
}
#[step_args] must not be combined with #[from] and cannot target reference
types because the macro needs to own the struct to parse it. Attempting to use
multiple #[step_args] parameters in a single step yields a compile-time
error. The compiler also surfaces an error when the step pattern does not
declare any placeholders.
Example:
use rstest::fixture;
use rstest_bdd_macros::{given, when, then, scenario};
// A fixture used by multiple steps.
#[fixture]
fn basket() -> Basket {
Basket::new()
}
#[given("an empty basket")]
fn empty_basket(basket: &mut Basket) {
basket.clear();
}
#[when("the user adds a pumpkin")]
fn add_pumpkin(basket: &mut Basket) {
basket.add(Item::Pumpkin, 1);
}
#[then("the basket contains one pumpkin")]
fn assert_pumpkins(basket: &Basket) {
assert_eq!(basket.count(Item::Pumpkin), 1);
}
#[scenario(path = "tests/features/shopping.feature")]
fn test_add_to_basket(#[with(basket)] _: Basket) {
// optional assertions after the steps
}
Scenario state slots
Complex scenarios often need to capture intermediate results or share mutable
state between multiple steps. Historically, this required wrapping every field
in RefCell<Option<T>>, introducing noise and risking forgotten resets. The
rstest-bdd runtime now provides Slot<T>, a thin wrapper that exposes a
focused API for populating, reading, and clearing per-scenario values. Each
slot starts empty and supports helpers such as set, replace,
get_or_insert_with, take, and predicates is_empty/is_filled.
Define a state struct whose fields are Slot<T> and derive [ScenarioState].
The derive macro clears every slot by implementing ScenarioState::reset and
it automatically adds a [Default] implementation that leaves all slots empty.
Do not also derive or implement Default: Rust will report a
duplicate-implementation error because the macro already provides it. For
custom initialization, plan to use the future #[scenario_state(no_default)]
flag (or equivalent) to opt out of the generated Default and supply bespoke
logic.
use rstest::fixture;
use rstest_bdd::{ScenarioState, Slot};
use rstest_bdd_macros::{given, scenario, then, when, ScenarioState};
#[derive(ScenarioState)]
struct CliState {
output: Slot<String>,
exit_code: Slot<i32>,
}
#[fixture]
fn cli_state() -> CliState {
CliState::default()
}
#[when("I run the CLI with {word}")]
fn run_command(cli_state: &CliState, argument: String) {
let result = format!("ran {argument}");
cli_state.output.set(result);
cli_state.exit_code.set(0);
}
#[then("the CLI succeeded")]
fn cli_succeeded(cli_state: &CliState) {
assert_eq!(cli_state.exit_code.get(), Some(0));
}
#[then("I can reset the state")]
fn reset_state(cli_state: &CliState) {
cli_state.reset();
assert!(cli_state.output.is_empty());
}
#[scenario(path = "tests/features/cli.feature")]
fn cli_behaviour(_cli_state: CliState) {}
Slots are independent, so scenarios can freely mix eager set calls with lazy
get_or_insert_with initializers. Because slots live inside a regular fixture,
the state benefits from rstest’s usual lifecycle: a fresh struct is produced
for each scenario invocation, yet it remains trivial to clear and reuse the
contents when chaining multiple behaviours inside a single test body.
Implicit fixture injection
Implicit fixtures such as basket must already be in scope in the test module;
#[from(name)] only renames a fixture and does not create one.
In this example, the step texts in the annotations must match the feature file
verbatim. The #[scenario] macro binds the test function to the first scenario
in the specified feature file and runs all registered steps before executing
the body of test_add_to_basket.
Inferred step patterns
Step macros may omit the pattern string or provide a string literal containing only whitespace. In either case, the macro derives a pattern from the function name by replacing underscores with spaces.
use rstest_bdd_macros::given;
#[given]
fn user_logs_in() {
// pattern "user logs in" is inferred
}
This reduces duplication between function names and patterns. A literal ""
registers an empty pattern instead of inferring one.
Note Inference preserves spaces derived from underscores:
- Leading and trailing underscores become leading or trailing spaces.
- Consecutive underscores become multiple spaces.
- Letter case is preserved.