SOLID Principles Applied to Test Automation — Writing Maintainable Frameworks

Test code is code. It deserves the same engineering discipline as production code — because when test code is poorly structured, tests become unmaintainable, fragile, and eventually abandoned. The SOLID principles, originally defined for object-oriented software design, apply directly to test automation frameworks. Each principle addresses a specific maintenance problem that every growing test suite eventually encounters.

SOLID Principles for Test Automation

Each principle is illustrated with a test automation anti-pattern (violating the principle) and the correct pattern (applying it).

# SOLID principles applied to test automation

SOLID = [
    {
        "principle": "S — Single Responsibility Principle",
        "definition": "A class should have only one reason to change",
        "anti_pattern": (
            "LoginPage class that contains locators, actions, assertions, "
            "AND test data — changes to any of these four concerns require "
            "modifying the same class"
        ),
        "correct_pattern": (
            "LoginPage has locators + actions only. Assertions live in tests. "
            "Test data lives in fixtures. Configuration lives in settings. "
            "Each concern changes independently."
        ),
        "framework_example": "BasePage (interactions) | PageObjects (locators+actions) | Tests (assertions) | Fixtures (data)",
    },
    {
        "principle": "O — Open/Closed Principle",
        "definition": "Open for extension, closed for modification",
        "anti_pattern": (
            "Adding a new browser requires modifying DriverFactory's if/elif chain "
            "and every test that references browser-specific logic"
        ),
        "correct_pattern": (
            "DriverFactory uses a registry/dictionary pattern. Adding a new browser "
            "means registering a new entry — no existing code is modified. "
            "driver_map['safari'] = create_safari_driver"
        ),
        "framework_example": "DriverFactory with pluggable browser configurations",
    },
    {
        "principle": "L — Liskov Substitution Principle",
        "definition": "Subtypes must be substitutable for their base types",
        "anti_pattern": (
            "CheckoutPage inherits from BasePage but overrides click() to also "
            "take a screenshot — code expecting BasePage.click() gets unexpected "
            "side effects"
        ),
        "correct_pattern": (
            "CheckoutPage.click() behaves identically to BasePage.click(). "
            "Screenshot logic is in a separate decorator or hook, not in the override."
        ),
        "framework_example": "All PageObjects are interchangeable through BasePage interface",
    },
    {
        "principle": "I — Interface Segregation Principle",
        "definition": "Clients should not depend on interfaces they do not use",
        "anti_pattern": (
            "BasePage has 30 methods including mobile-specific methods (swipe, pinch). "
            "Desktop PageObjects inherit all 30 but only use 15."
        ),
        "correct_pattern": (
            "BasePage has core methods (click, type, get_text). MobileBasePage "
            "adds mobile methods (swipe, pinch). Desktop pages inherit BasePage; "
            "mobile pages inherit MobileBasePage."
        ),
        "framework_example": "Separate BasePage and MobileBasePage hierarchies",
    },
    {
        "principle": "D — Dependency Inversion Principle",
        "definition": "Depend on abstractions, not concrete implementations",
        "anti_pattern": (
            "Tests directly instantiate webdriver.Chrome() — tied to Chrome, "
            "cannot switch to Firefox or Grid without modifying every test"
        ),
        "correct_pattern": (
            "Tests receive a 'driver' from a fixture/factory. The fixture decides "
            "which browser to create based on configuration. Tests depend on the "
            "WebDriver abstraction, not a specific browser class."
        ),
        "framework_example": "DriverFactory + pytest fixtures inject the driver; tests never import webdriver",
    },
]

for s in SOLID:
    print(f"\n{'='*65}")
    print(f"  {s['principle']}")
    print(f"{'='*65}")
    print(f"  Definition: {s['definition']}")
    print(f"  Anti-pattern: {s['anti_pattern']}")
    print(f"  Correct: {s['correct_pattern']}")
    print(f"  Example: {s['framework_example']}")
Note: The Single Responsibility Principle is the most frequently violated SOLID principle in test automation. Page objects that contain assertions, test data, and configuration alongside locators have four reasons to change — any modification risks breaking the class. The fix is strict layer separation: page objects handle locators and actions, tests handle assertions, fixtures handle data, and configuration handles environment settings. This separation is the foundation of the five-layer framework architecture from Chapter 42.
Tip: The Dependency Inversion Principle — depending on abstractions, not concrete implementations — is what makes your framework portable. When tests depend on a driver fixture instead of directly calling webdriver.Chrome(), switching from local Chrome to Selenium Grid to BrowserStack requires changing the fixture only. Every test, page object, and utility continues to work unchanged. This inversion is the single most valuable architectural decision in a test framework.
Warning: Applying SOLID principles aggressively to a small framework (5 page objects, 20 tests) creates unnecessary complexity. SOLID is most valuable when applied to frameworks with 50+ page objects and 200+ tests — where the maintenance burden justifies the architectural investment. Start simple, and refactor toward SOLID as the framework grows and specific pain points emerge. Over-engineering at the start delays your first test and confuses new team members.

Common Mistakes

Mistake 1 — Putting assertions in page objects (violates SRP)

❌ Wrong: LoginPage.verify_login_success() — the page object both performs actions AND verifies outcomes.

✅ Correct: LoginPage.login() returns DashboardPage. The test asserts: assert dashboard.get_username() == "Alice". Action and verification are separate responsibilities.

Mistake 2 — Tests creating their own WebDriver instances (violates DIP)

❌ Wrong: driver = webdriver.Chrome() at the start of every test — 200 tests directly depend on Chrome.

✅ Correct: def test_login(browser): — the browser fixture injects the driver. The test depends on the abstraction (a WebDriver instance), not the concrete implementation (Chrome).

🧠 Test Yourself

Which SOLID principle does the DriverFactory pattern (from Chapter 43) primarily implement?