Page Objects vs. Functional Helpers
A while ago, I published Functional Programming Test Patterns with Cypress, where I went in-depth on why page objects are unnecessary in modern test automation. Back then, I didn’t realize just how ahead of its time that take was—since many people still insist on using page objects today.
This post is a simpler, updated version of that argument.
🚀 Why Functional Helpers > Page Objects?
The Page Object Model (POM) follows inheritance, while functional helpers follow composition.
But modern web apps are built with component-based architecture, where components are the real building blocks—not pages.
❓ If components compose and pages are just collections of them, does it really make sense to abstract pages with classes? Or does it introduce unnecessary duplication and over-abstraction?
In modern testing frameworks like Playwright and Cypress, strict Page Object Model (POM) is often overkill, especially when:
✅ You’re using data selectors (data-qa
, data-cy
) for stable locators.
✅ The tools already offer powerful built-in utilities for UI interactions.
✅ POM introduces extra complexity that makes debugging harder.
❌ Why Page Objects No Longer Make Sense
1️⃣ Unnecessary Abstraction
- POM adds an extra layer that often doesn’t provide feasible value.
- Modern test frameworks are already powerful enough without it.
2️⃣ Base Page Inheritance is Overkill
- Having a
BasePage
class with generic methods (click()
,fill()
) just to wrap Playwright’s API (or Cypress) makes no sense. - Playwright (or Cy) already has
page.locator()
,page.click()
,page.fill()
, etc.
3️⃣ Harder Debugging
- With POM, if a test fails, you have to jump between multiple files to figure out what went wrong.
- With direct helper functions, you see exactly what’s happening.
🔴 Traditional Page Object Model (POM)
🚨 Problems with POM:
❌ Unnecessary complexity → Extra class & inheritance
❌ Harder debugging → Need to jump between files
❌ Wrapping Playwright’s own API for no reason
🔹 Example (LoginPage.js
- POM Approach)
class LoginPage {
constructor(page) {
this.page = page;
this.usernameField = page.locator('[data-testid="username"]');
this.passwordField = page.locator('[data-testid="password"]');
this.loginButton = page.locator('[data-testid="login-button"]');
}
async login(username, password) {
await this.usernameField.fill(username);
await this.passwordField.fill(password);
await this.loginButton.click();
}
}
export default LoginPage;
🔹 Usage in a Test
import { test, expect } from '@playwright/test';
import LoginPage from './LoginPage.js';
test('User can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('testUser', 'password123');
await expect(page.locator('[data-testid="welcome-message"]')).toHaveText('Welcome, testUser');
});
✅ Functional Helper Approach (Better)
📌 Why is this better?
✅ No extra class → Directly use Playwright API
✅ No unnecessary this.page
assignments
✅ Much easier to maintain & debug
🔹 Example (loginHelpers.js
- Functional Helper Approach)
export async function login(page, username, password) {
await page.fill('[data-testid="username"]', username);
await page.fill('[data-testid="password"]', password);
await page.click('[data-testid="login-button"]');
}
🔹 Usage in a Test
import { test, expect } from '@playwright/test';
import { login } from './loginHelpers.js';
test('User can log in', async ({ page }) => {
await login(page, 'testUser', 'password123');
await expect(page.locator('[data-testid="welcome-message"]')).toHaveText('Welcome, testUser');
});
🔥 Final Thoughts
Helper functions are simpler, faster to debug, and scale better in component-driven apps.
💡 POM was useful in Selenium/WebDriver days, but today? Just use functions.
🔥 What do you think? Are you still using POM? Have you already switched to functional helpers?
💬 Drop a comment below—I’d love to hear your take on this!
Top comments (12)
A good point view! I'd like to share my point too. I have a nice example of avoiding POM approach and just focusing on functions in e2e scenarios. Sooner or later the code becomes unbearable stack of lines and you need to handle it somehow so it stays readable (it all depends on dev's experience, anyway, or could use test blocks, steps, comments etc.). As for me POM has not only inheritance, but also a lexical context, as it was mentioned in previous comments. Like, you create on one page, then view on another and delete on the third. All these actions will be faceless until you start to wrap and name it like, createAItem, createBItem, createCItem, the same for view and delete actions, what seems like easier to be done in terms of different classes. Also don't forget about Playwright's fixture approach that ease the process class instantiation. So, as for me function helpers are suitable for a one functionality test cases or something like this. For complex or integration test cases POM is fitting better, I assume. Any thoughts on that case?
I see your point.
Ironically one way to get around that is using fixtures without classes. Take a look at this repo github.com/muratkeremozcan/cy-vs-p... .
Here is something you can improve POM with youtube.com/watch?v=of1v9cycTdQ
Decorators in POM methods.
Again, ironically, the same tweak can be used with the functional approach to solve the problem you identified.
Cool-cool! Thanks for examples!
Nice write-up.
I still prefer POMs - I like having 1-1 abstractions between the component and a testing flow. Each component gets a POM, and subcomponents are instantiated as POM properties. It makes the mental model easier for me to reason about as things scale. With functional approaches, I find people tend to be unsure which files to put functions, and in a team environment it can get fractured and confusing.
I use TypeScript so the constructor property setting gets rid of the extra assignment work, I still use Playwright's API directly, and the benefit of the defined 1-1 rule outweighs the extra bloat that a class structure may create.
Nonetheless appreciate the well-articulated perspective!
The TS constructor shortcut is a good point, I always use it as well.
One thing I identified from your post, and correlated with the information I get from other QA is that, the term "Component" is not used in the same context as a React/Angular/Vue as developers think.
For frontend devs, a component is a reusable UI unit with state, props, and lifecycle hooks.
For test automation, a component is often just a logical grouping of elements & interactions within a larger page.
• Example: A checkout form component in testing might represent the entire checkout UI, while in React, it might be broken into FormField, Button, AddressInput, etc.
• In POM, this means subcomponents are typically instantiated as properties within a page object, mirroring how a UI is structured for interaction rather than strict reusability.
While I understand the reasoning behind this, I don’t fully agree with the terminology.
I personally advocate for UI-component-driven testing, as I discuss in my bookhttps://muratkerem.gitbook.io/cctdd .
With modern tools like Playwright Component Testing, Cypress Component Testing, and Vitest with UI, we can now test at a lower level, making full-page interactions less necessary.
It is an alien approach for QA at the moment, but this shift indirectly solves many POM complexities:
We only move up the pyramid when there are things we cannot fully test at components, like routing, user flows, backend calls.
Hi Murat,
Thanks for sharing this post, I'm a big fan of both your QA articles and courses on Udemy. 🤠
I've a question regarding using Functional Programming instead of POM, if we have to automate a flow scenario, for example checkout a product in an E-commerce web app, and we need to navigate between pages, in POM I had to create classes with Page Objects then create instances of these classes and use them into the test file, if I will use the functional helpers pattern, how can I achieve this?
Simple: just compose functions—no need for classes!
🔥 TL;DR
• If a flow is one-off? Just write UI actions directly.
• If reused? Use functional helpers—not Page Objects.
❌ Unnecessary classes
❌ Extra abstraction
❌ Harder debugging
This is what I prefer, if the code isn't repeated anywhere else
✅ No class instantiation
✅ No unnecessary helpers
✅ Just readable, direct UI actions
✅ If this flow is repeated, use functional helpers instead
Hi Murat,
Thank you for sharing your opinion.
I really like the idea of the functional helpers as I am moving towards Playwright from Java/Selenium for my new projects. It does make sense that POM is not entirely necessary in such new tools. However I find it hard for the new tools to maintain locators as in your examples, you might need to repeat the locator strategy in many functions (e.g: Input text in textbox, verify text in textbox). If the UI changes, then we need to find all functions to update instead updating 1 line of code in POM.
Can you help me share the way we can deal with this problem if we implement functional helpers?
Thank you again!
Hi Duy,
What I recommend for locators is adding selectors in the source code, or using testing-library-like semantic selectors in PW (which come built-in).
Take if you check out my Udemy courses, this is the way I did it there udemy.com/user/murat-ozcan-14/?srs...
Here is the repo you can take a look at github.com/muratkeremozcan/pact-js.... Look for data-cy selectors. You will also find testing-library-like selectors where data-cy isn't used.
Cheers,
Murat
If we need another one method to enter username alone for sake of field validation we need to paste along with locator for Functional helper whereas for POM locator will be in one place. Always POM
You don’t need POM to centralize locators—just use a locators module instead.
If you must do this (and I'm not a fan) you can apply something like the below.
Technically Pom can have composition too and still be class based