Binding tests to scenarios

Version 0.2.0 Updated Dec 08, 2025

The #[scenario] macro is the entry point that ties a Rust test function to a scenario defined in a .feature file. It accepts four arguments:

Argument Purpose Status
path: &str Relative path to the feature file (required). Implemented: resolved and parsed at compile time.
index: usize Optional zero-based scenario index (defaults to 0). Implemented: selects the scenario by position.
name: &str Optional scenario title; resolves when unique. Implemented: errors when missing and directs duplicates to index.
tags: &str Optional tag-expression filter applied at expansion. Implemented: filters scenarios and outline example rows; errors when nothing matches.

Tag filters run at macro-expansion time against the union of tags on the feature, the matched scenario, and—when dealing with Scenario Outline—the Examples: block that produced each generated row. Expressions support the operators and, or, and not (case-insensitive) and respect the precedence not > and > or; parentheses may be used to override this ordering. Tags include the leading @. When the filter is combined with index or name, the macro emits a compile error if the selected scenario does not satisfy the expression.

# use rstest_bdd_macros::scenario;
#[scenario(path = "features/search.feature", tags = "@fast and not @wip")]
fn smoke_test() {}

If the feature file cannot be found or contains invalid Gherkin, the macro emits a compile-time error with the offending path.

When name is provided, the macro matches the title case-sensitively. A missing title triggers a diagnostic listing the available headings in the feature. Duplicate titles yield a diagnostic that highlights the conflict and lists the matching indexes and line numbers, enabling selection by the index argument when required.

During macro expansion, the feature file is read and parsed. The macro generates a new test function annotated with #[rstest::rstest] that performs the following steps:

  1. Build a StepContext and insert the test’s fixture arguments into it.

  2. For each step in the scenario (according to the Given‑When‑Then sequence), look up a matching step function by (keyword, pattern) in the registry. A missing step causes the macro to emit a compile‑time error such as No matching step definition found for: Given an undefined step, allowing detection of incomplete implementations before tests run. Multiple matching definitions likewise produce an error.

  3. Invoke the registered step function with the StepContext so that fixtures are available inside the step.

  4. After executing all steps, run the original test body. This block can include extra assertions or cleanup logic beyond the behaviour described in the feature.

Because the generated code uses #[rstest::rstest], it integrates seamlessly with rstest features such as parameterized tests and asynchronous fixtures. Tests are still discovered and executed by the standard Rust test runner, so one may filter or run them in parallel as usual.

Skipping scenarios

Steps or hooks may call rstest_bdd::skip! to stop executing the remaining steps. The macro records a Skipped outcome and short-circuits the scenario so the generated test returns before evaluating the annotated function body. Invoke skip!() with no arguments to record a skipped outcome without a message. Pass an optional string to describe the reason, and use the standard format! syntax to interpolate values when needed. Set the RSTEST_BDD_FAIL_ON_SKIPPED environment variable to 1, or call rstest_bdd::config::set_fail_on_skipped(true), to escalate skipped scenarios into test failures unless the feature or scenario carries an @allow_skipped tag. (Example-level tags are not yet evaluated.)

The macro captures the current execution scope internally, so helper functions may freely call skip! as long as they eventually run within a step or hook. When code outside that context—for example, a unit test or module initialization routine—invokes the macro, it panics with the message rstest_bdd::skip! may only be used inside a step or hook generated by rstest-bdd. Each scope tracks the thread that entered it; issuing a skip from another thread panics with rstest_bdd::skip! may only run on the thread executing the step .... Keep skip requests on the thread that executes steps, so the runner can intercept the panic payload.

# use rstest_bdd as bdd;
# use rstest_bdd_macros::{given, scenario};

#[given("a dependent service is unavailable")]
fn service_unavailable() {
    bdd::skip!("service still provisioning");
}

#[given("a maintenance window is scheduled")]
fn maintenance_window() {
    let component = "billing";
    bdd::skip!(
        "{component} maintenance in progress",
        component = component,
    );
}

#[scenario(path = "features/unhappy_path.feature")]
fn resilience_test() {
    panic!("scenario body is skipped");
}

When fail_on_skipped is enabled, apply @allow_skipped to the feature or the scenario to document that the skip is expected:

@allow_skipped
Scenario: Behaviour pending external contract
  Given a dependent service is unavailable

Asserting skipped outcomes

Tests that exercise skip-heavy flows no longer need to match on enums to verify that a step or scenario stopped executing. Use rstest_bdd::assert_step_skipped! to unwrap a StepExecution::Skipped outcome, optionally constraining its message, and rstest_bdd::assert_scenario_skipped! to inspect ScenarioStatus records. Both macros accept message_absent = true to assert that no message was provided and substring matching to confirm that a message contains the expected reason.

use rstest_bdd::{assert_scenario_skipped, assert_step_skipped, StepExecution};
use rstest_bdd::reporting::{ScenarioRecord, ScenarioStatus, SkippedScenario};

let outcome = StepExecution::skipped(Some("maintenance pending".into()));
let message = assert_step_skipped!(outcome, message = "maintenance");
assert_eq!(message, Some("maintenance pending".into()));

let record = ScenarioRecord::new(
    "features/unhappy.feature",
    "pending work",
    ScenarioStatus::Skipped(SkippedScenario::new(None, true, false)),
);
let details = assert_scenario_skipped!(
    record.status(),
    message_absent = true,
    allow_skipped = true,
    forced_failure = false,
);
assert!(details.allow_skipped());