From Fragile to Flexible: How Design Patterns Can Transform Your Test Automation
By Eduard Dublyer, CTO of Skipper Soft
What Are Design Patterns and Why Should QA/Automation Engineers Care?
A few years ago, I joined a fast-moving fintech startup. The developers were deploying like clockwork, but our QA automation was barely hanging on. A small UI change broke dozens of unrelated tests. Debugging took hours. Reuse was non-existent. It felt like we were writing temporary scripts, not scalable automation.
That was the moment I realized something critical was missing: design patterns.
Design patterns aren’t just theory from a software engineering textbook. They’re practical, reusable solutions to common coding challenges. Think of them as architectural blueprints for your test automation framework. They help bring structure, clarity, and long-term maintainability to your codebase.
A Real-World Example: Playwright Without Patterns
Playwright is a powerful testing tool, especially for end-to-end automation. But as your test suite grows, so do the headaches—duplicated code, brittle tests, and unclear test setup logic. That’s when applying design patterns like Singleton and Factory Method made a huge difference.
Introducing a singleton API client, a factory for dynamic requests, and a proper Page Object Model (POM) structure made our codebase readable, scalable, and far easier to maintain.
5 Signs Your Automation Framework Lacks Design Patterns
- Test scripts are bloated and hard to follow
- Copy-paste coding is the norm
- Small changes cause a cascade of broken tests
- Onboarding new engineers is slow and painful
- Nobody can explain how the tests work without diving into every line of code
If any of these resonate, it’s time to rethink your test architecture.
Top 5 Design Patterns Every Test Automation Team Should Use (With Playwright Examples)
Singleton Pattern
- Purpose: Ensures only one instance of a resource (e.g., API client) exists
- Benefit: Saves memory and ensures consistent configurations
import { request, APIRequestContext } from '@playwright/test';
class APIClient {
private static instance: APIRequestContext;
public static async getInstance(): Promise<APIRequestContext> {
if (!APIClient.instance) {
APIClient.instance = await request.newContext({ baseURL: 'https://api.example.com' });
}
return APIClient.instance;
}
}
export default APIClient;
Factory Method Pattern
- Purpose: Centralize the logic for creating objects or requests
- Benefit: Reduces duplication and simplifies future changes
class APIRequestFactory {
constructor(private apiContext: APIRequestContext) {}
async makeRequest(endpoint: string, method: 'GET' | 'POST', data?: any) {
switch (method) {
case 'GET': return this.apiContext.get(endpoint);
case 'POST': return this.apiContext.post(endpoint, { data });
}
}
}
Page Object Model (POM)
- Purpose: Encapsulates UI logic into dedicated page classes
- Benefit: Clean, reusable tests that are easy to maintain
export class LoginPage {
constructor(private page: Page) {}
async login(username: string, password: string) {
await this.page.fill('#username', username);
await this.page.fill('#password', password);
await this.page.click('button[type="submit"]');
}
}
Builder Pattern
- Purpose: Build complex test data step by step
- Benefit: Clean test setup, especially for APIs and form submissions
class UserBuilder {
private user = { name: '', email: '', role: 'user' };
setName(name: string) { this.user.name = name; return this; }
setEmail(email: string) { this.user.email = email; return this; }
setRole(role: string) { this.user.role = role; return this; }
build() { return this.user; }
}
Strategy Pattern
- Purpose: Enables dynamic selection of test execution strategies
- Benefit: Supports platform-specific logic for mobile testing (e.g., iOS vs Android)
interface DeviceStrategy {
runTests(): void;
}
class IOSStrategy implements DeviceStrategy {
runTests() {
console.log('Running tests on iOS devices using Safari and device-specific configs.');
}
}
class AndroidStrategy implements DeviceStrategy {
runTests() {
console.log('Running tests on Android devices using Chrome and Android automation setup.');
}
}
// Usage
const runAutomation = (strategy: DeviceStrategy) => {
strategy.runTests();
};
const deviceType = process.env.PLATFORM;
const strategy = deviceType === 'ios' ? new IOSStrategy() : new AndroidStrategy();
runAutomation(strategy);
How to Start Implementing Design Patterns
At Skipper Soft, we didn’t start with a complete framework rewrite. Instead, we adopted design patterns gradually—one API helper here, one page model there. Over time, we introduced code reviews focused on structure and started creating shared utilities.
Design maturity is a journey, not a big bang.
And remember — while Page Object Model (POM) is a great starting point, modern frameworks like Screenplay Pattern are gaining traction. Screenplay promotes reusability, abstraction, and readable domain language, testing complex interactions.
5 Practical Tips to Guide Your Team Toward Pattern-Driven Testing
- Refactored one low-risk test suite using a new pattern
- Create a patterns or framework folder in your repo
- Run short lunch-and-learn sessions to explain each pattern
- Highlight real wins like shorter tests or fewer bugs
- Track improvements like test flakiness, LOC, and review times
Final Thoughts: Build Smart, Test Smarter
You don’t need to be a senior architect to apply design patterns. You need the willingness to improve your codebase and collaborate better. By starting small and evolving your framework with design in mind, you’ll move from fragile automation to flexible, robust systems that scale.
Automation isn’t about writing tests. It’s about building confidence in your product. Design patterns are the foundation to make that possible.
So, pick one pattern. Apply it this week. Watch the magic happen.
Coming soon: We’re preparing a dedicated post comparing Page Object Model vs Screenplay Pattern — what to choose and when.
👉 Follow Eduard Dubilyer and Skipper Soft on LinkedIn to be the first to read it!