Data tables and Docstrings

Version 0.2.0 Updated Dec 08, 2025

Steps may supply structured or free-form data via a trailing argument. A data table is received by including a parameter annotated with #[datatable] or named datatable. The declared type must implement TryFrom<Vec<Vec<String>>>, so the wrapper can convert the parsed cells. Existing code can continue to accept a raw Vec<Vec<String>>, while the rstest_bdd::datatable module offers strongly typed helpers.

datatable::Rows<T> wraps a vector of parsed rows and derives Deref<Target = [T]>, From<Vec<T>>, and IntoIterator, enabling direct consumption of the table. The datatable::DataTableRow trait describes how each row should be interpreted for a given type. When T::REQUIRES_HEADER is true, the first table row is treated as a header and exposed via the HeaderSpec helpers.

use rstest_bdd::datatable::{
    self, DataTableError, DataTableRow, RowSpec, Rows,
};
# use rstest_bdd_macros::given;

#[derive(Debug, PartialEq, Eq)]
struct UserRow {
    name: String,
    email: String,
    active: bool,
}

impl DataTableRow for UserRow {
    const REQUIRES_HEADER: bool = true;

    fn parse_row(mut row: RowSpec<'_>) -> Result<Self, DataTableError> {
        let name = row.take_column("name")?;
        let email = row.take_column("email")?;
        let active = row.parse_column_with(
            "active",
            datatable::truthy_bool,
        )?;
        Ok(Self { name, email, active })
    }
}

#[given("the following users exist:")]
fn users_exist(#[datatable] rows: Rows<UserRow>) {
    for row in rows {
        assert!(row.active || row.name == "Bob");
    }
}

Projects that prefer to work with raw rows can declare the argument as Vec<Vec<String>> and handle parsing manually. Both forms can co-exist within the same project, allowing incremental adoption of typed tables.

Performance and caching

Data tables are now converted once per distinct table literal and cached for reuse. The generated wrappers key the cache by the table content, so repeated executions of the same step with the same table reuse the stored Vec<Vec<String>> without re-parsing cell text. The cache is scoped to the step wrapper, preventing different steps or tables from sharing entries.

For zero-allocation access on subsequent executions, prefer the datatable::CachedTable argument type. It borrows the cached rows via an Arc, avoiding fresh string allocations when the step runs multiple times. Existing Vec<Vec<String>> signatures remain supported; they clone the cached rows when binding the argument, which still avoids re-parsing but retains the ownership semantics of the older API. The cache lives for the lifetime of the test process, so large tables remain available to later scenarios without additional conversion overhead.

A derive macro removes the boilerplate when mapping headers to fields. Annotate the struct with #[derive(DataTableRow)] and customize behaviour via field attributes:

  • #[datatable(rename_all = "kebab-case")] applies a casing rule to unnamed fields.
  • #[datatable(column = "Email address")] targets a specific header.
  • #[datatable(optional)] treats missing cells as None.
  • #[datatable(default)] or #[datatable(default = path::to_fn)] supplies a fallback when the column is absent.
  • #[datatable(trim)] trims whitespace before parsing.
  • #[datatable(truthy)] parses tolerant booleans using datatable::truthy_bool.
  • #[datatable(parse_with = path::to_fn)] calls a custom parser that returns a Result<T, E>.
use rstest_bdd::datatable::{self, DataTableError, Rows};
use rstest_bdd_macros::DataTableRow;

fn default_region() -> String { String::from("EMEA") }

fn parse_age(value: &str) -> Result<u8, std::num::ParseIntError> {
    value.trim().parse()
}

#[derive(Debug, PartialEq, Eq, DataTableRow)]
#[datatable(rename_all = "kebab-case")]
struct UserRow {
    given_name: String,
    #[datatable(column = "email address")]
    email: String,
    #[datatable(truthy)]
    active: bool,
    #[datatable(optional)]
    nickname: Option<String>,
    #[datatable(default = default_region)]
    region: String,
    #[datatable(parse_with = parse_age)]
    age: u8,
}

fn load_users(rows: Rows<UserRow>) -> Result<Vec<UserRow>, DataTableError> {
    Ok(rows.into_vec())
}

#[derive(DataTableRow)] enforces these attributes at compile time. Optional fields must use Option<T>; applying #[datatable(optional)] to any other type triggers an error. Optional fields also cannot declare defaults because those behaviours are contradictory. Likewise, #[datatable(truthy)] and #[datatable(parse_with = …)] are mutually exclusive, ensuring the macro can select a single parsing strategy.

Tuple structs that wrap collections can derive DataTable to implement TryFrom<Vec<Vec<String>>>. The macro accepts optional hooks to post-process rows before exposing them to the step function:

  • #[datatable(map = path::to_fn)] transforms the parsed rows.
  • #[datatable(try_map = path::to_fn)] performs fallible conversion, returning a DataTableError on failure.
use rstest_bdd::datatable::{DataTableError, Rows};
use rstest_bdd_macros::{DataTable, DataTableRow};

#[derive(Debug, PartialEq, Eq, DataTableRow)]
struct UserRow {
    name: String,
    #[datatable(truthy)]
    active: bool,
}

#[derive(Debug, PartialEq, Eq, DataTable)]
#[datatable(row = UserRow, try_map = collect_active)] // Converts rows into a bespoke type.
struct ActiveUsers(Vec<String>);

fn collect_active(rows: Rows<UserRow>) -> Result<Vec<String>, DataTableError> {
    Ok(rows
        .into_iter()
        .filter(|row| row.active)
        .map(|row| row.name)
        .collect())
}

Debugging data table errors

Rows<T> propagates [DataTableError] variants unchanged, making it easy to surface context when something goes wrong. Matching on the error value enables inspection of the row and column that triggered the failure:

# use rstest_bdd::datatable::{DataTableError, Rows};
# use rstest_bdd_macros::DataTableRow;
#
# #[derive(Debug, PartialEq, Eq, DataTableRow)]
# struct UserRow {
#     name: String,
#     #[datatable(truthy)]
#     active: bool,
# }

let table = vec![
    vec!["name".into(), "active".into()],
    vec!["Alice".into()],
];

let Err(DataTableError::MissingColumn { row_number, column }) =
    Rows::<UserRow>::try_from(table)
else {
    panic!("expected the table to be missing the 'active' column");
};

assert_eq!(row_number, 2);
assert_eq!(column, "active");

Custom parsers bubble their source error through DataTableError::CellParse. Inspecting the formatted message shows the precise location of the failure, including the human-readable column label:

# use rstest_bdd::datatable::{DataTableError, Rows};
# use rstest_bdd_macros::DataTableRow;
#
# #[derive(Debug, PartialEq, Eq, DataTableRow)]
# struct UserRow {
#     name: String,
#     #[datatable(truthy)]
#     active: bool,
# }

let result = Rows::<UserRow>::try_from(vec![
    vec!["name".into(), "active".into()],
    vec!["Alice".into(), "maybe".into()],
]);

let err = match result {
    Err(err) => err,
    Ok(_) => panic!("expected the 'maybe' flag to trigger a parse error"),
};

let DataTableError::CellParse {
    row_number,
    column_index,
    ..
} = err
else {
    panic!("unexpected error variant");
};
assert_eq!(row_number, 2);
assert_eq!(column_index, 2);
assert!(err
    .to_string()
    .contains("unrecognised boolean value 'maybe'"));

A Gherkin Docstring is available through an argument named docstring of type String. Both arguments must use these exact names and types to be detected by the procedural macros. When both are declared, place datatable before docstring at the end of the parameter list.

Scenario: capture table and docstring
  Given the following numbers:
    | a | b |
    | 1 | 2 |
  When I submit:
    """
    payload
    """
#[given("the following numbers:")]
fn capture_table(datatable: Vec<Vec<String>>) {
    // ...
}

#[when("I submit:")]
fn capture_docstring(docstring: String) {
    // ...
}

#[then("table and text:")]
fn capture_both(datatable: Vec<Vec<String>>, docstring: String) {
    // datatable must precede docstring
}

At runtime, the generated wrapper converts the table cells or copies the block text and passes them to the step function. It panics if the step declares datatable or docstring but the feature omits the content. Docstrings may be delimited by triple double-quotes or triple backticks.