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.
- – 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
BasePagefor framework utility classes.DriverFactory,Config, andScreenshotHelperare not pages — they shouldn't extendBasePage. Inheritance should model an "is-a" relationship. A factory is not a page. - Applying DIP to everything including simple data classes. A
Userrecord with fieldsemailandpassworddoesn't need anIUserinterface. 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.
- 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,BasePagehas too many responsibilities. Identify which group to extract first. - 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.
- DIP check. Search for
new ChromeDriver()ornew FirefoxDriver()in test classes. Each one is a DIP violation. How many did you find? - 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.
- 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 areSearchable? Which expose aHeaderComponent? 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.