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 initialized 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
Deprecation notice: The legacy runtime = "tokio-current-thread" argument
is deprecated as of roadmap item 9.2.4. It now resolves to the explicit
harness = rstest_bdd_harness_tokio::TokioHarness form and emits a
compile-time warning. For new code, use the explicit harness form described in
Using the Tokio harness.
The deprecated runtime = "tokio-current-thread" syntax generates synchronous
(not async fn) test functions executed via TokioHarness. Async step
definitions (async fn) are not supported. For async scenario tests, use
explicit async fn scenario tests with #[tokio::test] annotation, or use the
explicit harness = rstest_bdd_harness_tokio::TokioHarness form with
synchronous steps that drive async work via tokio::spawn_local.
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][migration-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();
}
// Note: runtime = "tokio-current-thread" is deprecated; prefer explicit harness form
scenarios!(
"tests/features/streams.feature",
harness = rstest_bdd_harness_tokio::TokioHarness,
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][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.