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.
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.
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.
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);
}
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) { let _ = cart_state; }
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-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. Type aliases to Result behave identically.
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) {
let _ = cli_state;
}
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.