Framework Architecture — The Five Layers of a Production Automation Framework

The difference between “I have Selenium tests” and “I have a test automation framework” is architecture. A framework is a layered system where each layer has a single responsibility: configuration management, browser lifecycle, page interactions, test logic, and reporting. When you change a URL, you edit the config layer — not 200 test files. When you add a new browser, you edit the driver layer — not the page objects. This separation of concerns is what makes a framework maintainable at scale.

The Five-Layer Framework Architecture

Every production-grade Selenium framework — regardless of language, tool, or team size — follows the same five-layer structure. Each layer depends only on the layer below it, never on the layers above it.

# The Five-Layer Framework Architecture

FRAMEWORK_LAYERS = [
    {
        "layer": "Layer 1 — Configuration",
        "responsibility": "Environment URLs, credentials, browser settings, timeouts",
        "files": ["config/settings.py", ".env", "config/browsers.py"],
        "depends_on": "Nothing — this is the foundation",
        "changes_when": "Environment changes (new staging URL, timeout adjustment)",
        "example": (
            "class Settings:\n"
            "    BASE_URL = os.getenv('BASE_URL', 'https://staging.app.com')\n"
            "    BROWSER  = os.getenv('BROWSER', 'chrome')\n"
            "    HEADLESS = os.getenv('HEADLESS', 'true') == 'true'\n"
            "    TIMEOUT  = int(os.getenv('TIMEOUT', '10'))"
        ),
    },
    {
        "layer": "Layer 2 — Driver Management",
        "responsibility": "Browser lifecycle: create, configure, and destroy WebDriver instances",
        "files": ["conftest.py", "utils/driver_factory.py"],
        "depends_on": "Layer 1 (Configuration)",
        "changes_when": "New browser support, Grid integration, cloud provider switch",
        "example": (
            "@pytest.fixture(scope='function')\n"
            "def browser():\n"
            "    driver = DriverFactory.create(Settings.BROWSER, Settings.HEADLESS)\n"
            "    yield driver\n"
            "    driver.quit()"
        ),
    },
    {
        "layer": "Layer 3 — Page Objects",
        "responsibility": "Page locators and user actions — the application model",
        "files": ["pages/base_page.py", "pages/login_page.py", "pages/cart_page.py"],
        "depends_on": "Layer 2 (Driver — receives driver instance)",
        "changes_when": "UI changes (locator updates, new page features)",
        "example": (
            "class LoginPage(BasePage):\n"
            "    USERNAME = (By.ID, 'user-name')\n"
            "    def login(self, user, pwd):\n"
            "        self.type_text(self.USERNAME, user)\n"
            "        ..."
        ),
    },
    {
        "layer": "Layer 4 — Test Cases",
        "responsibility": "Business logic assertions — WHAT to verify",
        "files": ["tests/test_login.py", "tests/test_checkout.py"],
        "depends_on": "Layer 3 (Page Objects) + Layer 1 (test data)",
        "changes_when": "Business rules change, new features added",
        "example": (
            "def test_valid_login(browser):\n"
            "    inventory = LoginPage(browser).open().login('user', 'pass')\n"
            "    assert inventory.get_product_count() == 6"
        ),
    },
    {
        "layer": "Layer 5 — Reporting & Utilities",
        "responsibility": "Test reports, logging, screenshots, test data generation",
        "files": ["utils/reporter.py", "utils/helpers.py", "utils/data_generator.py"],
        "depends_on": "Layers 1-4 (cross-cutting concern)",
        "changes_when": "New reporting tool, additional diagnostics needed",
        "example": (
            "def on_test_failure(driver, test_name):\n"
            "    driver.save_screenshot(f'reports/{test_name}.png')\n"
            "    log.error(f'FAIL: {test_name}')"
        ),
    },
]

# Complete project structure
PROJECT_MAP = """
selenium-framework/
  config/
    settings.py          # Layer 1: env vars, URLs, timeouts
    browsers.py          # Layer 1: browser-specific options
  pages/
    base_page.py         # Layer 3: shared methods (click, type, wait)
    login_page.py        # Layer 3: login locators + actions
    inventory_page.py    # Layer 3: product page locators + actions
    cart_page.py         # Layer 3: cart locators + actions
  tests/
    conftest.py          # Layer 2: fixtures (browser, base_url)
    test_login.py        # Layer 4: login test cases
    test_cart.py         # Layer 4: cart test cases
    test_checkout.py     # Layer 4: checkout test cases
  utils/
    driver_factory.py    # Layer 2: create drivers (local/Grid/cloud)
    reporter.py          # Layer 5: screenshot + log on failure
    data_generator.py    # Layer 5: random test data
    helpers.py           # Layer 5: scroll, retry, file utilities
  test_data/
    users.json           # Test data files
    products.csv         # Test data files
  reports/               # Generated (in .gitignore)
  .env                   # Secrets (in .gitignore)
  pytest.ini             # pytest config
  requirements.txt       # Dependencies
  Dockerfile             # CI container
  docker-compose.yml     # Grid + tests
"""

print("Five-Layer Framework Architecture")
print("=" * 65)
for layer in FRAMEWORK_LAYERS:
    print(f"\n  {layer['layer']}")
    print(f"    Responsibility: {layer['responsibility']}")
    print(f"    Files: {', '.join(layer['files'])}")
    print(f"    Changes when: {layer['changes_when']}")

print(f"\n\nProject Structure:\n{PROJECT_MAP}")
Note: The five-layer architecture enforces a critical dependency rule: upper layers depend on lower layers, never the reverse. Tests (Layer 4) call page object methods (Layer 3). Page objects receive a driver (Layer 2). The driver is configured from settings (Layer 1). Reporting (Layer 5) is a cross-cutting concern that hooks into other layers. If a test file imports a configuration setting directly, that is acceptable. If a page object imports a test function, that violates the dependency rule and creates a circular dependency that makes the framework brittle.
Tip: Use a DriverFactory class in Layer 2 to centralise all driver creation logic. The factory reads the browser name from configuration and returns the appropriate WebDriver — Chrome, Firefox, Edge, Remote (Grid), or cloud provider. When your team adds Safari support or switches from BrowserStack to LambdaTest, you edit the factory only. No test or page object changes. This factory pattern is the single most effective way to make your framework portable across environments.
Warning: Avoid building framework features you do not need yet. Start with the five layers and the simplest implementation for each. Add data-driven testing when you have tests that need multiple data sets. Add retry logic when you have genuine flaky scenarios. Add Docker and CI/CD when you are ready to deploy. Over-engineering at the start delays your first test and creates complexity that nobody has tests to justify. Build the framework alongside the tests, not ahead of them.

Common Mistakes

Mistake 1 — Putting driver creation logic inside page objects

❌ Wrong: LoginPage.__init__ creates a webdriver.Chrome() — the page object is now tied to Chrome and cannot be used with Grid or other browsers.

✅ Correct: The driver is created in a fixture (Layer 2) and passed to the page object constructor. The page object does not know or care how the driver was created.

Mistake 2 — Mixing configuration values across multiple layers

❌ Wrong: Base URL hardcoded in page objects, timeout hardcoded in tests, browser name hardcoded in conftest.py — three places to update for every environment.

✅ Correct: All configuration in config/settings.py loaded from environment variables. Every other layer reads from Settings. One source of truth.

🧠 Test Yourself

In the five-layer framework architecture, which layer should contain the logic for choosing between local Chrome, Selenium Grid, and BrowserStack?