Test Automation Design Patterns: 6 Architectures That Prevent Suite Collapse at Scale
Your test suite started with 20 tests. Now you have 2,000. Maintenance time has gone from 2 hours/week to 20 hours/week. Tests break whenever the UI changes. Duplication is rampant. Nobody wants to touch the test code because it's become a liability instead of an asset.
Sound familiar? This is what happens when you build tests without design patterns. You accumulate technical debt faster than you can pay it down. Tests that were supposed to increase confidence instead create frustration and slow down development.
Design patterns aren't academic exercises—they're battle-tested solutions to recurring problems. This guide covers the essential patterns for building maintainable, scalable test automation: from Page Object Model to Screenplay, Builder, Factory, and Strategy patterns. For a full breakdown of the industry landscape, see our 2026 LLM Testing Buyers Guide.
Table of Contents
- Why Design Patterns Matter
- Page Object Model (POM)
- Screenplay Pattern
- Builder Pattern for Test Data
- Factory Pattern for Test Fixtures
- Strategy Pattern for Multi-Platform
- Command Pattern for Test Steps
- Observer Pattern for Reporting
- Pattern Selection Guide
- Anti-Patterns to Avoid
Why Design Patterns Matter
The Cost of Poor Test Architecture
| Symptom | Root Cause | Cost |
|---|---|---|
| Brittle tests | UI locators scattered everywhere | 40+ hours/month maintenance |
| Slow tests | No reusable components | 2-3x longer execution |
| Flaky tests | Poor state management | Wasted CI time, lost confidence |
| Difficult to scale | Copy-paste code | 5-10x slower to add new tests |
| Hard to onboard | No consistent structure | 2-3 weeks longer ramp-up |
Benefits of Design Patterns
✅ Maintainability: Change UI selectors in one place
✅ Readability: Tests read like documentation
✅ Reusability: Share code across tests
✅ Scalability: Add tests 5x faster
✅ Testability: Test the tests themselves
Page Object Model (POM)
The foundation of maintainable UI test automation.
Basic Page Object
// pages/login.page.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('[data-test="email"]');
this.passwordInput = page.locator('[data-test="password"]');
this.submitButton = page.locator('[data-test="submit"]');
this.errorMessage = page.locator('[data-test="error"]');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async getErrorMessage() {
return await this.errorMessage.textContent();
}
async isLoggedIn() {
// Wait for redirect to dashboard
await this.page.waitForURL('/dashboard');
return this.page.url().includes('/dashboard');
}
}
// Usage in tests
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
expect(await loginPage.isLoggedIn()).toBe(true);
});
Advanced POM with App State
// pages/dashboard.page.ts
export class DashboardPage {
constructor(private page: Page) {}
async getProjectCount(): Promise<number> {
const count = await this.page.locator('[data-test="project-card"]').count();
return count;
}
async createProject(name: string) {
await this.page.click('[data-test="new-project"]');
await this.page.fill('[data-test="project-name"]', name);
await this.page.click('[data-test="create"]');
// Wait for project to appear
await this.page.waitForSelector(`[data-test-project="${name}"]`);
}
async openProject(name: string) {
await this.page.click(`[data-test-project="${name}"]`);
}
async deleteProject(name: string) {
await this.page.click(`[data-test-project="${name}"] [data-test="menu"]`);
await this.page.click('[data-test="delete"]');
await this.page.click('[data-test="confirm-delete"]');
// Wait for project to disappear
await this.page.waitForSelector(`[data-test-project="${name}"]`, { state: 'detached' });
}
}
// Test using dashboard page
test('manage projects', async ({ page }) => {
const dashboard = new DashboardPage(page);
const initialCount = await dashboard.getProjectCount();
await dashboard.createProject('Test Project');
expect(await dashboard.getProjectCount()).toBe(initialCount + 1);
await dashboard.deleteProject('Test Project');
expect(await dashboard.getProjectCount()).toBe(initialCount);
});
Component-Based Page Objects
// components/navigation.component.ts
export class NavigationComponent {
constructor(private page: Page) {}
async navigateTo(section: 'dashboard' | 'projects' | 'settings') {
await this.page.click(`[data-test="nav-${section}"]`);
}
async openUserMenu() {
await this.page.click('[data-test="user-menu"]');
}
async logout() {
await this.openUserMenu();
await this.page.click('[data-test="logout"]');
}
async getCurrentSection(): Promise<string> {
const activeNav = await this.page.locator('[data-test^="nav-"][aria-current="page"]');
return (await activeNav.getAttribute('data-test')) || '';
}
}
// Compose pages from components
export class BasePage {
readonly nav: NavigationComponent;
constructor(protected page: Page) {
this.nav = new NavigationComponent(page);
}
}
export class ProjectsPage extends BasePage {
async goto() {
await this.nav.navigateTo('projects');
}
// Project-specific methods...
}
Screenplay Pattern
An alternative to POM focused on user behaviors and tasks.
Screenplay Implementation
// screenplay/actor.ts
export class Actor {
constructor(
public name: string,
public page: Page,
) {}
async attemptsTo(...tasks: Task[]): Promise<void> {
for (const task of tasks) {
await task.performAs(this);
}
}
async asks(question: Question<any>): Promise<any> {
return await question.answeredBy(this);
}
}
// screenplay/tasks.ts
export interface Task {
performAs(actor: Actor): Promise<void>;
}
export class NavigateTo implements Task {
constructor(private url: string) {}
async performAs(actor: Actor): Promise<void> {
await actor.page.goto(this.url);
}
}
export class Login implements Task {
constructor(private credentials: { email: string; password: string }) {}
async performAs(actor: Actor): Promise<void> {
await actor.page.fill('[data-test="email"]', this.credentials.email);
await actor.page.fill('[data-test="password"]', this.credentials.password);
await actor.page.click('[data-test="submit"]');
}
}
export class CreateProject implements Task {
constructor(private projectName: string) {}
async performAs(actor: Actor): Promise<void> {
await actor.page.click('[data-test="new-project"]');
await actor.page.fill('[data-test="project-name"]', this.projectName);
await actor.page.click('[data-test="create"]');
}
}
// screenplay/questions.ts
export interface Question<T> {
answeredBy(actor: Actor): Promise<T>;
}
export class ProjectCount implements Question<number> {
async answeredBy(actor: Actor): Promise<number> {
return await actor.page.locator('[data-test="project-card"]').count();
}
}
export class CurrentUrl implements Question<string> {
async answeredBy(actor: Actor): Promise<string> {
return actor.page.url();
}
}
// Usage in tests (very readable!)
test('user can create project', async ({ page }) => {
const james = new Actor('James', page);
await james.attemptsTo(
new NavigateTo('/login'),
new Login({ email: 'james@example.com', password: 'secret' }),
new CreateProject('My First Project'),
);
const projectCount = await james.asks(new ProjectCount());
expect(projectCount).toBeGreaterThan(0);
});
Screenplay vs POM
| Aspect | Page Object Model | Screenplay Pattern |
|---|---|---|
| Abstraction Level | Page-centric | Task/behavior-centric |
| Readability | Good | Excellent |
| Reusability | Medium | High |
| Learning Curve | Easy | Medium |
| Best For | Simple apps | Complex workflows |
Builder Pattern for Test Data
Create complex test data incrementally.
Test Data Builder
// builders/user.builder.ts
export class UserBuilder {
private user: Partial<User> = {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
role: 'user',
active: true,
};
withEmail(email: string): this {
this.user.email = email;
return this;
}
withName(name: string): this {
this.user.name = name;
return this;
}
withRole(role: 'user' | 'admin' | 'moderator'): this {
this.user.role = role;
return this;
}
inactive(): this {
this.user.active = false;
return this;
}
withSubscription(plan: 'free' | 'pro' | 'enterprise'): this {
this.user.subscription = {
plan,
status: 'active',
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
};
return this;
}
build(): User {
return this.user as User;
}
async create(db: Database): Promise<User> {
const user = this.build();
const created = await db.users.create(user);
return created;
}
}
// Usage - very expressive!
test('admin can manage users', async () => {
// Create admin user
const admin = await new UserBuilder()
.withName('Admin User')
.withRole('admin')
.withSubscription('enterprise')
.create(db);
// Create regular user
const regularUser = await new UserBuilder().withName('Regular User').withSubscription('free').create(db);
// Test admin permissions...
});
test('inactive users cannot login', async ({ page }) => {
const inactiveUser = await new UserBuilder().withEmail('inactive@example.com').inactive().create(db);
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(inactiveUser.email, 'password');
expect(await loginPage.getErrorMessage()).toContain('Account inactive');
});
Chaining Multiple Builders
// Build complex scenarios
test('full e-commerce scenario', async () => {
const customer = await new UserBuilder().withName('John Doe').withSubscription('pro').create(db);
const product = await new ProductBuilder().withName('Test Product').withPrice(29.99).inStock(10).create(db);
const order = await new OrderBuilder()
.forUser(customer.id)
.addProduct(product.id, 2)
.withShipping('express')
.withPaymentMethod('credit_card')
.create(db);
expect(order.total).toBe(59.98);
expect(order.status).toBe('pending');
});
Factory Pattern for Test Fixtures
Create different types of test objects.
Test Fixture Factory
// factories/user.factory.ts
export class UserFactory {
static createAdmin(): User {
return {
id: `admin-${Date.now()}`,
email: `admin-${Date.now()}@example.com`,
name: 'Test Admin',
role: 'admin',
permissions: ['read', 'write', 'delete', 'admin'],
active: true,
};
}
static createRegularUser(): User {
return {
id: `user-${Date.now()}`,
email: `user-${Date.now()}@example.com`,
name: 'Test User',
role: 'user',
permissions: ['read', 'write'],
active: true,
};
}
static createPremiumUser(): User {
return {
...this.createRegularUser(),
subscription: {
plan: 'premium',
status: 'active',
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
},
};
}
static createBatch(count: number, type: 'admin' | 'user' | 'premium' = 'user'): User[] {
return Array.from({ length: count }, () => {
switch (type) {
case 'admin':
return this.createAdmin();
case 'premium':
return this.createPremiumUser();
default:
return this.createRegularUser();
}
});
}
}
// Usage
test('admin can see all users', async () => {
const admin = UserFactory.createAdmin();
const users = UserFactory.createBatch(50, 'user');
await db.users.insertMany([admin, ...users]);
// Test admin view...
});
Strategy Pattern for Multi-Platform
Different test strategies for different contexts.
Browser Strategy
// strategies/browser.strategy.ts
export interface BrowserStrategy {
setUp(page: Page): Promise<void>;
tearDown(page: Page): Promise<void>;
screenshot(page: Page, name: string): Promise<void>;
}
export class DesktopStrategy implements BrowserStrategy {
async setUp(page: Page): Promise<void> {
await page.setViewportSize({ width: 1920, height: 1080 });
}
async tearDown(page: Page): Promise<void> {
// Desktop-specific cleanup
}
async screenshot(page: Page, name: string): Promise<void> {
await page.screenshot({
path: `screenshots/desktop-${name}.png`,
fullPage: true,
});
}
}
export class MobileStrategy implements BrowserStrategy {
async setUp(page: Page): Promise<void> {
await page.setViewportSize({ width: 375, height: 667 });
// Simulate mobile user agent
await page.setUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)...');
}
async tearDown(page: Page): Promise<void> {
// Mobile-specific cleanup
}
async screenshot(page: Page, name: string): Promise<void> {
await page.screenshot({
path: `screenshots/mobile-${name}.png`,
fullPage: false, // Mobile doesn't need full page
});
}
}
// Test runner with strategy
export class TestRunner {
constructor(private strategy: BrowserStrategy) {}
async runTest(page: Page, testFn: () => Promise<void>) {
await this.strategy.setUp(page);
try {
await testFn();
} catch (error) {
await this.strategy.screenshot(page, 'failure');
throw error;
} finally {
await this.strategy.tearDown(page);
}
}
}
// Usage - same test, different strategies
test('checkout flow - desktop', async ({ page }) => {
const runner = new TestRunner(new DesktopStrategy());
await runner.runTest(page, async () => {
// Test checkout flow
});
});
test('checkout flow - mobile', async ({ page }) => {
const runner = new TestRunner(new MobileStrategy());
await runner.runTest(page, async () => {
// Same test, mobile strategy
});
});
Command Pattern for Test Steps
Encapsulate test operations as objects.
Command Implementation
// commands/command.ts
export interface Command {
execute(): Promise<void>;
undo(): Promise<void>;
}
export class CreateUserCommand implements Command {
private createdUserId?: string;
constructor(
private db: Database,
private userData: Partial<User>,
) {}
async execute(): Promise<void> {
const user = await this.db.users.create(this.userData);
this.createdUserId = user.id;
}
async undo(): Promise<void> {
if (this.createdUserId) {
await this.db.users.delete(this.createdUserId);
}
}
}
export class CreateOrderCommand implements Command {
private createdOrderId?: string;
constructor(
private db: Database,
private orderData: Partial<Order>,
) {}
async execute(): Promise<void> {
const order = await this.db.orders.create(this.orderData);
this.createdOrderId = order.id;
}
async undo(): Promise<void> {
if (this.createdOrderId) {
await this.db.orders.delete(this.createdOrderId);
}
}
}
// Command executor with undo capability
export class CommandExecutor {
private executedCommands: Command[] = [];
async execute(command: Command) {
await command.execute();
this.executedCommands.push(command);
}
async undoAll() {
// Undo in reverse order
for (const command of this.executedCommands.reverse()) {
await command.undo();
}
this.executedCommands = [];
}
}
// Usage - automatic cleanup
test('order creation', async () => {
const executor = new CommandExecutor();
try {
await executor.execute(
new CreateUserCommand(db, {
email: 'test@example.com',
name: 'Test User',
}),
);
await executor.execute(
new CreateOrderCommand(db, {
userId: 'user-123',
items: [{ productId: 'prod-1', quantity: 2 }],
}),
);
// Run test assertions...
} finally {
await executor.undoAll(); // Automatic cleanup
}
});
Observer Pattern for Reporting
Track test execution events.
Test Observer
// observers/test-observer.ts
export interface TestEvent {
type: 'start' | 'pass' | 'fail' | 'skip';
testName: string;
duration?: number;
error?: Error;
timestamp: Date;
}
export interface TestObserver {
onTestEvent(event: TestEvent): void;
}
export class ConsoleReporter implements TestObserver {
onTestEvent(event: TestEvent): void {
const emoji = {
start: '🏃',
pass: '✅',
fail: '❌',
skip: '⏭️',
}[event.type];
console.log(`${emoji} ${event.testName} (${event.duration}ms)`);
}
}
export class MetricsReporter implements TestObserver {
private metrics: TestEvent[] = [];
onTestEvent(event: TestEvent): void {
this.metrics.push(event);
}
getSummary() {
return {
total: this.metrics.length,
passed: this.metrics.filter((e) => e.type === 'pass').length,
failed: this.metrics.filter((e) => e.type === 'fail').length,
avgDuration: this.metrics.reduce((sum, e) => sum + (e.duration || 0), 0) / this.metrics.length,
};
}
}
// Test runner with observers
export class ObservableTestRunner {
private observers: TestObserver[] = [];
addObserver(observer: TestObserver) {
this.observers.push(observer);
}
private notifyObservers(event: TestEvent) {
for (const observer of this.observers) {
observer.onTestEvent(event);
}
}
async runTest(name: string, testFn: () => Promise<void>) {
this.notifyObservers({ type: 'start', testName: name, timestamp: new Date() });
const startTime = Date.now();
try {
await testFn();
this.notifyObservers({
type: 'pass',
testName: name,
duration: Date.now() - startTime,
timestamp: new Date(),
});
} catch (error) {
this.notifyObservers({
type: 'fail',
testName: name,
duration: Date.now() - startTime,
error: error as Error,
timestamp: new Date(),
});
throw error;
}
}
}
// Usage
const runner = new ObservableTestRunner();
runner.addObserver(new ConsoleReporter());
runner.addObserver(new MetricsReporter());
await runner.runTest('login test', async () => {
// Test implementation
});
Pattern Selection Guide
When to Use Each Pattern
| Pattern | Use When | Avoid When |
|---|---|---|
| Page Object Model | UI-heavy testing | API-only testing |
| Screenplay | Complex user workflows | Simple, linear tests |
| Builder | Creating complex test data | Simple data structures |
| Factory | Need preset test fixtures | Highly customized data needs |
| Strategy | Multi-platform testing | Single platform only |
| Command | Need undo/redo capability | Simple, stateless operations |
| Observer | Custom reporting needs | Built-in reporting sufficient |
Anti-Patterns to Avoid
1. Locator Soup (No Page Objects)
// ❌ Bad - locators everywhere
test('bad test', async ({ page }) => {
await page.click('[data-test="email"]');
await page.fill('[data-test="email"]', 'test@example.com');
await page.fill('[data-test="password"]', 'password');
await page.click('[data-test="submit"]');
// If UI changes, every test breaks
});
// ✅ Good - Page Objects
test('good test', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('test@example.com', 'password');
// Change locators in one place
});
2. Test Interdependence
// ❌ Bad - tests depend on execution order
test('create user', async () => {
await createUser('test@example.com');
});
test('login user', async () => {
// Assumes previous test ran!
await login('test@example.com');
});
// ✅ Good - independent tests
test('create and login user', async () => {
const user = await createUser('test@example.com');
await login(user.email);
});
3. Magic Numbers and Strings
// ❌ Bad
await page.waitForTimeout(5000);
if (count > 42) { ... }
// ✅ Good
const ANIMATION_DURATION_MS = 5000;
const MAX_PROJECTS_PER_USER = 42;
await page.waitForTimeout(ANIMATION_DURATION_MS);
if (count > MAX_PROJECTS_PER_USER) { ... }
Conclusion
Design patterns transform test automation from a maintenance burden into a strategic asset. Page Objects keep your tests maintainable. Builder and Factory patterns make test data management effortless. Strategy and Command patterns enable sophisticated test architectures.
Start with Page Object Model—it's foundational. Add Builder pattern when test data gets complex. Introduce other patterns as your needs grow. Remember: patterns are solutions to problems. Don't use them until you have the problem they solve.
Your test suite should be as well-architected as your production code. Invest in the design, and it will pay dividends for years.
Related articles: Also see maintaining test suites built on solid design patterns, BDD patterns as a structured approach to readable test design, and Playwright fixtures as an implementation of the page object pattern.
Ready to build maintainable test suites? Try ScanlyApp with built-in support for modern test patterns, best practices, and architectural guidance. Start free—no credit card required.
