Step definitions

Version 0.6.0 Updated Sep 14, 2026

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 &mut when the world has straightforward owned fields and the steps mutate them directly.
  • Prefer Slot<T> (from rstest_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. previous is Some(_) when it displaced an earlier override for that fixture, or None otherwise.
  • 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 &mut fixtures. See crates/rstest-bdd/tests/mutable_world_macro.rs for a guarded example and crates/rstest-bdd/tests/mutable_fixture.rs for the context‑level regression test used until the upstream fix lands. Tracking details live in docs/known-issues.md#rustc-internal-compiler-error-ice-with-mutable-world-macro.
  • For advanced cases—custom fixture injection or manual borrowing—use StepContext::insert_owned and StepContext::borrow_mut directly; 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.