Async scenario execution

Version 0.5.0 Updated Feb 09, 2026

Scenarios can run asynchronously under Tokio's current-thread runtime. This enables test code to .await async operations while preserving the RefCell-backed fixture model for mutable borrows across await points.

Using `#[scenario]` with async

Declare the test function as async fn and add #[tokio::test(flavor = "current_thread")] before the #[scenario] attribute. The macro detects the async signature and generates an async step executor:

use rstest_bdd_macros::{given, scenario, then, when};
use rstest::fixture;

#[derive(Default)]
struct Counter {
    value: i32,
}

#[fixture]
fn counter() -> Counter {
    Counter::default()
}

#[given("a counter initialised to 0")]
fn init(counter: &mut Counter) {
    counter.value = 0;
}

#[when("the counter is incremented")]
fn increment(counter: &mut Counter) {
    counter.value += 1;
}

#[then(expr = "the counter value is {n}")]
fn check_value(counter: &Counter, n: i32) {
    assert_eq!(counter.value, n);
}

#[scenario(path = "tests/features/counter.feature", name = "Increment counter")]
#[tokio::test(flavor = "current_thread")]
async fn increment_counter(counter: Counter) {}

The macro generates #[rstest::rstest] without duplicating #[tokio::test(flavor = "current_thread")] when the user already supplies it.

Using `scenarios!` with async

The scenarios! macro accepts a runtime argument to generate async tests for all discovered scenarios:

use rstest_bdd_macros::{given, then, when, scenarios};

#[given("a precondition")] fn precondition() {}
#[when("an action occurs")] fn action() {}
#[then("events are recorded")] fn events() {}

scenarios!("tests/features/auto", runtime = "tokio-current-thread");

When runtime = "tokio-current-thread" is specified:

  • Generated test functions are async fn.
  • Each test is annotated with #[tokio::test(flavor = "current_thread")].
  • Steps execute sequentially within the single-threaded Tokio runtime.

Manual async wrapper pattern

Most step code does not need to name the fixture lifetime directly. When an explicit async wrapper is written around a synchronous StepFn, prefer rstest_bdd::async_step::sync_to_async and keep the context argument as StepContext<'_>:

This pattern is useful when a codebase already has synchronous handlers that must run in an async-only path, such as custom registries, reusable step helper layers, or adapter crates that expose async execution.

The resulting wrapper can be wired into normal step execution points, including run_async in custom Step registrations or explicit step-macro functions that delegate to shared handlers. Existing StepFn implementations remain reusable without rewriting their business logic.

use rstest_bdd::async_step::sync_to_async;
use rstest_bdd::{StepContext, StepError, StepExecution, StepFuture};

fn sync_step(
    _ctx: &mut StepContext<'_>,
    _text: &str,
    _docstring: Option<&str>,
    _table: Option<&[&[&str]]>,
) -> Result<StepExecution, StepError> {
    Ok(StepExecution::from_value(None))
}

fn async_wrapper<'ctx>(
    ctx: &'ctx mut StepContext<'_>,
    text: &'ctx str,
    docstring: Option<&'ctx str>,
    table: Option<&'ctx [&'ctx [&'ctx str]]>,
) -> StepFuture<'ctx> {
    sync_to_async(sync_step)(ctx, text, docstring, table)
}

For shorter signatures, use the exported aliases: StepCtx<'ctx, '_>, StepTextRef<'ctx>, StepDoc<'ctx>, and StepTable<'ctx>.

use rstest_bdd::async_step::sync_to_async;
use rstest_bdd::{
    StepContext, StepCtx, StepDoc, StepError, StepExecution, StepFuture,
    StepTable, StepTextRef,
};

fn sync_step(
    _ctx: &mut StepContext<'_>,
    _text: &str,
    _docstring: Option<&str>,
    _table: Option<&[&[&str]]>,
) -> Result<StepExecution, StepError> {
    Ok(StepExecution::from_value(None))
}

fn async_wrapper_with_aliases<'ctx>(
    ctx: StepCtx<'ctx, '_>,
    text: StepTextRef<'ctx>,
    docstring: StepDoc<'ctx>,
    table: StepTable<'ctx>,
) -> StepFuture<'ctx> {
    sync_to_async(sync_step)(ctx, text, docstring, table)
}

Current limitations

  • Tokio current-thread mode only: Multi-threaded Tokio mode would require Send futures, which conflicts with the RefCell-backed fixture storage. See ADR-001 for the full design rationale.
  • Nested runtime safeguards: Async-only steps running in synchronous scenarios use a per-step runtime fallback, which refuses to run when a Tokio runtime is already active on the current thread.
  • No async_std runtime: Only Tokio is supported at present.