Adlerqa

A well-designed automation framework isn’t just “tests that run”—it’s a system that scales, stays maintainable, and remains stable under constant product change. These patterns help you achieve that.

Page Object Model (POM)

In simple words: Represent each page or component as a class that owns its locators and actions. Tests call intent-level methods (e.g., loginAs) instead of manipulating selectors.

When to use:

  • You need to separate test logic from UI details.
  • Frequent UI changes; cross-browser support.
  • Teams new to design patterns—great starting point.

How it works:

  • Each page wraps locators and user actions.
  • Tests orchestrate what to do; POM implements how.

Playwright (TypeScript)

  
import { expect, type Locator, type Page } from '@playwright/test';

export class PlaywrightDevPage {
  readonly page: Page;
  readonly getStartedLink: Locator;
  readonly gettingStartedHeader: Locator;
  readonly pomLink: Locator;
  readonly tocList: Locator;

  constructor(page: Page) {
    this.page = page;
    this.getStartedLink = page.locator('a', { hasText: 'Get started' });
    this.gettingStartedHeader = page.locator('h1', { hasText: 'Installation' });
    this.pomLink = page.locator('li', {
      hasText: 'Guides',
    }).locator('a', {
      hasText: 'Page Object Model',
    });
    this.tocList = page.locator('article div.markdown ul > li > a');
  }

  async goto() {
    await this.page.goto('https://playwright.dev');
  }

  async getStarted() {
    await this.getStartedLink.first().click();
    await expect(this.gettingStartedHeader).toBeVisible();
  }

  async pageObjectModel() {
    await this.getStarted();
    await this.pomLink.click();
  }
}
  

Selenium (Java)

  
public class LoginPage {
  private final WebDriver driver;
  @FindBy(id="username") private WebElement user;
  @FindBy(id="password") private WebElement pass;
  @FindBy(css="button[type='submit']") private WebElement signIn;

  public LoginPage(WebDriver driver) {
    this.driver = driver; PageFactory.initElements(driver, this);
  }
  public void open(String baseUrl){ driver.get(baseUrl + "/login"); }
  public void loginAs(String u, String p){ user.sendKeys(u); pass.sendKeys(p); signIn.click(); }
}

  

Advantages

  • Single source of truth for locators/actions → easy maintenance.
  • Clean, readable tests; better reusability.
  • Works great with cross-browser and CI parallelism.

Things to consider

  • Needs strong naming and folder structure to scale.
  • Can produce God Pages if you cram everything in one class—split into Page + Components.
  • Keep assertions mostly outside POM to avoid business logic bloat.

UI / API fit: UI primary; pairs well with API preconditioning (e.g., create user via API, then UI login).

Fluent Test Data Builder

In simple words: Build complex test data (payloads, DTOs) using a fluent, chainable API with safe defaults.

When to use:

  • Many tests need variants of a base object.
  • Rich domain entities; nested JSON payloads for API.

Playwright (TypeScript, API)

  
class UserBuilder {
  private u = { name: "Alice", role: "user", age: 30, active: true };
  asAdmin(){ 
      this.u.role = "admin"; 
      return this; 
  }
  withAge(age: number){ 
      this.u.age = age; 
      return this; 
  }
  build(){ return structuredClone(this.u); }
}
// usage
const admin = new UserBuilder()
                  .asAdmin()
                  .withAge(35)
                  .build();
  

Selenium (Java, API)

  
public class UserBuilder {
  private final User u = new User("Alice", "user", 30, true);
  public UserBuilder asAdmin(){ 
        u.setRole("admin"); 
        return this; 
  }
  public UserBuilder withAge(int age){ 
         u.setAge(age); 
         return this; 
  }
  public User build(){ return u.clone(); }
}
  

Advantages

  • Readable setup; single place for defaults.
  • Reduces fragile inline JSON in tests; easy mutation.

Things to consider

  • Many entities → many builders; keep them near domain models.
  • Avoid over-configuring builders; provide clear defaults.

UI / API fit: Both (fixtures, API payloads, DB seeds).

Factory (Driver/Client/Service)

In simple words: Centralize creation of browsers, API clients, DB connectors based on env/config.

When to use:

  • Switch API clients or auth strategies.
  • Toggle Chrome/Firefox/WebKit, headless vs headed, or base URLs per env.

Playwright (TypeScript)

  
// apiClientFactory.ts
import { request, APIRequestContext } from "@playwright/test";

export async function createApiClient(env: "dev" | "stage" | "prod"): Promise {
  const baseURL = {
    dev: "https://api.dev.myapp.com",
    stage: "https://api.stage.myapp.com",
    prod: "https://api.prod.myapp.com"
  }[env];

  return request.newContext({
    baseURL,
    extraHTTPHeaders: { Authorization: `Bearer ${process.env.TOKEN}` }
  });
}

// test.spec.ts
import { test } from "@playwright/test";
import { createApiClient } from "../factory/apiClientFactory";

test("create user", async () => {
  const api = await createApiClient("stage");
  const res = await api.post("/users", { data: { name: "Jack" } });
  test.expect(res.ok()).toBeTruthy();
});

  

Selenium (Java)

  
public final class WebDriverFactory {
  public static WebDriver create(String name) {
    return switch(name.toLowerCase()){
      case "chrome" -> new ChromeDriver();
      case "firefox" -> new FirefoxDriver();
      default -> throw new IllegalArgumentException("Unknown Browser: " + name);
    };
  }
}
  

Advantages

  • One switch for many environments; DRY.
  • Enables matrix testing (browsers x OS x viewport).

Things to consider

  • Don’t embed complex logic—delegate to helpers or configs.
  • Keep lifecycle (init/teardown) consistent for parallel runs.

UI / API fit: Both.

Strategy (Waits, Retries, Assertions)

In simple words: Define a common interface for a behavior (e.g., “wait until clickable/visible” or “assert something”), then provide multiple interchangeable implementations—like explicit wait, polling wait, or optimistic (no wait).
At runtime, you pick the strategy based on environment, flakiness, or scenario—without changing test code.

When to use:

  • Page behaviours vary by env; you need pluggable waits/retries.
  • Stabilizing flaky UI actions.

Selenium (Java)

  
// Step 1: Strategy Interface
public interface WaitStrategy {
    void waitUntil(WebDriver driver, By locator);
}
// Step 2: Explicit Wait Strategy (implementation)
public class ExplicitWait implements WaitStrategy {
    public void waitUntil(WebDriver driver, By locator) {
        new WebDriverWait(driver, Duration.ofSeconds(5))
            .until(ExpectedConditions.visibilityOfElementLocated(locator));
    }
}
// Step 3: No Wait Strategy (implementation)
public class NoWait implements WaitStrategy {
    public void waitUntil(WebDriver driver, By locator) {
        // do nothing
    }
}
// Step 4: Method using the strategy
public class ClickAction {

    public static void click(WebDriver driver, By locator, WaitStrategy strategy) {
        strategy.waitUntil(driver, locator);
        driver.findElement(locator).click();
    }
}
//Test
@Test
public void testAddToCart() {
    WebDriver driver = new ChromeDriver();
    driver.get("https://example.com");

    By addToCart = By.id("add-to-cart");

    // pick wait strategy at runtime
    WaitStrategy strategy = new ExplicitWait();  // or new NoWait();

    ClickAction.click(driver, addToCart, strategy);
}
  

Advantages

  • Avoids if-else sprawl; choose policy per test/env.
  • Can A/B different stabilization tactics.

Things to consider

  • Requires clear interfaces and naming.
  • Overuse can hide slow app issues—monitor timings.

UI / API fit: Primarily UI; API retry/backoff also useful.

Facade (Unified Test API)

In simple words: Offer a simple interface that orchestrates UI, API, and DB behind the scenes.

When to use:

  • Tests should read like a story, not plumbing.
  • Complex setup/teardown involving multiple systems.

Selenium (Java)

  
// AppFacade.java
public class AppFacade {

    private final WebDriver driver;
    private final LoginPage loginPage;
    private final ProductPage productPage;
    private final CartPage cartPage;

    public AppFacade(WebDriver driver) {
        this.driver = driver;
        this.loginPage = new LoginPage(driver);
        this.productPage = new ProductPage(driver);
        this.cartPage = new CartPage(driver);
    }

    public void loginAndCheckout(String user, String pass, String product) {
        loginPage.login(user, pass);
        productPage.searchProduct(product);
        productPage.addToCart();
        cartPage.checkout();
    }
}
  

Playwright (TypeScript)

  
export class App {
  constructor(private page: Page, private api: ApiClient) {}
  async loginAs(username: string, password: string) { /* UI login */ }
  async prepareUser(data: any) { return this.api.createUser(data); } // API precondition
  async checkout(sku: string){ /* UI add to cart, API verify stock, etc. */ }
}
  

Advantages

  • Cuts import noise; consistent entry points for capabilities.
  • Ideal for hybrid UI+API workflows.

Things to consider

  • Don’t hide too much—log inner steps for debuggability.
  • Keep Facade thin; delegate to POM/clients.

UI / API fit: Both (that’s the point).

Dependency Injection (DI)

In simple words: Pass dependencies (driver/page, clients, config) into classes rather than constructing inside them.

When to use:

  • You need test isolation, mocking, or swapping implementations easily.
  • Large frameworks with many collaborators.

Selenium (Java)

  
//LoginPage.java
public class LoginPage {
    private WebDriver driver = new ChromeDriver(); // BAD 
}

// LoginPage.java
public class LoginPage {
    private final WebDriver driver;

    public LoginPage(WebDriver driver) {   // Dependency injected
        this.driver = driver;
    }

    public void login(String user, String pass) {
        driver.findElement(By.id("username")).sendKeys(user);
        driver.findElement(By.id("password")).sendKeys(pass);
        driver.findElement(By.id("login")).click();
    }
}
  

Playwright (TypeScript)

  
class LoginService { 
constructor(private page: Page, private baseUrl: string) {} 
}
const svc = new LoginService(page, process.env.BASE_URL!);
  

Advantages

  • Clear, testable wiring; easy to mock.
  • Avoids hidden singletons; parallel-safe.

Things to consider

  • Slightly more boilerplate; consider a light DI container for big projects.
  • Document construction order.

UI / API fit: Both.

SOLID Principles (as applied to Test Automation)

🟦 S — Single Responsibility Principle (SRP)
Do one thing well

A class should do only ONE thing.

  • Page Object contains only locators & actions.
  • No assertions or test logic inside POM.
🟩 O — Open/Closed Principle (OCP)
Extend, don’t edit

Open for extension, closed for modification.

  • Add new flows without touching existing POMs.
  • Use Strategy/Factory to plug in behavior.
🟨 L — Liskov Substitution Principle (LSP)
Subtypes are drop-ins

Subclasses should be usable as the base type.

  • LoginPageMobile should work where LoginPage is expected.
  • Prefer interfaces for POM contracts.
🟧 I — Interface Segregation Principle (ISP)
Small, focused APIs

Don’t force classes to implement unused methods.

  • Split utilities into focused helpers.
  • e.g., WaitHelper, ScreenshotHelper.
🟥 D — Dependency Inversion Principle (DIP)
Depend on abstractions

Depend on abstractions, not concrete classes.

  • Inject WebDriver / Playwright’s Page.
  • Don’t new-up drivers inside POMs.

Conclusion

Design patterns are not just theoretical concepts — they are practical tools that make automation frameworks scalable, readable, and maintainable. Whether you are testing UI with Selenium / Playwright, or APIs using payload builders, applying patterns like POM, Factory, Strategy, Builder, Facade, DI ensures that your test code focuses on what to validate, not how to interact with systems.

A well-architected test automation framework:

  • Avoids duplication of locators, flows, and payloads
  • Makes onboarding new team members easier
  • Adapts to UI / API changes with minimal rework
  • Reduces flakiness and increases test stability
  • Enforces a clean separation between test intent and implementation details

The more your framework follows patterns, the less “fixing tests” and more “delivering value” you do.

The goal is simple:

Write tests that are easy to change and hard to break.