Choosing the Right Test Automation Design Pattern: Page Object Model, Flow Model Pattern, or Screenplay Pattern?

TL;DR
The Page Object Model (POM) is the industry standard, but it is not easily scalable. The Flow Model Pattern improves upon the basic POM, offering a perfectly balanced solution that is not overly complex yet still scalable. The Screenplay Pattern is best suited for highly complex solutions and requires test engineers to have more technical knowledge. Therefore, before implementing it, ensure that your team can maintain a more abstract framework. If that’s not possible, either hold programming workshops or stick with the Flow Model Pattern.
Preface
Who should read this article?
Test Automation Engineers, Technical Architects, and Software Developers seeking optimal end-to-end test automation solutions
Anyone involved or interested in developing an end-to-end test automation strategy (e.g. Test managers, Test Automation Engineers, Technical Architects and Software Developers).
What is this article about?
After years of working with different test automation frameworks, I've learned that choosing the right design pattern isn't about finding the best one—it's about finding the right fit for your project and team.
The three primary patterns are:
This article explores when each pattern is most appropriate, drawing from my hands-on experience building and maintaining automation frameworks at different scales. I'll start with an overview of each approach, then guide you toward choosing the pattern that best fits your needs.
API-Based Model: A Universal Best Practice
Before exploring different design patterns, I want to highlight a principle that applies to all of them: the API-based approach to test automation.
My colleague Jovan Ilić advocates for this in his article "Test Automation: API-based Model", and I strongly recommend it regardless of which pattern you choose.
The Core Principle
Perform any action in the UI once, every other time use the API
Validate that the interface works, but use faster, more reliable API calls for test setup, navigation, and state management in subsequent tests.
Why It Matters
This approach delivers significant benefits with any pattern:
Faster test execution
Reduced flakiness
Better test isolation
Easier maintenance
More focused UI coverage
Whether you use Page Object Model, Flow Model Pattern, or Screenplay Pattern, consider which interactions genuinely require UI validation and which can be handled more efficiently through APIs. This pragmatic approach will improve your test suite regardless of the architectural pattern you choose.
Page Object Model
Overview
The Page Object Model (POM) is the foundational design pattern that most test automation engineers learn first. The core idea is simple: separate page interaction logic from test scripts by encapsulating UI elements and their actions into dedicated page classes/files.

Description
The Page Object Model remains my preferred starting point for most projects—because of its proven effectiveness and universal recognition. This widespread adoption significantly shortens the ramp-up period for newcomers, as they can quickly adapt to a new codebase without learning an entirely new architectural approach.
While POM has its limitations (particularly as applications grow in complexity) its simplicity and directness make it an excellent foundation. Think of it as the baseline: straightforward to implement, easy to understand, and flexible enough to evolve as your project's needs change.
Code Example
// For the sake of simplicity and framework-agnosticity
// we're skipping the selectors/locators definitions and functions
// that are mentioned on the picture, but not used within test case
// ===========
// Test Script
// ===========
it('should add yellow t-shirt to cart', async () => {
await ProductsPage.openProductDetails('tshirt-yellow-medium');
await ProductDetailsPage.clickAddButton();
const cartCount = await ProductDetailsPage.getCartCount();
expect(cartCount).toBe(1);
});
// =============
// Products Page
// =============
export const ProductsPage = {
async openProductDetails(id) { ... }
}
// ====================
// Product Details Page
// ====================
export const ProductDetailsPage = {
async clickAddButton() { ... },
async getCartCount() { ... }
}
Summary
✅ PROS
Widely adopted standard – most common pattern in test automation
Very intuitive – easy to understand for newcomers
Strong community support – abundant tutorials, examples, and help available
Flexible across all testing phases – DEV, SIT, UAT, etc.
Reduces code duplication – reusable page classes across multiple tests
Good separation of concerns – test logic separate from locators and interaction logic
❌ DOWNSIDES
In pure form can get hard to maintain – repetitive code, page classes can become bloated (hundreds/thousands of lines)
Violates SOLID principles – particularly Single Responsibility Principle
Less efficient for applications where API testing would be more appropriate than UI testing
UI-centric thinking – encourages testing everything through the UI
Lack of standardisation – implementations vary widely between teams
Poor scalability - becomes increasingly difficult to manage as application grows
Flow Model Pattern
Overview
Flow Model Pattern extends Page Object Model by adding a workflow abstraction layer between tests and page objects. This layer captures reusable business workflows, reducing code duplication and keeping both page objects and test scripts clean and focused.

Description
While developing my latest test automation framework, I found myself caught between two extremes: POMs felt too simplistic and led to duplication, while the Screenplay Pattern seemed too complex for a deadline-sensitive project where the team lacked experience with advanced patterns.
I needed something that combined the best of both worlds—as simple and straightforward as Page Object Model, but with better reusability. The solution was to add a single abstraction layer that would keep POMs focused on what they do best (mapping application pages) without the complexity of a full Screenplay implementation.
I called this approach User Steps, only to discover later while reading the ISTQB Test Automation Engineering Syllabus that it already had a name: Flow Model Pattern. Turns out I wasn't as original as I thought!
The last thing I want to mention about the Flow Model Pattern is that it isn't (very well) standardised, so you have complete flexibility to adapt it to your needs. This makes it particularly well-suited for small to medium teams who want better structure than pure POM without the learning curve of Screenplay Pattern.
Code Example
// For the sake of simplicity and framework-agnosticity
// we're skipping the selectors/locators definitions and functions
// that are mentioned on the picture, but not used within test case
// ===========
// TEST SCRIPT
// ===========
test('should add 20 t-shirts to cart', async () => {
await ShoppingFlow.addDiverseTShirts(20);
const cartCount = await ShoppingFlow.getCartItemCount();
expect(cartCount).toBe(20);
});
// =============
// SHOPPING FLOW
// =============
export const ShoppingFlow = {
async addProductToCart(productId) {
await ProductsPage.openProductDetails(productId);
await ProductDetailsPage.clickAddButton();
await ProductDetailsPage.goBackToProductsPage();
},
async addMultipleProducts(productIds) {
for (const id of productIds) {
await this.addProductToCart(id);
}
},
async getCartItemCount() {
return await ProductDetailsPage.getCartCount();
},
async addDiverseTShirts(count) {
const tshirts = TShirtFactory.createDiverseSet(count);
const productIds = tshirts.map(shirt => shirt.id);
await this.addMultipleProducts(productIds);
}
}
// ================================================================
// TEST DATA FACTORY - Clean way to handle the Test Data generation
// ================================================================
export const TShirtFactory = {
create(color, size) {
const id = `tshirt-${color.toLowerCase()}-${size.toLowerCase()}`;
return {
id: id,
name: `T-Shirt Model ${color} ${size}`,
color: color,
size: size
};
},
createDiverseSet(count) {
const colors = ['yellow', 'black', 'blue', 'white', 'green', 'turquoise'];
const sizes = ['small', 'medium', 'large'];
return Array(count).fill(null).map((_, index) => {
const color = colors[index % colors.length];
const size = sizes[index % sizes.length];
return this.create(color, size);
});
}
};
// =============
// PRODUCTS PAGE
// =============
export const ProductsPage = {
async openProductDetails(id) { ... }
}
// ====================
// PRODUCT DETAILS PAGE
// ====================
export const ProductDetailsPage = {
async clickAddButton() { ... },
async getCartCount() { ... },
async goBackToProductsPage() { ... }
}
✅ PROS
Pragmatic middle ground – balances simplicity with scalability
Reduces code duplication – workflows centralised in one place
Keeps Page Objects clean – POMs focus on page interactions only
Easy to learn – intuitive concept, quick onboarding
Improves test readability – tests focus on WHAT, not HOW
Quick to implement – can be added incrementally to existing frameworks
No framework dependency – works with any tool
Scales well for medium projects – handles growth without overwhelming complexity
❌ DOWNSIDES
Not standardised – no official specification or industry consensus
Boundary decisions – requires judgment on what goes in Flows vs Pages
Limited documentation – far fewer resources than POM or Screenplay
Can violate SOLID – risk of becoming a logic "dumping ground"
Less structure – team must establish own conventions
Risk of over-abstraction – can create unnecessary complexity if used too much
Screenplay Pattern
Overview
The Screenplay Pattern shifts from page-centric (or UI-centric) to actor-centric testing. Tests describe WHO performs WHAT actions to achieve their goals, using abilities, tasks, interactions, and questions. This actor-based approach excels in complex, multi-interface scenarios.

Description
Throughout my career working with all major test automation frameworks, I've observed an interesting pattern: while the Screenplay Pattern is well-known in the QA community, practical implementation experience remains relatively scarce. I've personally implemented it once in a production environment, and that experience—combined with extensive research—has given me valuable insights into why adoption remains limited.
This implementation gap isn't unique to my experience. The test automation community frequently grapples with understanding when and how to apply different patterns, as evidenced by ongoing discussions like the Reddit thread "I don't get the ScreenPlay Pattern". What's missing isn't just technical documentation—it's practical guidance on pattern selection based on real-world constraints and team capabilities.
This knowledge gap has significant implications. Many teams default to the Page Object Model and extend it with their best judgment—not because it's the optimal fit, but because it's familiar. Meanwhile, projects that could benefit from more sophisticated approaches continue using patterns that ultimately hinder their scalability and maintainability. My goal with this article is to bridge that gap by providing the contextual decision-making framework that's currently absent from most automation discussions—along with clear visual explanations that may finally help you "get the Screenplay Pattern”.
Why Screenplay Is Different
The Screenplay Pattern isn't just “POM with extra layers”—it's a fundamentally different way of thinking about test automation. Instead of organising code around pages and web elements, you organise around actors (users or systems) and their intentions.
Here's what makes it unique:
Actor-centric thinking: Tests read like user stories ("James logs in and adds a product to cart")
Separation of concerns: Abilities, Tasks, Interactions, and Questions each have single, clear responsibilities
Composability: Small, reusable pieces combine to create complex workflows
Multi-interface support: The same actor can interact with UI, API, and database seamlessly
Scalability: Architecture that handles enterprise complexity without becoming unwieldy
The Trade-off
The price for this sophistication is complexity. Screenplay Pattern requires:
Strong understanding of object-oriented design principles (especially SOLID)
Longer initial setup and learning curve
Team buy-in and training investment
Commitment to the pattern's structure
For large, long-term projects with experienced teams, these investments pay dividends. For smaller projects or teams without strong programming backgrounds, the pattern can feel like over-engineering. To be completely honest, implementing it in such cases rarely makes sense – the return on investment simply won't justify the effort.
Code Example
// For the sake of simplicity and framework-agnosticity
// we're skipping the selectors/locators definitions and functions
// that are mentioned on the picture, but not used within test case
// ===========
// TEST SCRIPT
// ===========
// src/tests/Add20Tshirts.js
test('should add 20 t-shirts to cart', async () => {
const sarah = Actor.named('Sarah');
await sarah.attemptsTo(
AddDiverseTShirtsToCart.count(20)
);
const cartCount = await sarah.asks(CartItemCount.value());
expect(cartCount).toBe(20);
});
// ==========================================================================
// ACTOR – PEOPLE and EXTERNAL SYSTEMS interacting with the system under test
// ==========================================================================
// src/actor.js
export const Actor = {
named(name) {
return {
name: name,
ability: BrowseTheWeb,
async attemptsTo(...tasks) {
for (const task of tasks) {
await task.performAs(this);
}
},
async asks(question) {
return await question.answeredBy(this);
}
};
}
};
// =====================================================================================
// ABILITIES – WRAPPERS around any INTEGRATION LIBRARIES (e.g. E2E, API, Cloud Services)
// =====================================================================================
// src/abilities.js
export const BrowseTheWeb = {
async findElement(selector) { /* ... */ },
async click(selector) { /* ... */ },
async getText(selector) { /* ... */ }
};
// ==========================================================================
// TASKS - SEQUENCES OF ACTIVITIES as meaningful steps of a business workflow
// ==========================================================================
// src/tasks/AddDiverseTShirtsToCart.js
export const AddDiverseTShirtsToCart = {
count(numberOfShirts) {
return {
numberOfShirts: numberOfShirts,
async performAs(actor) {
const tshirts = TShirtFactory.createDiverseSet(this.numberOfShirts);
for (const tshirt of tshirts) {
await actor.attemptsTo(
AddProductToCart.withId(tshirt.id)
);
}
}
};
}
};
// src/tasks/AddProductToCart.js
export const AddProductToCart = {
withId(productId) {
return {
productId: productId,
async performAs(actor) {
await actor.attemptsTo(
OpenProductDetails.for(this.productId),
Click.on('.add-button'),
Click.on('.back-to-products')
);
}
};
}
};
// ================================================================================
// INTERACTIONS - LOW-LEVEL ACTIVITIES an ACTOR can perform using a given interface
// ================================================================================
// src/interactions/OpenProductDetails.js
export const OpenProductDetails = {
for(productId) {
return {
productId: productId,
async performAs(actor) {
const selector = `[data-product-id="${this.productId}"]`;
await actor.attemptsTo(
Click.on(selector)
);
}
};
}
};
// src/interactions/Click.js
export const Click = {
on(selector) {
return {
selector: selector,
async performAs(actor) {
await actor.ability.click(this.selector);
}
};
}
};
// ====================================================================================
// QUESTIONS - RETRIEVE INFORMATION from the system under test and the test environment
// ====================================================================================
// src/questions/CartItemCount.js
export const CartItemCount = {
value() {
return {
async answeredBy(actor) {
const cartBadge = await actor.ability.findElement('.cart-badge');
const text = await actor.ability.getText('.cart-badge');
return parseInt(text, 10);
}
};
}
};
// ================================================================
// TEST DATA FACTORY - Clean way to handle the Test Data generation
// ================================================================
// src/utils/TShirtFactory.js
export const TShirtFactory = {
create(color, size) {
const id = `tshirt-${color.toLowerCase()}-${size.toLowerCase()}`;
return {
id: id,
name: `T-Shirt Model ${color} ${size}`,
color: color,
size: size
};
},
createDiverseSet(count) {
const colors = ['yellow', 'black', 'blue', 'white', 'green', 'turquoise'];
const sizes = ['small', 'medium', 'large'];
return Array(count).fill(null).map((_, index) => {
const color = colors[index % colors.length];
const size = sizes[index % sizes.length];
return this.create(color, size);
});
}
};
Summary
✅ PROS
Highly maintainable
Multi-interface support – seamlessly combines UI, API, and database interactions
SOLID design principles – follows Single Responsibility, Open-Closed principles
Business-focused language – tests read like user stories/workflows
Better abstraction layers – Tasks, Actions, Questions provide clear organisation
Excellent scalability – handles complex, multi-actor scenarios well
Reusable components – interactions and tasks can be composed and reused
❌ DOWNSIDES
Steeper learning curve – much more complex concepts to understand initially
Limited community knowledge – fewer practitioners with hands-on experience
Requires stronger technical skills – team needs understanding of design patterns
Overkill for simple projects – unnecessary overhead for small and even medium-sized applications
Framework dependency – typically requires specific frameworks (e.g. Serenity/JS, Boa Constrictor, ScreenPy)
Longer onboarding time – new team members take longer to become productive
Choosing the Right Approach
Here are my recommendations for different project settings. As a general rule of thumb: stick with the Keep It Stupidly Simple (KISS) paradigm and don't over-engineer in the early stages.
When it comes to different project contexts, here are my suggestions:
| Project Type | Test Maintainers Count | Recommended Strategy |
| Small and Simple | Small (1-3) | Page Object Model (extend to Flow Model if code maintenance becomes unmanageable). |
| Medium and Complex | Small (1-3) | Page Object Model with an idea of extension to Flow Model once you have identified the areas that would benefit most from it. |
| Large and Complex | Small (1-3) | Page Object Model with an idea of extension to Flow Model once you have identified the areas that would benefit most from it. If the team grows and you have enough technical knowledge - gradually introduce the Screenplay Pattern alongside your existing framework. |
| Large and Complex | Large (more than 4) | The Screenplay Pattern is ideal for larger teams as it is highly effective in complex environments and ensures greater compliance with programming best practices. |
If you have any questions, feel free to leave a comment.
Thanks and happy coding!





