Skip to content

Building a Test Automation Framework from Scratch

DodaTech Updated 2026-06-22 8 min read

In this tutorial, you'll learn about Building a Test Automation Framework from Scratch. We cover key concepts, practical examples, and best practices.

A test automation framework is a structured set of guidelines, tools, and libraries that makes tests consistent, reusable, and maintainable — turning ad-hoc test scripts into a scalable testing infrastructure.

What You'll Learn

In this tutorial, you'll learn how to build a complete test automation framework from scratch including the Page Object Model, test data management, reporting, parallel execution, and CI/CD integration using Selenium and Playwright.

Why This Matters

Without a framework, each tester writes tests differently. Test scripts duplicate code, break when the UI changes, and produce inconsistent reports. A well-designed framework reduces test maintenance by 70% and lets new team members contribute tests within days instead of weeks. DodaZIP uses a custom automation framework that runs 2,000+ tests in parallel across 10 browser instances, completing a full regression suite in under 8 minutes.

Learning Path

flowchart LR
  A[E2E Testing] --> B[Automation Framework
You are here] B --> C[Page Object Model] B --> D[Test Data Management] C --> E[Parallel Execution] D --> E E --> F[CI/CD Integration] style B fill:#f90,color:#fff

Framework Architecture

A well-structured framework has four layers:

Layer Responsibility Example
Test layer Test logic and assertions Test files
Page objects UI element locators and actions Page classes
Core layer Driver management, utilities Base classes
Config layer Environment, credentials, settings Config files

Step 1: Setting Up the Core Layer

# core/driver_factory.py
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.firefox.options import Options as FirefoxOptions

class DriverFactory:
    @staticmethod
    def create_driver(browser="chrome", headless=False):
        if browser == "chrome":
            options = Options()
            if headless:
                options.add_argument("--headless=new")
            options.add_argument("--window-size=1920,1080")
            options.add_argument("--disable-gpu")
            driver = webdriver.Chrome(options=options)
        elif browser == "firefox":
            options = FirefoxOptions()
            if headless:
                options.add_argument("--headless")
            driver = webdriver.Firefox(options=options)
        else:
            raise ValueError(f"Unsupported browser: {browser}")

        driver.implicitly_wait(10)
        driver.maximize_window()
        return driver
# core/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException

class BasePage:
    def __init__(self, driver):
        self.driver = driver
        self.wait = WebDriverWait(driver, 10)

    def find_element(self, locator):
        return self.wait.until(
            EC.presence_of_element_located(locator)
        )

    def click(self, locator):
        element = self.wait.until(
            EC.element_to_be_clickable(locator)
        )
        element.click()

    def enter_text(self, locator, text):
        element = self.find_element(locator)
        element.clear()
        element.send_keys(text)

    def get_text(self, locator):
        element = self.find_element(locator)
        return element.text

    def wait_for_url_contains(self, text):
        try:
            self.wait.until(EC.url_contains(text))
            return True
        except TimeoutException:
            return False

Step 2: Building Page Objects

# pages/login_page.py
from selenium.webdriver.common.by import By
from core.base_page import BasePage

class LoginPage(BasePage):
    USERNAME_INPUT = (By.ID, "username")
    PASSWORD_INPUT = (By.ID, "password")
    LOGIN_BUTTON = (By.CSS_SELECTOR, "button[type='submit']")
    ERROR_MESSAGE = (By.CLASS_NAME, "error-message")

    def login(self, username, password):
        self.enter_text(self.USERNAME_INPUT, username)
        self.enter_text(self.PASSWORD_INPUT, password)
        self.click(self.LOGIN_BUTTON)

    def get_error_message(self):
        return self.get_text(self.ERROR_MESSAGE)

    def is_login_button_visible(self):
        return self.find_element(self.LOGIN_BUTTON).is_displayed()
# pages/dashboard_page.py
from selenium.webdriver.common.by import By
from core.base_page import BasePage

class DashboardPage(BasePage):
    WELCOME_MESSAGE = (By.CLASS_NAME, "welcome-message")
    USER_AVATAR = (By.CLASS_NAME, "user-avatar")
    LOGOUT_BUTTON = (By.LINK_TEXT, "Logout")

    def get_welcome_text(self):
        return self.get_text(self.WELCOME_MESSAGE)

    def is_user_logged_in(self):
        return self.find_element(self.USER_AVATAR).is_displayed()

    def logout(self):
        self.click(self.LOGOUT_BUTTON)

Step 3: Writing Tests

# tests/test_login.py
import pytest
from core.driver_factory import DriverFactory
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage

@pytest.fixture
def setup():
    driver = DriverFactory.create_driver(browser="chrome", headless=True)
    driver.get("https://example.com/login")
    yield driver
    driver.quit()

def test_valid_login(setup):
    login_page = LoginPage(setup)
    dashboard_page = DashboardPage(setup)

    login_page.login("testuser", "password123")

    assert dashboard_page.is_user_logged_in()
    assert "Welcome" in dashboard_page.get_welcome_text()

def test_invalid_login_shows_error(setup):
    login_page = LoginPage(setup)

    login_page.login("invalid", "wrongpass")

    error_text = login_page.get_error_message()
    assert "Invalid credentials" in error_text

def test_login_page_elements(setup):
    login_page = LoginPage(setup)
    assert login_page.is_login_button_visible()

Expected test output:

test session starts
collecting ... collected 3 items
test_login.py ...                                            [100%]
3 passed in 12.45s

Step 4: Test Data Management

# data/test_data.py
import json
from pathlib import Path

class TestDataLoader:
    def __init__(self):
        self.data_dir = Path(__file__).parent

    def load_test_data(self, filename):
        filepath = self.data_dir / filename
        with open(filepath) as f:
            return json.load(f)

    def get_user_credentials(self, user_type):
        data = self.load_test_data("users.json")
        return data[user_type]

# data/users.json
"""
{
  "valid_user": {
    "username": "testuser",
    "password": "password123",
    "expected_welcome": "Welcome, Test User"
  },
  "invalid_user": {
    "username": "invalid",
    "password": "wrongpass",
    "expected_error": "Invalid credentials"
  },
  "locked_user": {
    "username": "locked_user",
    "password": "password123",
    "expected_error": "Account locked"
  }
}
"""

# Using test data in tests
@pytest.mark.parametrize("user_type", ["valid_user", "invalid_user"])
def test_login_with_data(user_type, setup):
    loader = TestDataLoader()
    credentials = loader.get_user_credentials(user_type)
    login_page = LoginPage(setup)

    login_page.login(credentials["username"], credentials["password"])

    if user_type == "valid_user":
        dashboard = DashboardPage(setup)
        assert credentials["expected_welcome"] in dashboard.get_welcome_text()
    else:
        assert credentials["expected_error"] in login_page.get_error_message()

Expected parametrized test output:

test session starts
collecting ... collected 2 items
test_login.py ..                                             [100%]
2 passed in 8.23s

Step 5: Parallel Execution

# pytest.ini
[pytest]
addopts = -n 4 --html=report.html --self-contained-html

Run tests in parallel across 4 browser instances:

pytest tests/ -n 4 --dist loadscope

Expected parallel execution output:

test session starts
collecting ... collected 12 items
test_login.py ....                                           [ 33%]
test_cart.py ....                                            [ 66%]
test_checkout.py ....                                        [100%]

====== 12 passed in 34.12s ======

Without parallelism, 12 tests take ~3 minutes. With 4 workers, they finish in 34 seconds — an 80% reduction.

Step 6: CI/CD Integration

# .github/workflows/automation-tests.yml
name: Automated Tests
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        browser: [chrome, firefox]
        shard: [1, 2]

    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          playwright install
      - name: Run tests
        run: |
          pytest tests/ \
            --browser=${{ matrix.browser }} \
            --shard=${{ matrix.shard }}/2 \
            --html=report-${{ matrix.browser }}.html
      - name: Upload report
        uses: actions/upload-artifact@v4
        with:
          name: test-report-${{ matrix.browser }}-${{ matrix.shard }}
          path: report-*.html

Framework Best Practices

Practice Why
Page Object Model Isolates UI changes to one file per page
Explicit waits Avoids flaky tests from race conditions
Test data externalization Tests pass with different environments
Parallel execution Reduces feedback time from hours to minutes
Screenshot on failure Debugging without reproducing
Logging Trace test execution step by step

Common Errors

1. No Abstraction Layer

Tests that directly use Selenium or Playwright API calls are hard to maintain. Always wrap browser interactions in page objects or utility methods.

2. Hardcoded Waits

time.sleep(5) is a guarantee of flaky tests and slow execution. Use explicit waits for specific conditions instead.

3. Tests That Depend on Each Other

Each test must be independent. A test that requires another test to run first creates a fragile, impossible-to-parallelize suite.

4. Ignoring Test Reporting

A test that fails with no context is useless. Include screenshots, page source, logs, and stack traces in every failure report.

5. No Environment Configuration

Hardcoded URLs and credentials mean tests can't run across environments. Use config files or environment variables.

Practice Questions

1. What is the Page Object Model? A design pattern where each web page has a corresponding class that encapsulates its elements and actions. Tests interact with page objects instead of directly with HTML.

2. Why should tests be independent of each other? Independent tests can run in parallel, in any order, and can be retried individually. Dependent tests create fragile suites that are hard to debug.

3. What is the difference between implicit and explicit waits? Implicit waits set a global timeout for element discovery. Explicit waits wait for a specific condition (visibility, clickability) on a specific element.

4. How do you run tests in parallel? Use pytest with the -n flag (pytest-xdist) or Playwright's built-in sharding. Distribute tests across CPU cores to reduce total execution time.

5. What should a good test failure report include? Screenshot at the moment of failure, browser console logs, test steps with timestamps, stack trace, environment details, and the test data used.

Challenge: Build a complete automation framework from scratch for a demo web application. Include at least 5 page objects, parameterized tests, test data from a JSON file, parallel execution, and HTML reporting.

Real-World Task: E-Commerce Automation Suite

Build an automation framework for an e-commerce web application with these pages: Login, Product Search, Shopping Cart, and Checkout. Implement:

  1. Page Object Model with base classes
  2. Test data management with JSON fixtures
  3. Parameterized tests for different user types
  4. Parallel execution across 3 browsers
  5. CI/CD pipeline with GitHub Actions
  6. HTML reports with screenshots on failure
  7. Retry logic for flaky tests

Use Selenium or Playwright as the browser automation tool. Integrate with CI/CD for continuous validation on every pull request.

FAQ

Should I use Selenium or Playwright?

Playwright is newer and faster with better auto-waiting and cross-browser support. Selenium has a larger ecosystem and more community support. Both are excellent choices. Playwright is recommended for new projects.

How long does it take to build a framework?

A basic framework with page objects, test data management, and reporting takes 2-3 days for an experienced engineer. A production-grade framework with parallel execution, CI/CD, and advanced reporting takes 1-2 weeks.

How many tests should an automation framework support?

A well-designed framework scales from 10 tests to 10,000 tests. The key is architecture — loosely coupled page objects, independent tests, and efficient parallel execution.

Do I need Docker to run automation tests in CI?

Not required, but Docker provides consistent browser environments across machines. Without Docker, you depend on the CI runner's pre-installed browsers, which can vary.

What is the biggest mistake in building an automation framework?

Over-engineering. Start simple with a few page objects and tests. Add complexity (parallel execution, cloud grid, advanced reporting) only when you need it

What's Next

Tutorial What You'll Learn
Playwright Testing Guide Modern browser automation with Playwright
Selenium Testing Guide Classic browser automation framework
Cypress Testing Guide Frontend testing with Cypress

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro