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 asNone.#[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 usingdatatable::truthy_bool.#[datatable(parse_with = path::to_fn)]calls a custom parser that returns aResult<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 aDataTableErroron 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.