Behavior-Driven Development (BDD) with Cucumber makes test scenarios easy to readโbut maintaining them as systems grow can become tricky. In this post, we'll explore how to use custom parameter types and data table transformations in Cucumber with JavaScript to keep our step definitions clean, expressive, and close to our domain language. We'll see how turning raw strings and tables into rich objects like Email, UserRole, and Product makes our tests not just easier to write, but also easier to trust and scale.
Before we explore how to extend Cucumber with custom parameter types and data table transformations, let's start by setting up a clean and scalable project structure. Here's how our project is organized:
This layout keeps our feature files close to their corresponding step logic, while isolating our core application logic in the src directory.
To get started with Cucumber.js, we run the following command:
npm init -y
npm install --save-dev @cucumber/cucumber
We can now create and run BDD-style tests using Gherkin syntax with step definitions powered by JavaScript.
In Cucumber, step definitions often extract values using simple placeholders like {string} or {int}. But what if our steps contain domain-specific values like emails, roles, or formatted prices?
That's where custom parameter types come in. They allow us to define:
Each custom parameter type is defined using:
Let's walk through each custom type used in our parameter_types.js.
Why this implementation?
defineParameterType({
name: "email",
regexp: /[^\s@]+@[^\s@]+\.[^\s@]+/,
transformer: (emailString) => {
return new Email(emailString);
},
});
This ensures invalid emails are caught early and gives us rich email handling in our steps.
Why this implementation?
defineParameterType({
name: "user_role",
regexp: /admin|user|guest|moderator/,
transformer: (roleString) => {
return new UserRole(roleString);
},
});
Why this implementation?
defineParameterType({
name: "money",
regexp: /\$?(\d+(?:\.\d{2})?)/,
transformer: (amountString) => {
const amount = parseFloat(amountString.replace("$", ""));
return {
amount: amount,
cents: Math.round(amount * 100),
currency: "USD",
toString: () => `$${amount.toFixed(2)}`,
};
},
});
This lets us write money-based steps naturally, like Given I have $150.50 in my account, while working with precise cent-based values in logic.
Why this implementation?
defineParameterType({
name: "date",
regexp:
/(\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{4}|today|tomorrow|yesterday)/,
transformer: (dateString) => {
let date;
switch (dateString.toLowerCase()) {
case "today":
date = new Date();
break;
case "tomorrow":
date = new Date();
date.setDate(date.getDate() + 1);
break;
case "yesterday":
date = new Date();
date.setDate(date.getDate() - 1);
break;
default:
date = new Date(dateString);
}
return {
value: date,
format: (formatString = "YYYY-MM-DD") => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
},
isWeekend: () => {
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6;
},
};
},
});
By handling formatting and logic upfront, our step definitions stay focused on test intentโnot parsing.
Here's how these parameter types are used in .feature files and what they resolve to:
Gherkin Step Example | Parameter Type | Transformed Value |
---|---|---|
Given a user with email john@example.com and role admin | {email} {user_role} | Email and UserRole instances |
Given I have $150.50 in my account | {money} | { amount: 150.5, cents: 15050, ... } |
Given today is tomorrow | {date} | { value: Date, isWeekend(): boolean, ... } |
Then the user should have admin privileges | {user_role} | UserRole instance with permission logic |
These transformations allow us to write natural language steps while working with structured, validated, and behavior-rich data in our test code.
Once we've defined our custom parameter types in parameter_types.js, we can use them directly in our Gherkin step definitions using curly brace syntaxโjust like built-in types ({int}, {string}, etc.).
This section shows how our scenarios in user_management.feature leverage {email}, {user_role}, {money}, and {date} to make steps clean, expressive, and strongly typed.
Scenario: Creating users with different roles
Given a user with email john@example.com and role admin
And a user with email jane@example.com and role user
When I login as john@example.com
Then the user should have admin privileges
And the user should be able to "manage_users"
How It Works:
In our step definition:
Given("a user with email {email} and role {user_role}", function (email, role) {
const user = new User({ email, role });
users.push(user);
this.lastCreatedUser = user;
});
The test receives email and role as fully-formed objects, not raw strings. This allows our User model to validate and behave exactly like it would in production code.
Scenario: Money handling
Given I have $150.50 in my account
Then my balance should be $150.50
How It Works:
{money}: Matches strings like $150.50 or 150.50 and transforms them into a structured object:
{
amount: 150.5,
cents: 15050,
currency: 'USD',
toString: () => "$150.50"
}
Step definition:
Given("I have {money} in my account", function (amount) {
this.accountBalance = amount;
});
Then("my balance should be {money}", function (expectedAmount) {
assert.strictEqual(this.accountBalance.cents, expectedAmount.cents);
});
This keeps our balance logic consistent, avoids string comparison issues, and supports currency-aware logic.
Scenario: Date handling
Given today is tomorrow
Then the date should be a weekend
How It Works:
{date}: Accepts keywords (today, tomorrow, yesterday) or formatted dates (2025-06-01, 06/01/2025) and transforms them into an object with a Date value and utilities like isWeekend().
Given("today is {date}", function (date) {
this.currentDate = date;
});
Then("the date should be a weekend", function () {
assert(this.currentDate.isWeekend(), "Expected date to be a weekend");
});
This allows natural language like tomorrow while still testing real logic based on calendar dates.
Cucumber automatically links {email}, {user_role}, {money}, and {date} in our step definitions to the corresponding custom parameter types defined earlier. This is based on the name field used in defineParameterType.
We don't need to manually parse or convert valuesโthey arrive in our step functions already validated and modeled.
Cucumber supports data tables in Gherkin steps, which are great for passing structured input into our tests. But instead of manually parsing raw strings every time, we can build reusable transformation helpers that turn data tables into real domain objects like Product, User, or even plain key-value objects.
dataTable.hashes() converts a horizontal table into an array of objects. We use this when each row represents a new object (e.g., a list of users or products).
Example Input:
| name | price | category |
| Phone | $999 | gadgets |
| Mouse | $49 | gadgets |
Output:
[
{ name: 'Phone', price: '$999', category: 'gadgets' },
{ name: 'Mouse', price: '$49', category: 'gadgets' }
]
dataTable.rowsHash() converts a vertical table into a single object, where the first column becomes keys and the second column becomes values.
Example Input:
| name | Magic Mouse |
| price | $79.99 |
| category | accessories |
Output:
{
name: 'Magic Mouse',
price: '$79.99',
category: 'accessories'
}
The transformProductTable helper converts horizontal tables of multiple products into an array of Product instances. It extracts fields like price, tags, and inStock, and wraps them with the appropriate logic.
Use case: Feature scenarios that define a product catalog with multiple entries.
function transformProductTable(table) {
return table.hashes().map((row) => {
return new Product({
name: row.name,
price: {
amount: parseFloat(row.price.replace("$", "")),
cents: Math.round(parseFloat(row.price.replace("$", "")) * 100),
currency: "USD",
toString: () => row.price,
},
category: row.category,
inStock: row.in_stock === "true",
description: row.description || "",
tags: row.tags ? row.tags.split(",").map((tag) => tag.trim()) : [],
});
});
}
This makes it easy to populate a product list in one line inside a step definition:
Given("the following products exist:", function (dataTable) {
const productList = transformProductTable(dataTable);
products.push(...productList);
});
The transformUserTable function converts horizontal tables into an array of User instances, internally utilizing Email and UserRole parameter types for consistency.
Use case: Creating multiple users with emails, names, and roles.
function transformUserTable(table) {
return table.hashes().map((row) => {
return new User({
email: new Email(row.email),
role: new UserRole(row.role),
name: row.name,
});
});
}
Feature example:
Given the following users exist:
| name | email | role |
| John Smith | john@example.com | admin |
| Jane Doe | jane@example.com | user |
Step definition:
Given("the following users exist:", function (dataTable) {
const userList = transformUserTable(dataTable);
users.push(...userList);
});
When a feature step describes a single entity in a vertical format, the transformVerticalTable utility turns it into a plain object, which can be passed to a class constructor or used directly.
function transformVerticalTable(table) {
const obj = {};
for (const [key, value] of Object.entries(table.rowsHash())) {
obj[key] = value;
}
return obj;
}
Feature example:
Given a product with these details:
| name | Premium Widget |
| price | $29.99 |
| category | gadgets |
| description | A fantastic gadget |
| tags | premium,new,useful |
Step definition:
Given("a product with these details:", function (dataTable) {
const productData = transformVerticalTable(dataTable);
const product = new Product({
name: productData.name,
price: {
amount: parseFloat(productData.price.replace("$", "")),
cents: Math.round(parseFloat(productData.price.replace("$", "")) * 100),
currency: "USD",
toString: () => productData.price,
},
category: productData.category,
description: productData.description,
tags: productData.tags
? productData.tags.split(",").map((tag) => tag.trim())
: [],
});
products.push(product);
this.lastCreatedProduct = product;
});
After defining transformation helpers for data tables, we can plug them into our step definitions to work with domain objects instead of raw strings. This improves readability, reduces duplication, and keeps test logic closely aligned with business logic.
Given the following products exist: This step uses a horizontal table with multiple products. Instead of parsing the table manually, it uses transformProductTable() to create fully-formed Product instances.
Given("the following products exist:", function (dataTable) {
const productList = transformProductTable(dataTable);
products.push(...productList);
this.products = products;
});
Feature Example:
Given the following products exist:
| name | price | category | in_stock | tags |
| iPhone 15 | $999.99 | electronics | true | phone,premium |
| MacBook Pro | $2499 | computers | true | laptop,premium |
This lets us simulate a product catalog without needing to duplicate object creation logic across tests.
Given a product with these details: This step creates a single product using a vertical key-value table. The transformVerticalTable() helper converts the input into a flat object, and the step builds a Product from it.
Given("a product with these details:", function (dataTable) {
const productData = transformVerticalTable(dataTable);
const product = new Product({
name: productData.name,
price: {
amount: parseFloat(productData.price.replace("$", "")),
cents: Math.round(parseFloat(productData.price.replace("$", "")) * 100),
currency: "USD",
toString: () => productData.price,
},
category: productData.category,
description: productData.description,
tags: productData.tags
? productData.tags.split(",").map((tag) => tag.trim())
: [],
});
products.push(product);
this.lastCreatedProduct = product;
});
The product steps also include logic for filtering by category or tag, and for validating product details.
When("I search for products in category {string}", function (category) {
searchResults = products.filter((product) => product.category === category);
});
Then("I should find {int} product(s)", function (expectedCount) {
assert.strictEqual(searchResults.length, expectedCount);
});
And more detailed validation:
Then("the products should have the following details:", function (dataTable) {
const expectedProducts = transformProductTable(dataTable);
expectedProducts.forEach((expectedProduct) => {
const actualProduct = products.find((p) => p.name === expectedProduct.name);
assert(actualProduct, `Product ${expectedProduct.name} not found`);
assert.strictEqual(actualProduct.category, expectedProduct.category);
assert.strictEqual(actualProduct.price.cents, expectedProduct.price.cents);
});
});
This makes end-to-end product tests robust while keeping the syntax concise.
Given the following users exist: Just like products, users can be created using horizontal tables and the transformUserTable() helper:
Given("the following users exist:", function (dataTable) {
const userList = transformUserTable(dataTable);
users.push(...userList);
this.users = users;
});
Feature Example:
Given the following users exist:
| name | email | role |
| John Smith | john@example.com | admin |
| Jane Doe | jane@example.com | user |
Each user is initialized with an Email and UserRole object, thanks to the transformation logic.
The user steps go beyond just creationโthey simulate login and assert behavior based on role permissions:
When("I login as {email}", function (email) {
currentUser = users.find((user) => user.email.value === email.value);
if (!currentUser) {
throw new Error(`No user found with email: ${email.value}`);
}
});
Then("the user should have {user_role} privileges", function (expectedRole) {
assert.strictEqual(currentUser.role.value, expectedRole.value);
});
Then("the user should be able to {string}", function (permission) {
assert(
currentUser.hasPermission(permission),
`User with role ${currentUser.role.value} should have ${permission} permission`
);
});
This tight integration between transformed input and test logic makes it easy to verify complex role-based behavior with just a few lines of Gherkin.
By transforming tables into rich objects early, our step definitions stay focused on behavior, not parsing.
This leads to cleaner, more expressive, and easier-to-maintain tests.
Well-structured .feature files serve as living documentation and help stakeholders understand system behavior. By combining custom parameter types with transformed data tables, our Gherkin scenarios become both readable and executable.
This file demonstrates multiple ways to define product data:
✷ Horizontal tables for bulk creation:
Given the following products exist:
| name | price | category | in_stock | tags |
| iPhone 15 | $999.99 | electronics | true | phone,premium |
✷ Vertical tables for individual product setup:
Given a product with these details:
| name | Premium Widget |
| price | $29.99 |
| category | gadgets |
This approach makes it easy to test different use cases like search, filtering, and data validation with minimal duplication.
These files showcase:
✷ Using custom types ({email}, {user_role}) directly in steps:
Given a user with email john@example.com and role admin
✷ Creating multiple users using a data table:
Given the following users exist:
| name | email | role |
| Jane Doe | jane@example.com | user |
This keeps role-based testing scenarios clear, even for non-technical stakeholders.
Using custom parameter types and table transformation helpers offers several key advantages that scale well with growing projects:
To get the most out of custom parameter types and data table transformations, follow these practical guidelines:
Custom parameter types and table transformations in Cucumber bring structure, clarity, and power to our test automation. By converting raw strings and tables into rich domain objects, we write tests that are easier to read, more reusable, and closely aligned with real application behavior.
This approach reduces duplication, improves maintainability, and keeps our .feature files readable for both technical and non-technical team members.
The complete code examples from this blog are available on our GitHub page โ feel free to explore, clone, and adapt them to your own projects.