SOLID Principles Applied to Test Frameworks

9 min read

SOLID is a set of five object-oriented design principles that originated in software engineering. Most QA engineers encounter them in interview prep and promptly forget them because the definitions feel abstract. "A class should have only one reason to change" — what does that mean for a page object? This lesson translates each principle directly into test framework decisions you make every week. No theory. No abstract examples. Each principle maps to a specific problem that large test suites develop, and a specific fix.

S — Single Responsibility Principle

One class, one reason to change.

The most common violation in test frameworks: a BasePage class that manages the WebDriver instance, provides shared page methods, reads configuration, initialises the logger, AND captures screenshots on failure. That class changes when any one of those five responsibilities changes.

Good single-responsibility design:

// LoginPage — one job: model the login page interactions
public class LoginPage extends BasePage {
    private final By emailInput = By.id("email");
    private final By passwordInput = By.id("password");
    private final By submitButton = By.cssSelector("[data-testid='submit']");
 
    public LoginPage(WebDriver driver) {
        super(driver);
    }
 
    public void loginAs(String email, String password) {
        find(emailInput).sendKeys(email);
        find(passwordInput).sendKeys(password);
        find(submitButton).click();
    }
}
 
// ScreenshotHelper — one job: capture screenshots
public class ScreenshotHelper {
    public static void captureOnFailure(WebDriver driver, String testName) {
        TakesScreenshot ts = (TakesScreenshot) driver;
        File src = ts.getScreenshotAs(OutputType.FILE);
        // save to reports folder...
    }
}

LoginPage changes when the login form changes. ScreenshotHelper changes when the screenshot strategy changes. Neither change touches the other class.

O — Open/Closed Principle

Open for extension, closed for modification.

A WebDriverFactory that determines the browser via a switch statement violates this principle:

// Violates OCP — adding Safari requires modifying this class
public class DriverFactory {
    public static WebDriver create(String browser) {
        switch (browser) {
            case "chrome": return new ChromeDriver();
            case "firefox": return new FirefoxDriver();
            case "edge": return new EdgeDriver();
            // Adding "safari" means editing this switch
            default: throw new IllegalArgumentException("Unknown browser: " + browser);
        }
    }
}

Every new browser is a modification. Modifications risk breaking existing cases. OCP-compliant design:

// OCP-compliant — add a browser by adding a class, not editing the factory
public interface BrowserDriver {
    WebDriver create();
}
 
public class ChromeDriver implements BrowserDriver {
    public WebDriver create() { return new org.openqa.selenium.chrome.ChromeDriver(); }
}
 
public class FirefoxDriver implements BrowserDriver {
    public WebDriver create() { return new org.openqa.selenium.firefox.FirefoxDriver(); }
}
 
// Factory maps strings to implementations — adding Safari = add a class + one map entry
public class DriverFactory {
    private static final Map<String, BrowserDriver> DRIVERS = Map.of(
        "chrome",  new ChromeDriver(),
        "firefox", new FirefoxDriver(),
        "edge",    new EdgeDriver()
    );
 
    public static WebDriver create(String browser) {
        BrowserDriver driver = DRIVERS.get(browser.toLowerCase());
        if (driver == null) throw new IllegalArgumentException("Unknown: " + browser);
        return driver.create();
    }
}

Adding Safari: write SafariDriver implements BrowserDriver, add one line to the map. No existing code changes.

L — Liskov Substitution Principle

Subclasses must be usable wherever the parent class is expected.

In test frameworks, this most often applies to page object inheritance. When you create CheckoutPage extends BasePage, every method you override in CheckoutPage must behave consistently with BasePage's contract. If BasePage defines waitForPageLoad() and CheckoutPage overrides it to do nothing (or to throw an exception in some conditions), tests that call waitForPageLoad() on a page reference get unpredictable behaviour.

// TypeScript — LSP in page objects
abstract class BasePage {
  constructor(protected page: Page) {}
  abstract waitForReady(): Promise<void>;
 
  async getTitle(): Promise<string> {
    return this.page.title();
  }
}
 
// CheckoutPage honours the contract — callers can rely on it
class CheckoutPage extends BasePage {
  async waitForReady(): Promise<void> {
    await this.page.waitForSelector("[data-testid='checkout-form']");
  }
}
 
// DashboardPage also honours the contract
class DashboardPage extends BasePage {
  async waitForReady(): Promise<void> {
    await this.page.waitForSelector("[data-testid='dashboard']");
  }
}
 
// Test can call waitForReady() on any page object — always safe
async function verifyPageLoaded(page: BasePage) {
  await page.waitForReady();
  return page.getTitle();
}

Every BasePage subclass works correctly wherever a BasePage reference is used. No surprises.

I — Interface Segregation Principle

Many specific interfaces beat one large general interface.

A IPage interface with 20 methods forces every page object to implement methods it doesn't use:

// Violates ISP — not every page has a search bar or a cart
public interface IPage {
    void navigateTo();
    void search(String query);       // Not relevant for the checkout page
    void addToCart(String item);     // Not relevant for the login page
    void login(String u, String p);  // Not relevant for the product page
    String getErrorMessage();
    // ...20 more methods
}

ISP-compliant design uses targeted interfaces:

public interface Navigable { void navigateTo(); }
public interface Searchable { List<String> search(String query); }
public interface Authenticatable { void login(String email, String password); }
 
// LoginPage only implements what it actually does
public class LoginPage implements Navigable, Authenticatable {
    public void navigateTo() { driver.get(config.baseUrl() + "/login"); }
    public void login(String email, String password) { /* ... */ }
}
 
// SearchPage only implements what it does
public class SearchPage implements Navigable, Searchable {
    public void navigateTo() { driver.get(config.baseUrl() + "/search"); }
    public List<String> search(String query) { /* ... */ }
}

Tests that only care about navigation accept a Navigable. Tests that care about search accept a Searchable. Adding a Printable interface for pages with a print button doesn't touch Navigable or Authenticatable.

D — Dependency Inversion Principle

Depend on abstractions, not concrete implementations.

The single most impactful place to apply DIP in test frameworks is driver management. Tests that depend on ChromeDriver directly can never switch browsers without code changes:

// Violates DIP — test is locked to Chrome
public class CheckoutTest {
    private ChromeDriver driver;   // ❌ concrete implementation
 
    @BeforeMethod
    public void setUp() {
        driver = new ChromeDriver();
    }
}

DIP-compliant: depend on WebDriver (the abstraction), receive it from a factory:

// DIP-compliant — test depends on the abstraction
public class CheckoutTest extends BaseTest {
    // BaseTest provides WebDriver driver via DriverFactory
    // driver is typed as WebDriver — the interface
 
    @Test
    public void adminCanCheckout() {
        // driver.get, driver.findElement — all WebDriver interface methods
        // Running with Firefox? Change one config value. Zero test code changes.
    }
}

The same principle applies in Python with pytest fixtures — tests receive a page fixture typed to Playwright's Page interface, not a specific browser's implementation. In TypeScript, Playwright's BrowserContext and Page are the abstractions; tests never instantiate browsers directly.

SOLID in Test Frameworks
  • – LoginPage → login UI
  • – ScreenshotHelper → capture
  • – Config → env values
  • – Add browsers via new class
  • – Never edit DriverFactory switch
  • – Extend BasePage safely
  • – All pages honour BasePage contract
  • – waitForReady() consistent everywhere
  • Navigable, Searchable, Authenticatable –
  • No forced empty implementations –
  • Depend on WebDriver not ChromeDriver –
  • Tests receive via factory/fixture –
  • Browser swap = one config change –

Applying SOLID together

A RegistrationPage that applies all five principles:

// S: one job — registration form interaction
// O: extends BasePage, doesn't modify it
// L: honours BasePage contract
// I: implements only Navigable, not all of IPage
// D: depends on WebDriver interface, not ChromeDriver
public class RegistrationPage extends BasePage implements Navigable {
 
    private final By emailInput    = By.id("reg-email");
    private final By passwordInput = By.id("reg-password");
    private final By submitButton  = By.cssSelector("[data-testid='register']");
    private final By successBanner = By.cssSelector(".success-banner");
 
    public RegistrationPage(WebDriver driver) {   // DIP: receives abstraction
        super(driver);
    }
 
    @Override
    public void navigateTo() {                    // ISP: only what this page does
        driver.get(Config.baseUrl() + "/register");
    }
 
    public void register(String email, String password) {  // SRP: interaction only
        find(emailInput).sendKeys(email);
        find(passwordInput).sendKeys(password);
        find(submitButton).click();
    }
 
    public boolean isSuccessBannerDisplayed() {
        return !driver.findElements(successBanner).isEmpty();
    }
}

No assertions. No hardcoded data. Depends on the WebDriver interface. Only implements Navigable. Adding a new registration method doesn't require changing BasePage. A browser swap requires zero changes to this class.

⚠️ Common mistakes

  • Applying SRP too aggressively. A class with one 3-line method and nothing else isn't single-responsibility — it's premature decomposition. SRP means "one reason to change at a useful granularity," not "one line of code per class."
  • Extending BasePage for framework utility classes. DriverFactory, Config, and ScreenshotHelper are not pages — they shouldn't extend BasePage. Inheritance should model an "is-a" relationship. A factory is not a page.
  • Applying DIP to everything including simple data classes. A User record with fields email and password doesn't need an IUser interface. DIP targets the parts of the system that need to change independently — typically driver and external service dependencies.

🎯 Practice task

SOLID audit of your framework — 40 minutes.

  1. SRP check. Open BasePage (or whatever your base class is). List every method. Group them by concern: driver management, element location, config reading, logging, screenshot capture. If you have more than 2 groups, BasePage has too many responsibilities. Identify which group to extract first.
  2. OCP check. Find your browser driver creation code. Is it a switch statement or if/else chain? Sketch the refactor to an interface + implementations. You don't have to implement it now — just the class diagram.
  3. DIP check. Search for new ChromeDriver() or new FirefoxDriver() in test classes. Each one is a DIP violation. How many did you find?
  4. Implement one fix. Choose the most impactful violation you found — probably the SRP or DIP one — and implement the fix. Run the tests; they should pass after the refactor.
  5. Stretch — ISP design exercise. Your page objects probably implement no interfaces at all. Design a small interface hierarchy for your app: which pages are Navigable? Which are Searchable? Which expose a HeaderComponent? Write the interfaces (no implementation required). The exercise forces you to think about what each page's contract actually is.

Next lesson: DRY, KISS, and YAGNI — the three principles that keep frameworks from being over-engineered in the opposite direction.

// tip to track lessons you complete and pick up where you left off across devices.