Skip to main content
Back to Blog
Guide
2026-05-18

Hybrid Test Automation Framework: Complete Architecture and Implementation

Complete guide to building a hybrid test automation framework combining data-driven, keyword-driven, and Page Object Model patterns. Covers architecture layers, Playwright and TestNG implementation, Excel integration, and parallel execution.

A hybrid test automation framework combines the strengths of multiple framework patterns -- data-driven, keyword-driven, and Page Object Model -- into a single cohesive architecture. Instead of choosing one approach and accepting its limitations, a hybrid framework lets you use data-driven testing for parameterized scenarios, keyword-driven testing for non-technical test creation, and the Page Object Model for maintainable browser automation. The result is a framework that is flexible enough to handle any test type, accessible to both technical and non-technical team members, and maintainable as your test suite grows.

This guide walks you through the complete architecture and implementation of a hybrid framework. You will learn what a hybrid framework is and why it exists, understand each architectural layer from configuration through reporting, implement it with Playwright and TestNG, integrate Excel-based test data, build custom annotations for test metadata, configure parallel execution, and see how the pieces fit together in a production environment.


Key Takeaways

  • A hybrid framework combines data-driven, keyword-driven, and Page Object Model patterns into a unified architecture that leverages the strengths of each approach
  • The architecture has six layers: configuration, test data, page objects, keywords/actions, test execution, and utility/reporting -- each with a single responsibility
  • Data-driven testing separates test data from test logic, enabling the same test to run with hundreds of data combinations from Excel, CSV, JSON, or database sources
  • Keyword-driven testing maps natural-language keywords to reusable action functions, allowing non-technical team members to create tests by specifying sequences of keywords
  • Page Object Model encapsulates page elements and interactions in dedicated classes, so when the UI changes you update one class instead of dozens of tests
  • Excel integration with Apache POI (Java) or xlsx libraries (TypeScript) lets QA teams manage test data in familiar spreadsheet tools
  • Parallel execution using TestNG suites or Playwright sharding distributes tests across threads or machines, reducing execution time proportionally
  • Custom annotations and metadata tag tests with priority, module, test type, and environment information for flexible filtering and reporting

What Is a Hybrid Framework?

A hybrid test automation framework is an architecture that combines two or more framework patterns. The most common combination is:

  1. Data-Driven Framework -- test data is externalized in spreadsheets, JSON files, CSV files, or databases, and the framework reads this data at runtime to parameterize test execution
  2. Keyword-Driven Framework -- test actions are defined as keywords (e.g., "navigate," "click," "enterText," "verifyText"), and tests are specified as sequences of keywords with parameters
  3. Page Object Model (POM) -- each page or component of the application under test is represented by a class containing element locators and interaction methods

Each pattern has its own strengths:

  • Data-driven eliminates test duplication when you need to verify the same workflow with different inputs
  • Keyword-driven makes tests accessible to non-technical team members and enables test creation without code
  • POM makes tests maintainable by isolating UI changes to a single class per page

By combining all three, the hybrid framework handles any testing scenario your team encounters.

Why Not Just Pick One?

Pure data-driven frameworks become unwieldy when you need complex interaction sequences -- your data files end up encoding logic through special columns and flags.

Pure keyword-driven frameworks struggle with complex assertions and conditional logic -- the keyword abstraction becomes leaky when you need programmatic control.

Pure POM frameworks lack a clean mechanism for test data parameterization and are not accessible to non-technical team members.

The hybrid approach avoids these limitations by using each pattern where it excels.


Architecture Layers

A production-grade hybrid framework is organized into six layers, each with a distinct responsibility.

Layer 1: Configuration Layer

The configuration layer manages all external settings -- environment URLs, browser preferences, timeouts, database connections, and feature flags.

config/
  config.properties       # Default configuration
  config.dev.properties   # Dev environment overrides
  config.staging.properties # Staging environment overrides
  config.prod.properties  # Production environment overrides
  browsers.json           # Browser capability definitions

Example config.properties:

# Application
base.url=https://staging.example.com
api.base.url=https://api.staging.example.com

# Browser
browser=chrome
headless=true
window.width=1920
window.height=1080

# Timeouts (milliseconds)
implicit.wait=10000
explicit.wait=15000
page.load.timeout=30000

# Test Data
test.data.source=excel
test.data.path=src/test/resources/testdata

# Reporting
report.output.path=target/reports
screenshot.on.failure=true
video.recording=false

# Parallel Execution
parallel.threads=4
parallel.mode=methods

Configuration reader class:

public class ConfigManager {
    private static final Properties properties = new Properties();
    private static String environment;

    static {
        environment = System.getProperty("env", "staging");
        loadProperties("config/config.properties");
        loadProperties("config/config." + environment + ".properties");
    }

    private static void loadProperties(String path) {
        try (InputStream input = ConfigManager.class
                .getClassLoader().getResourceAsStream(path)) {
            if (input != null) {
                properties.load(input);
            }
        } catch (IOException e) {
            throw new RuntimeException("Failed to load config: " + path, e);
        }
    }

    public static String get(String key) {
        String envVar = System.getenv(key.toUpperCase().replace('.', '_'));
        if (envVar != null) return envVar;
        return properties.getProperty(key);
    }

    public static int getInt(String key) {
        return Integer.parseInt(get(key));
    }

    public static boolean getBoolean(String key) {
        return Boolean.parseBoolean(get(key));
    }
}

This configuration reader supports:

  • Default values from properties files
  • Environment-specific overrides
  • System property overrides (-Dbase.url=...)
  • Environment variable overrides

Layer 2: Test Data Layer

The test data layer manages all external data sources -- Excel files, CSV, JSON, and database connections.

testdata/
  login/
    valid-credentials.xlsx
    invalid-credentials.xlsx
  checkout/
    payment-methods.xlsx
    shipping-addresses.json
  users/
    test-users.csv

Excel Data Reader (Java with Apache POI)

public class ExcelReader {
    private final Workbook workbook;

    public ExcelReader(String filePath) {
        try (FileInputStream fis = new FileInputStream(filePath)) {
            this.workbook = WorkbookFactory.create(fis);
        } catch (IOException e) {
            throw new RuntimeException("Failed to read Excel file: " + filePath, e);
        }
    }

    public List<Map<String, String>> readSheet(String sheetName) {
        Sheet sheet = workbook.getSheet(sheetName);
        if (sheet == null) {
            throw new RuntimeException("Sheet not found: " + sheetName);
        }

        List<Map<String, String>> data = new ArrayList<>();
        Row headerRow = sheet.getRow(0);
        List<String> headers = new ArrayList<>();

        for (Cell cell : headerRow) {
            headers.add(cell.getStringCellValue().trim());
        }

        for (int i = 1; i <= sheet.getLastRowNum(); i++) {
            Row row = sheet.getRow(i);
            if (row == null) continue;

            Map<String, String> rowData = new LinkedHashMap<>();
            for (int j = 0; j < headers.size(); j++) {
                Cell cell = row.getCell(j);
                rowData.put(headers.get(j), getCellValueAsString(cell));
            }
            data.add(rowData);
        }
        return data;
    }

    private String getCellValueAsString(Cell cell) {
        if (cell == null) return "";
        return switch (cell.getCellType()) {
            case STRING -> cell.getStringCellValue();
            case NUMERIC -> {
                if (DateUtil.isCellDateFormatted(cell)) {
                    yield cell.getDateCellValue().toString();
                }
                yield String.valueOf((long) cell.getNumericCellValue());
            }
            case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
            case FORMULA -> cell.getCellFormula();
            default -> "";
        };
    }

    public Object[][] toDataProvider(String sheetName) {
        List<Map<String, String>> data = readSheet(sheetName);
        Object[][] result = new Object[data.size()][1];
        for (int i = 0; i < data.size(); i++) {
            result[i][0] = data.get(i);
        }
        return result;
    }
}

JSON Data Reader

public class JsonDataReader {
    private static final ObjectMapper mapper = new ObjectMapper();

    public static <T> T read(String filePath, Class<T> type) {
        try {
            return mapper.readValue(
                new File(filePath), type);
        } catch (IOException e) {
            throw new RuntimeException("Failed to read JSON: " + filePath, e);
        }
    }

    public static List<Map<String, Object>> readAsList(String filePath) {
        try {
            return mapper.readValue(
                new File(filePath),
                new TypeReference<>() {});
        } catch (IOException e) {
            throw new RuntimeException("Failed to read JSON list: " + filePath, e);
        }
    }
}

Layer 3: Page Object Layer

The page object layer encapsulates all UI element locators and interaction methods.

pages/
  BasePage.java
  LoginPage.java
  DashboardPage.java
  CheckoutPage.java
  components/
    NavigationBar.java
    SearchWidget.java
    CartSidebar.java

Base Page

public abstract class BasePage {
    protected final Page page;
    protected final ConfigManager config;

    public BasePage(Page page) {
        this.page = page;
        this.config = ConfigManager.getInstance();
    }

    protected void click(String selector) {
        page.locator(selector).click();
    }

    protected void fill(String selector, String text) {
        page.locator(selector).fill(text);
    }

    protected String getText(String selector) {
        return page.locator(selector).textContent();
    }

    protected boolean isVisible(String selector) {
        return page.locator(selector).isVisible();
    }

    protected void waitForUrl(String urlPattern) {
        page.waitForURL(urlPattern);
    }

    protected void selectOption(String selector, String value) {
        page.locator(selector).selectOption(value);
    }

    protected void uploadFile(String selector, String filePath) {
        page.locator(selector).setInputFiles(Paths.get(filePath));
    }

    public void takeScreenshot(String name) {
        page.screenshot(new Page.ScreenshotOptions()
            .setPath(Paths.get("target/screenshots/" + name + ".png"))
            .setFullPage(true));
    }
}

Concrete Page Object

public class LoginPage extends BasePage {
    // Locators
    private static final String EMAIL_INPUT = "#email";
    private static final String PASSWORD_INPUT = "#password";
    private static final String LOGIN_BUTTON = "#login-btn";
    private static final String ERROR_MESSAGE = ".error-message";
    private static final String REMEMBER_ME = "#remember-me";

    public LoginPage(Page page) {
        super(page);
    }

    public LoginPage navigate() {
        page.navigate(config.get("base.url") + "/login");
        return this;
    }

    public LoginPage enterEmail(String email) {
        fill(EMAIL_INPUT, email);
        return this;
    }

    public LoginPage enterPassword(String password) {
        fill(PASSWORD_INPUT, password);
        return this;
    }

    public LoginPage checkRememberMe() {
        click(REMEMBER_ME);
        return this;
    }

    public DashboardPage clickLogin() {
        click(LOGIN_BUTTON);
        return new DashboardPage(page);
    }

    public LoginPage clickLoginExpectingError() {
        click(LOGIN_BUTTON);
        return this;
    }

    public String getErrorMessage() {
        return getText(ERROR_MESSAGE);
    }

    public boolean isErrorDisplayed() {
        return isVisible(ERROR_MESSAGE);
    }

    // Fluent composite method
    public DashboardPage loginAs(String email, String password) {
        return navigate()
            .enterEmail(email)
            .enterPassword(password)
            .clickLogin();
    }
}

Layer 4: Keyword/Action Layer

The keyword layer maps natural-language actions to page object methods. This is what makes the framework accessible to non-technical team members.

public class KeywordEngine {
    private final Page page;
    private final Map<String, BasePage> pageRegistry = new HashMap<>();

    public KeywordEngine(Page page) {
        this.page = page;
        registerPages();
    }

    private void registerPages() {
        pageRegistry.put("LoginPage", new LoginPage(page));
        pageRegistry.put("DashboardPage", new DashboardPage(page));
        pageRegistry.put("CheckoutPage", new CheckoutPage(page));
    }

    public void executeKeyword(String pageName, String action, String... params) {
        BasePage pageObject = pageRegistry.get(pageName);
        if (pageObject == null) {
            throw new RuntimeException("Page not registered: " + pageName);
        }

        try {
            Method method;
            if (params.length > 0) {
                Class<?>[] paramTypes = new Class[params.length];
                Arrays.fill(paramTypes, String.class);
                method = pageObject.getClass().getMethod(action, paramTypes);
                method.invoke(pageObject, (Object[]) params);
            } else {
                method = pageObject.getClass().getMethod(action);
                method.invoke(pageObject);
            }
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(
                "Action not found: " + pageName + "." + action, e);
        } catch (InvocationTargetException | IllegalAccessException e) {
            throw new RuntimeException(
                "Failed to execute: " + pageName + "." + action, e);
        }
    }

    public void executeFromExcel(String testCaseSheet) {
        ExcelReader reader = new ExcelReader(
            ConfigManager.get("test.data.path") + "/keywords.xlsx");
        List<Map<String, String>> steps = reader.readSheet(testCaseSheet);

        for (Map<String, String> step : steps) {
            String pageName = step.get("Page");
            String action = step.get("Action");
            String param1 = step.getOrDefault("Param1", "");
            String param2 = step.getOrDefault("Param2", "");

            List<String> params = new ArrayList<>();
            if (!param1.isEmpty()) params.add(param1);
            if (!param2.isEmpty()) params.add(param2);

            executeKeyword(pageName, action, params.toArray(new String[0]));
        }
    }
}

The Excel sheet for keyword-driven tests looks like:

PageActionParam1Param2
LoginPagenavigate
LoginPageenterEmailalice@example.com
LoginPageenterPasswordSecurePass123!
LoginPageclickLogin
DashboardPageverifyWelcomeMessageWelcome, Alice

Non-technical team members can add new test cases by adding rows to the spreadsheet without writing any code.

Layer 5: Test Execution Layer

The test execution layer contains the actual test classes that orchestrate page objects, data providers, and keywords.

public class BaseTest {
    protected static Playwright playwright;
    protected static Browser browser;
    protected Page page;
    protected BrowserContext context;

    @BeforeSuite
    public void globalSetup() {
        playwright = Playwright.create();
        BrowserType.LaunchOptions options = new BrowserType.LaunchOptions()
            .setHeadless(ConfigManager.getBoolean("headless"));

        String browserName = ConfigManager.get("browser");
        browser = switch (browserName) {
            case "firefox" -> playwright.firefox().launch(options);
            case "webkit" -> playwright.webkit().launch(options);
            default -> playwright.chromium().launch(options);
        };
    }

    @BeforeMethod
    public void setUp() {
        context = browser.newContext(new Browser.NewContextOptions()
            .setViewportSize(
                ConfigManager.getInt("window.width"),
                ConfigManager.getInt("window.height")));
        page = context.newPage();
    }

    @AfterMethod
    public void tearDown(ITestResult result) {
        if (result.getStatus() == ITestResult.FAILURE) {
            takeScreenshotOnFailure(result.getName());
        }
        context.close();
    }

    @AfterSuite
    public void globalTeardown() {
        browser.close();
        playwright.close();
    }

    private void takeScreenshotOnFailure(String testName) {
        if (ConfigManager.getBoolean("screenshot.on.failure")) {
            page.screenshot(new Page.ScreenshotOptions()
                .setPath(Paths.get("target/screenshots/" + testName + ".png"))
                .setFullPage(true));
        }
    }
}

Data-Driven Test Example

public class LoginTest extends BaseTest {

    @DataProvider(name = "validCredentials")
    public Object[][] validCredentials() {
        ExcelReader reader = new ExcelReader("src/test/resources/testdata/login/valid-credentials.xlsx");
        return reader.toDataProvider("ValidLogins");
    }

    @DataProvider(name = "invalidCredentials")
    public Object[][] invalidCredentials() {
        ExcelReader reader = new ExcelReader("src/test/resources/testdata/login/invalid-credentials.xlsx");
        return reader.toDataProvider("InvalidLogins");
    }

    @Test(dataProvider = "validCredentials",
          groups = {"smoke", "login"},
          description = "Verify login with valid credentials")
    public void testValidLogin(Map<String, String> data) {
        LoginPage loginPage = new LoginPage(page);
        DashboardPage dashboard = loginPage.loginAs(
            data.get("email"), data.get("password"));

        assertThat(dashboard.getWelcomeMessage())
            .contains(data.get("expectedName"));
    }

    @Test(dataProvider = "invalidCredentials",
          groups = {"regression", "login"},
          description = "Verify error messages for invalid credentials")
    public void testInvalidLogin(Map<String, String> data) {
        LoginPage loginPage = new LoginPage(page);
        loginPage.navigate()
            .enterEmail(data.get("email"))
            .enterPassword(data.get("password"))
            .clickLoginExpectingError();

        assertThat(loginPage.getErrorMessage())
            .isEqualTo(data.get("expectedError"));
    }
}

Keyword-Driven Test Example

public class KeywordDrivenTest extends BaseTest {

    @Test(groups = {"smoke"},
          description = "Execute login flow from keyword spreadsheet")
    public void testLoginViaKeywords() {
        KeywordEngine engine = new KeywordEngine(page);
        engine.executeFromExcel("LoginFlow");
    }

    @Test(groups = {"regression"},
          description = "Execute checkout flow from keyword spreadsheet")
    public void testCheckoutViaKeywords() {
        KeywordEngine engine = new KeywordEngine(page);
        engine.executeFromExcel("CheckoutFlow");
    }
}

Layer 6: Utility and Reporting Layer

utils/
  WaitUtils.java
  DateUtils.java
  RandomDataGenerator.java
  RetryAnalyzer.java
reporting/
  AllureReportManager.java
  ExtentReportManager.java
  TestListener.java

Retry Analyzer

public class RetryAnalyzer implements IRetryAnalyzer {
    private int retryCount = 0;
    private static final int MAX_RETRIES = 2;

    @Override
    public boolean retry(ITestResult result) {
        if (retryCount < MAX_RETRIES) {
            retryCount++;
            return true;
        }
        return false;
    }
}

Test Listener for Reporting

public class TestListener implements ITestListener {

    @Override
    public void onTestStart(ITestResult result) {
        Allure.step("Starting test: " + result.getName());
    }

    @Override
    public void onTestSuccess(ITestResult result) {
        Allure.step("Test passed: " + result.getName());
    }

    @Override
    public void onTestFailure(ITestResult result) {
        Allure.step("Test failed: " + result.getName());
        Allure.addAttachment("Failure Screenshot",
            new ByteArrayInputStream(captureScreenshot(result)));
        Allure.addAttachment("Page Source",
            getPageSource(result));
    }

    private byte[] captureScreenshot(ITestResult result) {
        Page page = ((BaseTest) result.getInstance()).page;
        return page.screenshot(new Page.ScreenshotOptions().setFullPage(true));
    }

    private String getPageSource(ITestResult result) {
        Page page = ((BaseTest) result.getInstance()).page;
        return page.content();
    }
}

Complete Project Structure

hybrid-framework/
  src/
    main/
      java/
        com/example/
          config/
            ConfigManager.java
          data/
            ExcelReader.java
            JsonDataReader.java
            CsvReader.java
          pages/
            BasePage.java
            LoginPage.java
            DashboardPage.java
            CheckoutPage.java
            components/
              NavigationBar.java
              SearchWidget.java
          keywords/
            KeywordEngine.java
          utils/
            WaitUtils.java
            DateUtils.java
            RandomDataGenerator.java
    test/
      java/
        com/example/
          tests/
            BaseTest.java
            LoginTest.java
            CheckoutTest.java
            KeywordDrivenTest.java
          listeners/
            TestListener.java
            RetryAnalyzer.java
      resources/
        config/
          config.properties
          config.staging.properties
        testdata/
          login/
            valid-credentials.xlsx
            invalid-credentials.xlsx
          checkout/
            payment-methods.xlsx
          keywords/
            keywords.xlsx
        testng/
          smoke-suite.xml
          regression-suite.xml
          parallel-suite.xml
  pom.xml
  README.md

Parallel Execution Configuration

TestNG Parallel Suite

<!-- parallel-suite.xml -->
<suite name="Parallel Suite" parallel="methods" thread-count="4">
    <listeners>
        <listener class-name="com.example.listeners.TestListener"/>
    </listeners>

    <test name="Smoke Tests">
        <groups>
            <run>
                <include name="smoke"/>
            </run>
        </groups>
        <classes>
            <class name="com.example.tests.LoginTest"/>
            <class name="com.example.tests.CheckoutTest"/>
        </classes>
    </test>

    <test name="Regression Tests">
        <groups>
            <run>
                <include name="regression"/>
            </run>
        </groups>
        <classes>
            <class name="com.example.tests.LoginTest"/>
            <class name="com.example.tests.CheckoutTest"/>
            <class name="com.example.tests.KeywordDrivenTest"/>
        </classes>
    </test>
</suite>

Thread Safety

When running in parallel, each thread must have its own browser context. Use ThreadLocal or TestNG's dependency injection:

public class BaseTest {
    private static final ThreadLocal<Page> pageThreadLocal = new ThreadLocal<>();
    private static final ThreadLocal<BrowserContext> contextThreadLocal = new ThreadLocal<>();

    @BeforeMethod
    public void setUp() {
        BrowserContext context = browser.newContext();
        Page page = context.newPage();
        contextThreadLocal.set(context);
        pageThreadLocal.set(page);
    }

    protected Page getPage() {
        return pageThreadLocal.get();
    }

    @AfterMethod
    public void tearDown() {
        contextThreadLocal.get().close();
        contextThreadLocal.remove();
        pageThreadLocal.remove();
    }
}

TypeScript/Playwright Implementation

If your team uses TypeScript, here is how the same hybrid architecture maps to Playwright:

Configuration

// config/config.ts
import * as dotenv from 'dotenv';

dotenv.config({ path: `.env.${process.env.ENV || 'staging'}` });

export const config = {
  baseUrl: process.env.BASE_URL || 'https://staging.example.com',
  headless: process.env.HEADLESS !== 'false',
  timeout: parseInt(process.env.TIMEOUT || '30000'),
  retries: parseInt(process.env.RETRIES || '2'),
  workers: parseInt(process.env.WORKERS || '4'),
};

Page Object

// pages/login.page.ts
import { Page, expect } from '@playwright/test';
import { config } from '../config/config';

export class LoginPage {
  constructor(private page: Page) {}

  async navigate() {
    await this.page.goto(`${config.baseUrl}/login`);
  }

  async login(email: string, password: string) {
    await this.page.fill('#email', email);
    await this.page.fill('#password', password);
    await this.page.click('#login-btn');
  }

  async getErrorMessage(): Promise<string> {
    return await this.page.textContent('.error-message') || '';
  }

  async expectDashboard() {
    await expect(this.page).toHaveURL(/\/dashboard/);
  }
}

Data-Driven Test

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { readExcel } from '../utils/excel-reader';

const validCredentials = readExcel('testdata/login/valid-credentials.xlsx', 'ValidLogins');

for (const data of validCredentials) {
  test(`Login with ${data.email}`, async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.login(data.email, data.password);
    await loginPage.expectDashboard();
  });
}

Playwright Config for Parallel Execution

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { config } from './config/config';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  workers: config.workers,
  retries: config.retries,
  reporter: [
    ['html', { outputFolder: 'reports/html' }],
    ['allure-playwright'],
    ['junit', { outputFile: 'reports/junit.xml' }],
  ],
  use: {
    baseURL: config.baseUrl,
    headless: config.headless,
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    trace: 'retain-on-failure',
  },
  projects: [
    { name: 'chrome', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } },
  ],
});

Custom Annotations for Test Metadata

Custom annotations let you tag tests with metadata for filtering and reporting.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface TestInfo {
    String module();
    String priority() default "medium";
    String[] tags() default {};
    String testType() default "functional";
}

Usage:

@Test
@TestInfo(module = "auth", priority = "critical", tags = {"smoke", "login"}, testType = "e2e")
public void testValidLogin(Map<String, String> data) {
    // test implementation
}

Read annotations in the test listener for reporting:

@Override
public void onTestStart(ITestResult result) {
    TestInfo info = result.getMethod().getConstructorOrMethod()
        .getMethod().getAnnotation(TestInfo.class);
    if (info != null) {
        Allure.label("module", info.module());
        Allure.label("priority", info.priority());
        Allure.label("testType", info.testType());
        for (String tag : info.tags()) {
            Allure.label("tag", tag);
        }
    }
}

Reporting Integration

Allure Report

Add Allure to your Maven project:

<dependency>
    <groupId>io.qameta.allure</groupId>
    <artifactId>allure-testng</artifactId>
    <version>2.27.0</version>
</dependency>

Enhance test methods with Allure annotations:

@Epic("Authentication")
@Feature("Login")
@Story("Valid Login")
@Severity(SeverityLevel.CRITICAL)
@Test(dataProvider = "validCredentials")
public void testValidLogin(Map<String, String> data) {
    Allure.step("Navigate to login page");
    LoginPage loginPage = new LoginPage(page);
    loginPage.navigate();

    Allure.step("Enter credentials and submit");
    DashboardPage dashboard = loginPage.loginAs(
        data.get("email"), data.get("password"));

    Allure.step("Verify dashboard is displayed");
    assertThat(dashboard.getWelcomeMessage())
        .contains(data.get("expectedName"));
}

Generate the report:

mvn test
mvn allure:serve

CI/CD Integration

GitHub Actions

name: Hybrid Framework Tests

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        test-suite: [smoke, regression]
    steps:
      - uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '21'
          cache: 'maven'

      - name: Install Playwright browsers
        run: mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install --with-deps"

      - name: Run tests
        run: mvn test -Dsurefire.suiteXmlFiles=src/test/resources/testng/${{ matrix.test-suite }}-suite.xml -Denv=staging

      - name: Generate Allure report
        if: always()
        run: mvn allure:report

      - name: Upload reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.test-suite }}-report
          path: target/site/allure-maven-plugin/

Best Practices

  1. Keep page objects thin -- page objects should contain locators and simple interaction methods, not test logic or assertions
  2. Use meaningful test data -- test data in Excel should be realistic, not "test123" and "abc@xyz.com"
  3. Version control your test data -- Excel files and JSON test data belong in the repository alongside the test code
  4. Do not over-engineer the keyword layer -- start with direct page object calls and add keyword abstraction only when non-technical team members need it
  5. Use fluent APIs -- return this from page object methods to enable method chaining
  6. Isolate tests -- each test should set up its own state and clean up after itself
  7. Configure timeouts at the framework level -- do not hardcode waits in individual tests
  8. Log meaningful information -- every action should produce a log entry that helps debug failures
  9. Run smoke tests on every commit -- reserve full regression for nightly or pre-release runs
  10. Review the framework architecture quarterly -- as your application evolves, your framework should evolve with it

Conclusion

A hybrid test automation framework gives you the best of all worlds: data-driven flexibility, keyword-driven accessibility, and Page Object Model maintainability. The six-layer architecture -- configuration, test data, page objects, keywords, test execution, and utilities -- provides clean separation of concerns that scales with your test suite.

Start by building the configuration and page object layers. Add data-driven capabilities through Excel readers. Introduce the keyword engine when non-technical team members need to create tests. Configure parallel execution to keep feedback fast. Layer in Allure reporting for visibility into test results.

The framework is not the goal -- test coverage and fast feedback are. Build the minimum framework that lets your team write, run, and maintain tests effectively, and extend it as your needs grow.

Hybrid Test Automation Framework: Complete Architecture and Implementation | QASkills.sh