Data-Driven Testing with Serenity BDD
Data-driven testing allows you to run the same test logic with multiple sets of input data, making your tests more comprehensive and maintainable. This guide covers data-driven testing approaches with both JUnit 5 and Cucumber.
Prerequisites
- Java 17 or higher
- Serenity BDD 5.0.2 or higher
- JUnit 5 6.0.1 or higher (for JUnit examples)
- Cucumber 7.33.0 or higher (for Cucumber examples)
Why Data-Driven Testing?
Data-driven testing helps you:
- Test multiple scenarios with minimal code duplication
- Improve test coverage by testing edge cases and boundary values
- Separate test logic from test data for better maintainability
- Make tests more readable by clearly showing what varies between test cases
JUnit 5 Parameterized Tests
JUnit 5 provides powerful parameterized testing capabilities through @ParameterizedTest.
Basic Setup
First, add the JUnit Jupiter parameters dependency:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>6.0.1</version>
<scope>test</scope>
</dependency>
Simple Value Sources
Use @ValueSource for simple data types:
import net.serenitybdd.annotations.Managed;
import net.serenitybdd.junit5.SerenityJUnit5Extension;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.openqa.selenium.WebDriver;
@ExtendWith(SerenityJUnit5Extension.class)
class SearchTests {
@Managed(driver = "chrome", options = "headless")
WebDriver driver;
@ParameterizedTest
@ValueSource(strings = {"Selenium", "Cucumber", "JUnit 5"})
void shouldFindSearchResults(String searchTerm) {
// Navigate to search page
driver.get("https://duckduckgo.com");
// Perform search with each term
SearchPage searchPage = new SearchPage(driver);
searchPage.searchFor(searchTerm);
// Verify results
assertThat(searchPage.getResults()).isNotEmpty();
}
}
Supported types for @ValueSource:
strings- String valuesints- Integer valueslongs- Long valuesdoubles- Double valuesbooleans- Boolean values
CSV Source - Inline Data
Use @CsvSource for multiple parameters per test case:
@ExtendWith(SerenityJUnit5Extension.class)
class LoginTests {
@Managed(driver = "chrome", options = "headless")
WebDriver driver;
@Steps
LoginActions loginActions;
@ParameterizedTest
@CsvSource({
"standard_user, secret_sauce, true",
"locked_out_user, secret_sauce, false",
"problem_user, secret_sauce, true",
"invalid_user, wrong_password, false"
})
void shouldHandleLoginScenarios(String username, String password, boolean shouldSucceed) {
loginActions.navigateToLoginPage();
loginActions.loginWith(username, password);
if (shouldSucceed) {
loginActions.shouldSeeProductsPage();
} else {
loginActions.shouldSeeErrorMessage();
}
}
}
Tips for @CsvSource:
- Values are comma-separated by default
- Use single quotes for strings containing commas:
'value, with, commas' - Empty values are treated as null
- Whitespace is trimmed by default
CSV Source with Custom Delimiter
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"Alice | alice@example.com | Premium",
"Bob | bob@example.com | Standard",
"Carol | carol@example.com | Basic"
})
void shouldHandleDifferentUserTypes(String name, String email, String accountType) {
// Test implementation
}
CSV File Source - External Data
Store test data in external CSV files for better maintainability:
src/test/resources/test-data/users.csv:
username,password,email,expectedRole
admin,admin123,admin@example.com,ADMIN
user1,password1,user1@example.com,USER
user2,password2,user2@example.com,USER
guest,guest123,guest@example.com,GUEST
@ExtendWith(SerenityJUnit5Extension.class)
class UserRegistrationTests {
@Steps
RegistrationActions registration;
@ParameterizedTest
@CsvFileSource(resources = "/test-data/users.csv", numLinesToSkip = 1)
void shouldRegisterUsers(String username, String password, String email, String expectedRole) {
registration.registerNewUser(username, password, email);
registration.shouldHaveRole(expectedRole);
}
}
@CsvFileSource options:
resources- Path to CSV file (relative to src/test/resources)numLinesToSkip- Skip header row (typically 1)delimiter- Custom delimiter (default is comma)encoding- File encoding (default is UTF-8)
Method Source - Complex Objects
Use @MethodSource for complex test data or when you need more control:
@ExtendWith(SerenityJUnit5Extension.class)
class ProductTests {
@Steps
ShoppingActions shopping;
@ParameterizedTest
@MethodSource("productTestData")
void shouldAddProductToCart(Product product, int quantity, double expectedTotal) {
shopping.addToCart(product, quantity);
shopping.shouldShowTotal(expectedTotal);
}
static Stream<Arguments> productTestData() {
return Stream.of(
Arguments.of(new Product("Laptop", 999.99), 1, 999.99),
Arguments.of(new Product("Mouse", 29.99), 2, 59.98),
Arguments.of(new Product("Keyboard", 79.99), 3, 239.97)
);
}
}
Method Source from External Class
Organize test data in separate classes:
class TestDataProviders {
static Stream<Arguments> loginScenarios() {
return Stream.of(
Arguments.of("admin", "admin123", true, "Dashboard"),
Arguments.of("user", "user123", true, "Home"),
Arguments.of("invalid", "wrong", false, "Login Error")
);
}
static Stream<Arguments> searchQueries() {
return Stream.of(
Arguments.of("Selenium", 10),
Arguments.of("Cucumber", 8),
Arguments.of("JUnit", 15)
);
}
}
@ExtendWith(SerenityJUnit5Extension.class)
class LoginTests {
@ParameterizedTest
@MethodSource("com.example.TestDataProviders#loginScenarios")
void shouldHandleLoginScenarios(String username, String password,
boolean shouldSucceed, String expectedPage) {
// Test implementation
}
}
Enum Source
Test with enum values:
enum UserRole {
ADMIN, MANAGER, USER, GUEST
}
@ExtendWith(SerenityJUnit5Extension.class)
class RoleBasedTests {
@ParameterizedTest
@EnumSource(UserRole.class)
void shouldHandleAllUserRoles(UserRole role) {
// Test each role
}
@ParameterizedTest
@EnumSource(value = UserRole.class, names = {"ADMIN", "MANAGER"})
void shouldAllowAdminActions(UserRole role) {
// Test only admin and manager roles
}
}
Custom Display Names
Make test reports more readable:
@ParameterizedTest(name = "Search for ''{0}'' should return at least {1} results")
@CsvSource({
"Selenium, 10",
"Cucumber, 5",
"JUnit, 8"
})
void searchTests(String term, int minimumResults) {
// Test implementation
}
The {0}, {1} placeholders correspond to parameter positions.
Cucumber Scenario Outlines
Cucumber provides Scenario Outlines for data-driven BDD tests.
Basic Scenario Outline
features/login.feature:
Feature: User Login
Scenario Outline: Login with different credentials
Given the user is on the login page
When the user logs in with username "<username>" and password "<password>"
Then the login should <result>
And the user should see the "<page>" page
Examples:
| username | password | result | page |
| standard_user | secret_sauce | succeed | Products |
| locked_user | secret_sauce | fail | Login |
| invalid_user | wrong_pass | fail | Login |
Step Definitions with Screenplay
import io.cucumber.java.en.*;
import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.abilities.BrowseTheWeb;
import net.serenitybdd.screenplay.actions.Open;
import static net.serenitybdd.screenplay.GivenWhenThen.*;
public class LoginStepDefinitions {
@Given("{actor} is on the login page")
public void userIsOnLoginPage(Actor actor) {
actor.attemptsTo(
Open.url("https://www.saucedemo.com")
);
}
@When("{actor} logs in with username {string} and password {string}")
public void userLogsIn(Actor actor, String username, String password) {
actor.attemptsTo(
Login.withCredentials(username, password)
);
}
@Then("the login should {word}")
public void verifyLoginResult(String result) {
// Verification based on result
}
@Then("{actor} should see the {string} page")
public void verifiesPage(Actor actor, String expectedPage) {
actor.should(
seeThat(TheCurrentPage.title(), containsString(expectedPage))
);
}
}
Multiple Examples Tables
Use multiple Examples tables to organize different test scenarios:
Feature: Shopping Cart
Scenario Outline: Add products to cart
Given the user is logged in
When the user adds "<product>" with quantity <quantity>
Then the cart total should be <total>
Examples: Single items
| product | quantity | total |
| Laptop | 1 | 999.99 |
| Mouse | 1 | 29.99 |
| Keyboard | 1 | 79.99 |
Examples: Multiple quantities
| product | quantity | total |
| Mouse | 3 | 89.97 |
| Keyboard | 2 | 159.98 |
Data Tables
For more complex data structures, use Data Tables:
Feature: Bulk User Registration
Scenario: Register multiple users
Given the admin is logged in
When the admin registers the following users:
| username | email | role |
| alice | alice@example.com | Admin |
| bob | bob@example.com | User |
| charlie | charlie@example.com| Manager |
Then all users should be created successfully
Step definition:
@When("the admin registers the following users:")
public void registerUsers(DataTable dataTable) {
List<Map<String, String>> users = dataTable.asMaps();
for (Map<String, String> user : users) {
String username = user.get("username");
String email = user.get("email");
String role = user.get("role");
actor.attemptsTo(
RegisterUser.withDetails(username, email, role)
);
}
}
External Data Files with Cucumber
Load data from external files:
Scenario: Register users from CSV
Given the admin is logged in
When the admin imports users from "test-data/users.csv"
Then all users should be registered
@When("the admin imports users from {string}")
public void importUsersFromFile(String filePath) {
List<User> users = UserDataLoader.fromCsv(filePath);
users.forEach(user ->
actor.attemptsTo(RegisterUser.with(user))
);
}
External Data Sources
Reading from CSV Files
Create a reusable CSV reader:
public class CsvDataReader {
public static List<Map<String, String>> readCsv(String filePath) {
List<Map<String, String>> data = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(filePath))) {
String headerLine = reader.readLine();
String[] headers = headerLine.split(",");
String line;
while ((line = reader.readLine()) != null) {
String[] values = line.split(",");
Map<String, String> row = new HashMap<>();
for (int i = 0; i < headers.length; i++) {
row.put(headers[i].trim(), values[i].trim());
}
data.add(row);
}
} catch (IOException e) {
throw new RuntimeException("Failed to read CSV file: " + filePath, e);
}
return data;
}
}
Reading from JSON Files
Use Jackson or Gson to read JSON test data:
src/test/resources/test-data/products.json:
[
{
"name": "Laptop",
"price": 999.99,
"category": "Electronics",
"inStock": true
},
{
"name": "Mouse",
"price": 29.99,
"category": "Accessories",
"inStock": true
}
]
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
public class JsonDataReader {
private static final ObjectMapper mapper = new ObjectMapper();
public static <T> List<T> readJson(String filePath, Class<T> type) {
try {
InputStream stream = JsonDataReader.class
.getResourceAsStream(filePath);
return mapper.readValue(stream,
mapper.getTypeFactory().constructCollectionType(List.class, type));
} catch (IOException e) {
throw new RuntimeException("Failed to read JSON file: " + filePath, e);
}
}
}
// Usage
@MethodSource("productData")
static Stream<Product> productData() {
List<Product> products = JsonDataReader.readJson(
"/test-data/products.json",
Product.class
);
return products.stream();
}
Reading from Excel Files
Use Apache POI for Excel files:
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
<scope>test</scope>
</dependency>
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
public class ExcelDataReader {
public static List<Map<String, String>> readExcel(String filePath, String sheetName) {
List<Map<String, String>> data = new ArrayList<>();
try (FileInputStream fis = new FileInputStream(filePath);
Workbook workbook = new XSSFWorkbook(fis)) {
Sheet sheet = workbook.getSheet(sheetName);
Row headerRow = sheet.getRow(0);
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
Map<String, String> rowData = new HashMap<>();
for (int j = 0; j < headerRow.getLastCellNum(); j++) {
String header = headerRow.getCell(j).getStringCellValue();
String value = getCellValueAsString(row.getCell(j));
rowData.put(header, value);
}
data.add(rowData);
}
} catch (IOException e) {
throw new RuntimeException("Failed to read Excel file", e);
}
return data;
}
private static String getCellValueAsString(Cell cell) {
if (cell == null) return "";
switch (cell.getCellType()) {
case STRING: return cell.getStringCellValue();
case NUMERIC: return String.valueOf(cell.getNumericCellValue());
case BOOLEAN: return String.valueOf(cell.getBooleanCellValue());
default: return "";
}
}
}
Test Data Builders
Use the Builder pattern for complex test data:
public class UserBuilder {
private String username;
private String email;
private String password;
private String role = "USER";
private boolean active = true;
public static UserBuilder aUser() {
return new UserBuilder();
}
public UserBuilder withUsername(String username) {
this.username = username;
return this;
}
public UserBuilder withEmail(String email) {
this.email = email;
return this;
}
public UserBuilder withPassword(String password) {
this.password = password;
return this;
}
public UserBuilder withRole(String role) {
this.role = role;
return this;
}
public UserBuilder inactive() {
this.active = false;
return this;
}
public User build() {
return new User(username, email, password, role, active);
}
}
// Usage
@MethodSource("userTestData")
static Stream<User> userTestData() {
return Stream.of(
aUser().withUsername("admin").withRole("ADMIN").build(),
aUser().withUsername("user1").withEmail("user1@example.com").build(),
aUser().withUsername("inactive").inactive().build()
);
}
Using Faker for Random Data
Generate realistic random test data:
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>
<version>1.0.2</version>
<scope>test</scope>
</dependency>
import com.github.javafaker.Faker;
public class TestDataGenerator {
private static final Faker faker = new Faker();
public static User randomUser() {
return aUser()
.withUsername(faker.name().username())
.withEmail(faker.internet().emailAddress())
.withPassword(faker.internet().password(8, 16))
.build();
}
public static Product randomProduct() {
return new Product(
faker.commerce().productName(),
Double.parseDouble(faker.commerce().price()),
faker.commerce().department()
);
}
}
// Usage in tests
@ParameterizedTest
@MethodSource("randomUsers")
void shouldRegisterRandomUsers(User user) {
registration.registerUser(user);
registration.shouldSucceed();
}
static Stream<User> randomUsers() {
return Stream.generate(TestDataGenerator::randomUser).limit(10);
}
Best Practices
1. Keep Test Data Close to Tests
src/test/resources/
├── test-data/
│ ├── users/
│ │ ├── valid-users.csv
│ │ └── invalid-users.csv
│ ├── products/
│ │ └── products.json
│ └── shared/
│ └── countries.csv
2. Use Meaningful Test Names
@ParameterizedTest(name = "User ''{0}'' with role ''{1}'' should access ''{2}''")
@CsvSource({
"admin, ADMIN, dashboard",
"user, USER, homepage"
})
void accessControlTests(String username, String role, String page) {
// Test implementation
}
3. Validate Test Data
@MethodSource("userData")
static Stream<User> userData() {
return loadUsersFromCsv("/test-data/users.csv")
.stream()
.peek(user -> {
assertThat(user.getEmail()).contains("@");
assertThat(user.getPassword()).hasSizeGreaterThan(8);
});
}
4. Isolate Test Data
Ensure each test has its own independent data:
@BeforeEach
void setUp() {
// Clean up any existing test data
testDataCleanup.deleteTestUsers();
}
@ParameterizedTest
@MethodSource("userTestData")
void userTests(User user) {
// Each test gets fresh data
String uniqueUsername = user.getUsername() + "_" + UUID.randomUUID();
User isolatedUser = user.withUsername(uniqueUsername);
// Test with isolated data
}
5. Document Data Requirements
/**
* Tests user registration with various valid inputs.
*
* Test data requirements:
* - Username: 3-20 characters, alphanumeric
* - Email: Valid email format
* - Password: Minimum 8 characters
* - Role: One of [USER, ADMIN, MANAGER]
*/
@ParameterizedTest
@CsvFileSource(resources = "/test-data/valid-users.csv")
void shouldRegisterValidUsers(String username, String email,
String password, String role) {
// Test implementation
}
6. Separate Valid and Invalid Data
// Valid data tests
@ParameterizedTest
@CsvFileSource(resources = "/test-data/valid-users.csv")
void shouldAcceptValidUsers(User user) {
// Expect success
}
// Invalid data tests
@ParameterizedTest
@CsvFileSource(resources = "/test-data/invalid-users.csv")
void shouldRejectInvalidUsers(User user, String expectedError) {
// Expect specific error
}
Complete Example: E-Commerce Testing
Here's a complete example combining multiple techniques:
Product.java:
public class Product {
private final String name;
private final double price;
private final String category;
private final boolean inStock;
// Constructor, getters, builder...
}
test-data/products.csv:
name,price,category,inStock,expectedInCart
Laptop,999.99,Electronics,true,true
Headphones,79.99,Audio,true,true
Out of Stock Item,49.99,Misc,false,false
ShoppingTests.java:
@ExtendWith(SerenityJUnit5Extension.class)
class ShoppingTests {
@Steps
ShoppingActions shopping;
@ParameterizedTest(name = "Adding {0} (${1}) should {4}")
@CsvFileSource(resources = "/test-data/products.csv", numLinesToSkip = 1)
void shouldHandleProductAddition(String name, double price, String category,
boolean inStock, boolean expectedInCart) {
Product product = new Product(name, price, category, inStock);
shopping.addProductToCart(product);
if (expectedInCart) {
shopping.shouldSeeProductInCart(product);
shopping.cartTotalShouldBe(price);
} else {
shopping.shouldSeeOutOfStockMessage();
}
}
}
Troubleshooting
Issue: Parameterized tests not running
Solution: Ensure you have the junit-jupiter-params dependency and use @ParameterizedTest not @Test.
Issue: CSV data not loading
Solution:
- Check file path is relative to
src/test/resources - Verify
numLinesToSkipis set correctly - Check for proper CSV formatting
Issue: Data appearing in wrong order
Solution: JUnit doesn't guarantee execution order. Use @TestMethodOrder if needed:
@TestMethodOrder(OrderAnnotation.class)
class OrderedTests {
@ParameterizedTest
@Order(1)
@ValueSource(strings = {"first"})
void test1(String value) { }
}
Next Steps
- Explore Screenplay Pattern for better test organization
- Learn about Parallel Execution to speed up data-driven tests
- Check out Serenity Reports to see how parameterized tests appear in reports