level 04 / solid-principles

SOLID for Automation

The five SOLID principles translated into test framework decisions — with the violations you'll actually meet in real suites.

S — Single Responsibility

One class, one reason to change. The classic violation is the page object that also owns test data, API calls, and database checks:

// ❌ Four responsibilities — four reasons to change
class CheckoutPage {
  async fillCard(/*...*/) {}
  async generateTestUser() {}          // test data
  async verifyOrderInDatabase() {}     // db validation
  async callPaymentApi() {}            // api client
}

// ✅ Each concern in its own unit
class CheckoutPage { async fillCard(/*...*/) {} }
function buildUser(/*...*/) {}
class OrdersDb { async findOrder(/*...*/) {} }
class PaymentsClient { async charge(/*...*/) {} }

O — Open/Closed

Open for extension, closed for modification. Add a payment method without editing the checkout flow:

// New payment types register themselves — checkout() never changes
const strategies: Record<string, PaymentStrategy> = {};
export function registerPayment(name: string, s: PaymentStrategy) {
  strategies[name] = s;
}

export async function checkout(page: Page, method: string) {
  await strategies[method].pay(page);   // closed for modification
}

registerPayment('card', cardPayment);
registerPayment('paypal', paypalPayment);
registerPayment('klarna', klarnaPayment);  // extension, no edits

L — Liskov Substitution

A subtype must honour its parent’s contract. In frameworks this bites with base page classes:

class BasePage {
  // Contract: resolves when the page is ready for interaction
  async waitUntilReady(): Promise<void> {
    await this.page.waitForLoadState('domcontentloaded');
  }
}

// ❌ Violates the contract — "ready" now means something weaker
class FlakyDashboard extends BasePage {
  override async waitUntilReady(): Promise<void> {
    // returns immediately; widgets still loading
  }
}

Every caller of waitUntilReady() now gets different guarantees depending on the concrete page — the bug appears far from its cause. If a subclass can’t honour the contract, the hierarchy is wrong: compose instead.

I — Interface Segregation

Don’t force consumers to depend on methods they don’t use:

// ❌ Fat interface — the smoke test needs goto() but must mock everything
interface FullPage {
  goto(): Promise<void>;
  fillForm(data: FormData): Promise<void>;
  submitAndWait(): Promise<void>;
  exportToPdf(): Promise<Buffer>;
}

// ✅ Segregated — depend on what you use
interface Navigable { goto(): Promise<void> }
interface FormCapable { fillForm(data: FormData): Promise<void> }

function smokeCheck(p: Navigable) { /* only needs goto */ }

D — Dependency Inversion

Depend on abstractions, not concretions. The framework’s flows should not know which environment, browser, or data source they run against:

// Abstraction
interface UserSource {
  getUser(role: string): Promise<User>;
}

// Concretions — swappable without touching flows
class ApiUserSource implements UserSource { /* creates via API */ }
class StaticUserSource implements UserSource { /* reads fixtures file */ }

// The flow depends on the interface; fixtures inject the right one
export const test = base.extend<{ users: UserSource }>({
  users: async ({}, use) => {
    const source = process.env.CI ? new ApiUserSource() : new StaticUserSource();
    await use(source);
  },
});
Key insight: SOLID in one test-framework sentence: small single-purpose units (S), extended via registration not modification (O), with subtypes that keep promises (L), narrow interfaces (I), wired through fixtures against abstractions (D).

Try it: spotting the violation

Interview questions