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);
},
});