Data tables and doc strings

Version 0.6.0 Updated Sep 14, 2026

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,
# }

The selection function preserves the caller-supplied order, so applications can pass a list of preferred locales. The helper resolves to the best available translation and continues to fall back to English when a requested locale is not shipped with the crate. Procedural macro diagnostics remain in English so compile-time output stays deterministic regardless of the host machine’s language settings.