Data-Driven Testing in Cucumber

Data Tables, Scenario Outline, transformers

Data Tables vs Scenario Outline

Cucumber has two ways to work with data. Understanding the difference is critical:

AspectData TableScenario Outline
PurposePass multiple values to a single stepRun the entire scenario with different data
ExecutionScenario runs onceScenario runs N times (once per row)
PlacementUnder a single stepUnder the scenario with Examples:
Use CaseFill a form, create multiple recordsTest login with 5 different credential sets
# DATA TABLE — runs once, table passed to one step
Scenario: Fill registration form
  When I fill in the form:
    | firstName | John            |
    | lastName  | Doe             |
    | email     | john@test.com   |
  Then registration should succeed

# SCENARIO OUTLINE — runs 3 times (once per Examples row)
Scenario Outline: Login attempts
  When I login with "<user>" and "<pass>"
  Then I should see "<result>"

  Examples:
    | user  | pass    | result    |
    | admin | admin1  | Dashboard |
    | user  | wrong   | Error     |
    | guest |         | Error     |

Data Tables — All Formats

Format 1: Key-Value Pairs (Map)

Scenario: Update user profile
  When I update my profile with:
    | firstName | John              |
    | lastName  | Doe               |
    | email     | john@example.com  |
    | phone     | 1234567890        |
@When("I update my profile with:")
public void iUpdateProfileWith(DataTable dataTable) {
    // Convert to Map (2-column table without header)
    Map<String, String> data = dataTable.asMap(String.class, String.class);

    profilePage.enterFirstName(data.get("firstName")); // "John"
    profilePage.enterLastName(data.get("lastName"));   // "Doe"
    profilePage.enterEmail(data.get("email"));         // "john@example.com"
    profilePage.enterPhone(data.get("phone"));         // "1234567890"
}

Format 2: Single Column (List)

Scenario: Verify available categories
  Then I should see the following categories:
    | Electronics |
    | Clothing    |
    | Books       |
    | Sports      |
@Then("I should see the following categories:")
public void iShouldSeeCategories(DataTable dataTable) {
    // Convert to flat list
    List<String> expectedCategories = dataTable.asList();
    // ["Electronics", "Clothing", "Books", "Sports"]

    List<String> actualCategories = categoryPage.getCategories();
    Assert.assertEquals(expectedCategories, actualCategories);
}

Format 3: Multiple Rows with Headers (List of Maps)

Scenario: Create multiple users
  When I create the following users:
    | username | email            | role   |
    | john     | john@test.com    | admin  |
    | jane     | jane@test.com    | user   |
    | bob      | bob@test.com     | viewer |
@When("I create the following users:")
public void iCreateUsers(DataTable dataTable) {
    // First row is the header, remaining rows are data
    List<Map<String, String>> users = dataTable.asMaps();

    for (Map<String, String> user : users) {
        userService.createUser(
            user.get("username"),  // "john", "jane", "bob"
            user.get("email"),     // "john@test.com", ...
            user.get("role")       // "admin", "user", "viewer"
        );
    }
}
// users.size() == 3 (header row is not counted)

Format 4: Lists of Lists

@When("I verify the price list:")
public void verifyPriceList(DataTable dataTable) {
    // Raw access — each row is a List<String>
    List<List<String>> rows = dataTable.asLists();

    // rows.get(0) = ["Product", "Price"] (header)
    // rows.get(1) = ["Laptop", "999.99"]
    // rows.get(2) = ["Mouse", "29.99"]

    for (int i = 1; i < rows.size(); i++) {  // skip header
        String product = rows.get(i).get(0);
        String price = rows.get(i).get(1);
        Assert.assertEquals(price, productPage.getPrice(product));
    }
}

Quick Reference — DataTable Methods

MethodReturnsBest For
asMap()Map<String, String>2-column key-value pairs
asMaps()List<Map<String, String>>Multiple rows with headers
asList()List<String>Single column flat list
asLists()List<List<String>>Raw access to all cells
transpose()DataTableFlip rows and columns
cell(row, col)StringSingle cell access

@DataTableType — Transform to Objects

Instead of working with raw Maps, transform data table rows directly into Java objects using @DataTableType:

The POJO

public class User {
    private String username;
    private String email;
    private String role;

    public User(String username, String email, String role) {
        this.username = username;
        this.email = email;
        this.role = role;
    }

    // Getters
    public String getUsername() { return username; }
    public String getEmail() { return email; }
    public String getRole() { return role; }
}

The Transformer

import io.cucumber.java.DataTableType;

public class DataTableTransformers {

    @DataTableType
    public User userEntry(Map<String, String> entry) {
        return new User(
            entry.get("username"),
            entry.get("email"),
            entry.get("role")
        );
    }
}

The Step Definition — Clean!

@When("I create the following users:")
public void iCreateUsers(List<User> users) {
    // Cucumber automatically converts each row to a User object
    for (User user : users) {
        userService.createUser(user);
        System.out.println("Created: " + user.getUsername());
    }
}

// Or even simpler with Java streams
@When("I create the following users:")
public void iCreateUsers(List<User> users) {
    users.forEach(userService::createUser);
}

More @DataTableType Examples

// Product with type conversion
@DataTableType
public Product productEntry(Map<String, String> entry) {
    return new Product(
        entry.get("name"),
        Double.parseDouble(entry.get("price")),
        Integer.parseInt(entry.get("quantity"))
    );
}

// Single-cell table entry (for single-column tables)
@DataTableType
public Category categoryEntry(String cell) {
    return new Category(cell);
}

Scenario Outline — Full Guide

Scenario Outline is a template that runs the entire scenario once for each row in the Examples table. Placeholders in angle brackets (<name>) get replaced with values from each row.

Basic Example

Scenario Outline: Calculator operations
  Given I have a calculator
  When I perform "<operation>" on <a> and <b>
  Then the result should be <result>

  Examples:
    | operation | a  | b  | result |
    | add       | 5  | 3  | 8      |
    | subtract  | 10 | 4  | 6      |
    | multiply  | 3  | 7  | 21     |
    | divide    | 20 | 5  | 4      |

This generates 4 separate scenarios — one for each row.

@When("I perform {string} on {int} and {int}")
public void iPerform(String operation, int a, int b) {
    switch (operation) {
        case "add"      -> result = a + b;
        case "subtract" -> result = a - b;
        case "multiply" -> result = a * b;
        case "divide"   -> result = a / b;
    }
}

@Then("the result should be {int}")
public void resultShouldBe(int expected) {
    Assert.assertEquals(result, expected);
}

Multiple Examples Tables

You can have multiple Examples blocks, each with its own name and tags:

Scenario Outline: User role permissions
  Given I am logged in as "<role>"
  When I navigate to "<page>"
  Then access should be "<access>"

  @admin-tests
  Examples: Admin permissions
    | role  | page          | access  |
    | admin | dashboard     | granted |
    | admin | settings      | granted |
    | admin | user-mgmt     | granted |
    | admin | billing       | granted |

  @user-tests
  Examples: Regular user permissions
    | role | page          | access  |
    | user | dashboard     | granted |
    | user | settings      | denied  |
    | user | user-mgmt     | denied  |
    | user | billing       | denied  |

  @guest-tests
  Examples: Guest permissions
    | role  | page          | access  |
    | guest | dashboard     | denied  |
    | guest | settings      | denied  |
    | guest | user-mgmt     | denied  |
    | guest | billing       | denied  |

Combining Outline with Data Tables

You can use placeholders inside data tables within a Scenario Outline:

Scenario Outline: Register user by type
  When I register a new "<type>" user:
    | firstName | <firstName>     |
    | lastName  | <lastName>      |
    | email     | <email>         |
  Then the user should be created with role "<expectedRole>"

  Examples:
    | type     | firstName | lastName | email            | expectedRole |
    | customer | John      | Doe      | john@test.com    | CUSTOMER     |
    | vendor   | Jane      | Smith    | jane@vendor.com  | VENDOR       |
    | admin    | Bob       | Admin    | bob@company.com  | ADMIN        |

The placeholders in the data table get replaced just like those in regular steps, so each Examples row generates a complete scenario with its own data table values.

Real-World Patterns

Pattern 1: Login Test Matrix

@regression
Scenario Outline: Login validation matrix
  Given I am on the login page
  When I enter username "<username>" and password "<password>"
  And I click login
  Then I should see "<message>"
  And the response code should be <code>

  @positive
  Examples: Valid credentials
    | username    | password     | message    | code |
    | admin       | Admin@123    | Welcome    | 200  |
    | testuser    | Test@456     | Welcome    | 200  |

  @negative
  Examples: Invalid credentials
    | username    | password     | message              | code |
    | admin       | wrongpass    | Invalid credentials  | 401  |
    | unknown     | Admin@123    | User not found       | 404  |
    |             | Admin@123    | Username required    | 400  |
    | admin       |              | Password required    | 400  |

  @security
  Examples: Security scenarios
    | username         | password    | message          | code |
    | admin' OR '1'='1 | anything    | Invalid input    | 400  |
    | admin            | <script>   | Invalid input    | 400  |

Pattern 2: Form Validation with Data Tables

Scenario: Validate registration form fields
  When I submit the registration form with:
    | field       | value           | valid |
    | email       | john@test.com   | true  |
    | email       | invalid-email   | false |
    | email       |                 | false |
    | phone       | 1234567890      | true  |
    | phone       | 123             | false |
    | phone       | abc             | false |
    | password    | Str0ng@Pass     | true  |
    | password    | weak            | false |
    | password    | 12345678        | false |
@When("I submit the registration form with:")
public void validateFields(DataTable dataTable) {
    List<Map<String, String>> rows = dataTable.asMaps();

    for (Map<String, String> row : rows) {
        String field = row.get("field");
        String value = row.get("value");
        boolean expectedValid = Boolean.parseBoolean(row.get("valid"));

        boolean actualValid = validationHelper.isValid(field, value);
        Assert.assertEquals(
            actualValid, expectedValid,
            field + " with value '" + value + "' expected "
                + (expectedValid ? "valid" : "invalid")
        );
    }
}

Pattern 3: API Response Verification

Scenario: Verify user API response fields
  Given I request user details for "john_doe"
  Then the response should contain:
    | field      | value             |
    | username   | john_doe          |
    | email      | john@example.com  |
    | role       | admin             |
    | isActive   | true              |
  And the response should not contain:
    | password |
    | ssn      |
    | token    |

Advanced Table Operations

Transpose

Flip rows and columns when the natural layout is horizontal:

# Horizontal layout (easier to read for single records)
Scenario: Verify product details
  Then the product should have:
    | name  | price | category    | inStock |
    | Mouse | 29.99 | Electronics | true    |
@Then("the product should have:")
public void verifyProduct(DataTable dataTable) {
    // Transpose converts the single row into a map
    Map<String, String> product = dataTable.transpose()
        .asMap(String.class, String.class);

    Assert.assertEquals("Mouse", product.get("name"));
    Assert.assertEquals("29.99", product.get("price"));
    Assert.assertEquals("Electronics", product.get("category"));
    Assert.assertEquals("true", product.get("inStock"));
}

Cell Access

@Then("I verify the data:")
public void verifyData(DataTable dataTable) {
    // Direct cell access (0-indexed)
    String topLeft = dataTable.cell(0, 0);
    String secondRowThirdCol = dataTable.cell(1, 2);

    // Row count
    int rows = dataTable.height();
    // Column count
    int cols = dataTable.width();
}

Diff Tables — Compare Expected vs Actual

@Then("the search results should be:")
public void verifySearchResults(DataTable expectedTable) {
    // Build actual data table from the page
    List<List<String>> actualData = new ArrayList<>();
    actualData.add(List.of("name", "price", "rating")); // header

    for (Product p : searchPage.getResults()) {
        actualData.add(List.of(
            p.getName(),
            String.valueOf(p.getPrice()),
            String.valueOf(p.getRating())
        ));
    }

    DataTable actualTable = DataTable.create(actualData);

    // diff() throws an error with a clear diff if tables don't match
    expectedTable.diff(actualTable);
}

Data-Driven Best Practices

Summary