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.
Recommended patterns for async work in steps
Async scenarios run on Tokio's current-thread runtime. Step functions may be
async fn and are awaited sequentially, keeping fixture borrows valid across
.await points. Use one of the following patterns to keep async work safe and
predictable. This section summarizes the canonical guidance in
Migration and async patterns.
- Prefer async fixtures: If a step needs async data, move the async call into a fixture and inject the resolved value into the step. The scenario runtime awaits the fixture once, then passes the result to the synchronous step.
use rstest::fixture;
use rstest_bdd_macros::{given, scenarios, when};
struct StreamEnd;
impl StreamEnd {
async fn connect() -> Self {
StreamEnd
}
fn trigger(&self) {}
}
#[fixture]
async fn stream_end() -> StreamEnd {
StreamEnd::connect().await
}
#[when("the stream ends")]
fn end_stream(stream_end: &StreamEnd) {
stream_end.trigger();
}
scenarios!(
"tests/features/streams.feature",
runtime = "tokio-current-thread",
fixtures = [stream_end]
);
- Use a per-step runtime only in synchronous scenarios: When an async-only
step runs under a synchronous scenario,
rstest-bddfalls back to a per-step Tokio runtime and blocks on the step. Avoid building additional runtimes inside an async scenario because nested runtimes can fail. For async scenarios, prefer async steps, async fixtures, or the async test body.
use rstest_bdd_macros::when;
#[when("the stream ends")]
fn end_stream() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build step runtime");
runtime.block_on(async {
// async work here
});
}
The sequence below shows how synchronous scenarios execute async-only steps through the Tokio fallback runtime.
Figure: Per-step Tokio fallback flow when a synchronous scenario reaches an async-only step.
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
Sendfutures, which conflicts with theRefCell-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_stdruntime: Only Tokio is supported at present.