Building a Test Automation Framework from Scratch
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:
- Page Object Model with base classes
- Test data management with JSON fixtures
- Parameterized tests for different user types
- Parallel execution across 3 browsers
- CI/CD pipeline with GitHub Actions
- HTML reports with screenshots on failure
- 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
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