Optimize Your Automation Framework: Using Data-Driven Testing and Screenshots (Part 4)

Samiksha KuteSamiksha Kute
11 min read

Welcome back to the fourth installment of our Selenium Framework Design! So far we have built a standalone Selenium test for an e-commerce application, adopted the Page Object Model (POM), introduced a BaseTest class, TestNG for test management, and advanced features like parallel execution and dependent tests. Now, in Part 4, we’ll dive into data-driven testing using TestNG’s @DataProvider, learn how to drive test data from JSON files, and create a screenshot utility for failed tests. This guide, will walk you through every step to make your Selenium framework more powerful and intelligent. Let’s get started!

Recap: Where We Are

In Part 3, we:

  • Created a BaseTest class to centralize WebDriver setup and browser configuration.

  • Used a GlobalData.properties file to dynamically select browsers (Chrome, Firefox, Edge).

  • Implemented TestNG annotations (@BeforeMethod, @AfterMethod) to streamline setup and teardown.

  • Organized tests into logical groups (e.g., ErrorValidationsTest, SubmitOrderTest) and ran them via testng.xml.

  • Enabled parallel execution and selective test runs using TestNG groups.

  • Added dependent tests to verify order history after order submission.

Now, we’ll focus on parameterization to run tests with multiple data sets, use JSON as an external data source, and build a screenshot utility to capture failures for better debugging.

Step 1: Understanding Data-Driven Testing with TestNG DataProvider

Data-driven testing allows a single test to run multiple times with different input data, reducing code duplication. TestNG’s @DataProvider annotation makes this easy by supplying multiple data sets to a test method. If you’re new to @DataProvider, don’t worry we’ll go through it step-by-step. (For a deeper dive, you can check the TestNG Guide)

Creating a DataProvider

Let’s enhance our SubmitOrderTest to run with two different user accounts and product selections. We’ll create a @DataProvider method in SubmitOrderTest to supply the data.

  1. In src/test/java/tests.SubmitOrderTest, add a @DataProvider method named getData:
@DataProvider
public Object[][] getData() {
    return new Object[][] {
        {"first_email@example.com", "your_password", "ZARA COAT 3"},
        {"second_email@example.com", "your_password", "ADIDAS ORIGINAL"}
    };
}

Explanation:

  • @DataProvider: Marks getData as a data provider that TestNG will use to feed data to test methods.

  • Two-Dimensional Array: Object[][] is a 2D array where each inner array ({...}) represents one data set. Here, we have two data sets, each with three values: email, password, and product name.

  • Object Type: We use Object instead of String or Integer because it’s a generic data type that can hold any value (strings, integers, etc.), making the data provider flexible.

  • Data Sets:

    • First set: first_email@example.com, your_password, ZARA COAT 3

    • Second set: second_email@example.com, your_password, ADIDAS ORIGINAL

Attaching DataProvider to a Test

Update the submitOrder test to use the getData data provider:

@Test(dataProvider = "getData", groups = "Purchase")
public void submitOrder(String email, String password, String productName) {
    ProductCatalogue productCatalogue = landingPage.loginApplication(email, password);
    productCatalogue.addProductToCart(productName);
    CartPage cartPage = productCatalogue.goToCartPage();
    Boolean match = cartPage.verifyProductDisplay(productName);
    Assert.assertTrue(match);
    CheckoutPage checkoutPage = cartPage.goToCheckout();
    checkoutPage.selectCountry("India");
    ConfirmationPage confirmationPage = checkoutPage.submitOrder();
    String confirmMessage = confirmationPage.getConfirmationMessage();
    Assert.assertTrue(confirmMessage.equalsIgnoreCase("THANK YOU FOR THE ORDER"));
}

Explanation:

  • dataProvider = "getData": Links the test to the getData method, telling TestNG to fetch data from it.

  • Method Parameters: The test method accepts three parameters (email, password, productName) that match the data set structure in getData.

  • Dynamic Inputs: Replaces hardcoded values (e.g., "first_email@example.com", "ZARA COAT 3") with the parameters, making the test reusable.

  • Groups: Adds the test to a “Purchase” group for selective execution (more on this later).

Running the Test

To run only “Purchase” group tests, create a new TestNG XML file in src/test/java/testSuites named purchase.xml:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="PurchaseSuite">
    <test name="PurchaseTests">
        <groups>
            <run>
                <include name="Purchase"/>
            </run>
        </groups>
        <classes>
            <class name="tests.SubmitOrderTest"/>
        </classes>
    </test>
</suite>

Run purchase.xml (right-click > Run As > TestNG Suite). The test should:

  1. Run with the first data set (first_email@example.com, ZARA COAT 3), logging in, adding the product, and completing the order.

  2. Run again with the second data set (second_email@example.com, ADIDAS ORIGINAL).

Check the TestNG results (in Eclipse’s “Results of Running Suite” tab). You’ll see two test runs:

  • First run: first_email@example.com, ZARA COAT 3

  • Second run: second_email@example.com, ADIDAS ORIGINAL

Why This Matters?

This approach is ideal for scenarios like financial applications, where you need to test the same feature (e.g., order submission) across different user accounts with varying permissions or product selections.

Supporting Multiple Groups

You can assign multiple groups to a test for flexibility. For example, to include submitOrder in both “Purchase” and “Smoke” groups (for daily regression tests):

@Test(dataProvider = "getData", groups = {"Purchase", "Smoke"})

This allows the test to be run under different suites (e.g., a smoke suite for critical tests).

Step 2: Using HashMaps with DataProvider

The above approach works well for a few parameters, but what if a test requires 15 parameters? Listing 15 parameters in the test method signature (e.g., submitOrder(String param1, String param2, ..., String param15)) is messy and hard to maintain. A cleaner solution is to use a HashMap to store data as key-value pairs.

Modifying DataProvider to Return HashMaps

Update the getData method in SubmitOrderTest to return a list of HashMaps:

@DataProvider
public Object[][] getData() {
    HashMap<String, String> map = new HashMap<>();
    map.put("email", "first_email@example.com");
    map.put("password", "your_password");
    map.put("product", "ZARA COAT 3");

    HashMap<String, String> map1 = new HashMap<>();
    map1.put("email", "second_email@example.com");
    map1.put("password", "your_password");
    map1.put("product", "ADIDAS ORIGINAL");

    return new Object[][] { {map}, {map1} };
}

Explanation:

  • HashMap Creation: Each HashMap<String, String> stores data as key-value pairs (e.g., "email" -> "first_email@example.com").

  • Two HashMaps: map for the first data set, map1 for the second.

  • 2D Array: Wraps each HashMap in an array ({map}), creating a 2D array with two entries.

  • String Type: Uses String for keys and values for simplicity. (You could use HashMap<String, Object> for mixed data types like integers.)

Updating the Test Method

Modify submitOrder to accept a HashMap and extract values using keys:

@Test(dataProvider = "getData", groups = "Purchase")
public void submitOrder(HashMap<String, String> input) {
    ProductCatalogue product = landingPage.loginApplication(input.get("email"), input.get("password"));
    product.addProductToCart(input.get("product"));
    CartPage cartPage = product.goToCartPage();
    Boolean match = cartPage.verifyProductDisplay(input.get("product"));
    Assert.assertTrue(match);
    CheckoutPage checkoutPage = cartPage.goToCheckout();
    checkoutPage.selectCountry("India");
    String confirmMessage = checkoutPage.submitOrder().getConfirmationMessage();
    Assert.assertTrue(confirmMessage.equalsIgnoreCase("THANK YOU FOR THE ORDER."));
}

Explanation:

  • HashMap Parameter: Accepts a single HashMap<String, String> named input instead of multiple parameters.

  • Accessing Values: Uses input.get("email"), input.get("password"), and input.get("product") to retrieve values by key.

  • Cleaner Code: Makes the method more readable and scalable, as adding parameters only requires updating the HashMap.

Run purchase.xml again. The test should run twice, first with map and then with map1, producing the same results but with a more maintainable structure.

Why HashMaps?

  • Scalability: Easily add more parameters without changing the test method signature.

  • Readability: Key-value pairs are intuitive and self-documenting.

  • Flexibility: Supports any data type if using HashMap<String, Object>.

Step 3: Driving Data from JSON Files

Hardcoding data in the test class (even as HashMaps) isn’t ideal, as it mixes test logic with test data. Instead, we’ll store data in an external JSON file, which is lightweight, widely used, and readable by both technical and non-technical stakeholders (e.g., business analysts). We’ll create a utility to parse the JSON and convert it into HashMaps for our DataProvider.

Creating a JSON File

  1. In src/main/java, create a new package named data.

  2. Inside data, create a file named PurchaseOrder.json:

[
    {
        "email": "first_email@example.com",
        "password": "your_password",
        "product": "ZARA COAT 3"
    },
    {
        "email": "second_email@example.com",
        "password": "your_password",
        "product": "ADIDAS ORIGINAL"
    }
]

Explanation:

  • Array Structure: The JSON starts with [], indicating an array of data sets.

  • Objects: Each data set is a JSON object {} with key-value pairs.

  • Two Data Sets: Matches the two HashMaps from Step 2, making it easy for business teams to provide data in this format.

Creating a Data Reader Utility

To parse the JSON file, we’ll create a utility class in the data named DataReader:

package data;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.FileUtils;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;

public class DataReader {
    public List<HashMap<String, String>> getJsonDataToMap(String filePath) throws IOException {
        // Read JSON to String
        String jsonContent = FileUtils.readFileToString(new File(filePath), StandardCharsets.UTF_8);

        // Convert String to HashMap
        ObjectMapper mapper = new ObjectMapper();
        List<HashMap<String, String>> data = mapper.readValue(jsonContent, new TypeReference<List<HashMap<String, String>>>() {});
        return data;
    }
}

Explanation:

  • FileUtils.readFileToString: From the org.apache.commons.io package, reads the JSON file into a string. Requires the commons-io dependency in pom.xml (likely included with existing dependencies).

  • Dynamic File Path: Uses new File(filePath) to accept any JSON file path.

  • Encoding: Specifies StandardCharsets.UTF_8 to avoid deprecation warnings and ensure proper string conversion.

  • ObjectMapper: The com.fasterxml.jackson.databind package converts the JSON string into a list of HashMaps. Requires the jackson-databind dependency.

  • TypeReference: Specifies the expected output type (List<HashMap<String, String>>) for Jackson to parse the JSON array into two HashMaps.

  • Return Value: Returns a List<HashMap<String, String>> containing the two data sets.

Adding Jackson Dependency

If ObjectMapper isn’t recognized, add the jackson-databind dependency to pom.xml:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.2</version> <!-- Use the latest version -->
</dependency>

Find the latest version on Maven Repository.

Moving the Utility to BaseTest

To make the getJsonDataToMap method accessible to all tests, move it to BaseTest (since all test classes extend BaseTest):

package testComponents;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.apache.commons.io.FileUtils;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.edge.EdgeDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import pageobjects.LandingPage;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Properties;

public class BaseTest {
    public WebDriver driver;
    public LandingPage landingPage;

    public WebDriver initializeDriver() throws IOException {
        Properties prop = new Properties();
        FileInputStream fis = new FileInputStream(System.getProperty("user.dir") + "\\src\\main\\java\\resources\\GlobalData.properties");
        prop.load(fis);
        String browserName = prop.getProperty("browser");

        if (browserName.equalsIgnoreCase("chrome")) {
            WebDriverManager.chromedriver().setup();
            driver = new ChromeDriver();
        } else if (browserName.equalsIgnoreCase("firefox")) {
            WebDriverManager.firefoxdriver().setup();
            driver = new FirefoxDriver();
        } else if (browserName.equalsIgnoreCase("edge")) {
            WebDriverManager.edgedriver().setup();
            driver = new EdgeDriver();
        }

        driver.manage().window().maximize();
        driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
        return driver;
    }

    @BeforeMethod
    public void launchApplication() throws IOException {
        driver = initializeDriver();
        landingPage = new LandingPage(driver);
        landingPage.goTo();
    }

    @AfterMethod
    public void tearDown() {
        driver.quit();
    }

    public List<HashMap<String, String>> getJsonDataToMap(String filePath) throws IOException {
        String jsonContent = FileUtils.readFileToString(new File(filePath), StandardCharsets.UTF_8);
        ObjectMapper mapper = new ObjectMapper();
        List<HashMap<String, String>> data = mapper.readValue(jsonContent, new TypeReference<List<HashMap<String, String>>>() {});
        return data;
    }
}

Explanation:

  • Inheritance: Since test classes extend BaseTest, they can call getJsonDataToMap without creating a DataReader object.

  • Generic Utility: Accepts a filePath parameter, making it reusable for any JSON file.

Updating SubmitOrderTest

Remove the hardcoded HashMaps from getData and use getJsonDataToMap:

@DataProvider
public Object[][] getData() throws IOException {
    List<HashMap<String, String>> data = getJsonDataToMap(System.getProperty("user.dir") + "\\src\\main\\java\\data\\PurchaseOrder.json");
    return new Object[][] { {data.get(0)}, {data.get(1)} };
}

Explanation:

  • Dynamic Path: Uses System.getProperty("user.dir") to build the JSON file path dynamically.

  • getJsonDataToMap: Calls the utility to parse PurchaseOrder.json into a List<HashMap<String, String>>.

  • List Access: data.get(0) retrieves the first HashMap, data.get(1) the second.

  • 2D Array: Wraps each HashMap in an array for the DataProvider.

Run purchase.xml. The test should run twice, reading data from PurchaseOrder.json, producing the same results as before but with data externalized.

Why JSON?

  • Separation of Concerns: Keeps test data out of test code, making tests cleaner.

  • Accessibility: Business teams can provide data in JSON without touching code.

  • Reusability: The getJsonDataToMap utility works for any JSON file, supporting all tests.

  • Industry Standard: JSON is lightweight and widely used, unlike Excel or CSV.

Interview Tip: Be prepared to explain how you drive data from external files (e.g., JSON) using TestNG’s DataProvider for parameterization. Highlight the steps: JSON creation, parsing with Jackson, and integration with DataProvider.

Step 4: Building a Screenshot Utility

To improve debugging, we’ll create a utility to take screenshots when tests fail. These screenshots will later be attached to HTML reports which we will cover later.

Creating the Screenshot Method

Add a getScreenshot method to BaseTest to capture and save screenshots:

public String getScreenshot(String testCaseName) throws IOException {
    TakesScreenshot ts = (TakesScreenshot) driver;
    File source = ts.getScreenshotAs(OutputType.FILE);
    File file = new File(System.getProperty("user.dir") + "\\reports\\" + testCaseName + ".png");
    FileUtils.copyFile(source, file);
    return System.getProperty("user.dir") + "\\reports\\" + testCaseName + ".png";
}

Explanation:

  • TakesScreenshot: Casts the WebDriver to TakesScreenshot to enable screenshot capture.

  • getScreenshotAs: Captures the screenshot as a File (other options include bytes or base64).

  • File Destination: Saves the screenshot in a reports folder under the project directory, naming it with testCaseName (e.g., submitOrder.png).

  • Dynamic Path: Uses System.getProperty("user.dir") for portability.

  • FileUtils.copyFile: Copies the screenshot from source (temporary file) to file (destination).

  • Return Value: Returns the file path as a string for later use (e.g., attaching to reports).

  • Throws IOException: Handles file operations errors.

Note: The reports folder must exist in your project directory. Create it manually or add logic to create it programmatically.

Check out the complete code repository below:

Key Takeaways

  1. DataProvider: Enables data-driven testing by supplying multiple data sets to a test method.

  2. HashMaps: Makes tests scalable and readable by using key-value pairs instead of multiple parameters.

  3. JSON Data: Externalizes test data for better maintainability and collaboration, parsed using Jackson’s ObjectMapper.

  4. Screenshot Utility: Captures screenshots for failed tests, saving them with dynamic file names.

  5. Framework Design: Utilities like getJsonDataToMap and getScreenshot are one-time implementations, reusable across all tests.

  6. TestNG Groups: Organizes tests for selective execution (e.g., “Purchase” suite).

What’s Next?

In Part 5, we’ll:

  • We will discuss about the basics of Extent Reports.

  • Integrate Extent Reports into the framework to generate HTML reports.

  • Attach screenshots to reports for failed tests.

  • Explore CI/CD integration with tools like Jenkins.

  • Add advanced parameterization techniques.

Stay tuned for the next part of Selenium Framework Design!

Happy testing!

0
Subscribe to my newsletter

Read articles from Samiksha Kute directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Samiksha Kute
Samiksha Kute

Passionate Learner!