DEV Community

Murat K Ozcan
Murat K Ozcan

Posted on

Page Objects vs. Functional Helpers

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;
Enter fullscreen mode Exit fullscreen mode

🔹 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');
});
Enter fullscreen mode Exit fullscreen mode

✅ 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"]');
}
Enter fullscreen mode Exit fullscreen mode

🔹 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');
});
Enter fullscreen mode Exit fullscreen mode

🔥 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)

Collapse
 
kirill_tikhonov_93c4445eb profile image
Kirill Tikhonov

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?

Collapse
 
muratkeremozcan profile image
Murat K Ozcan

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.

Collapse
 
kirill_tikhonov_93c4445eb profile image
Kirill Tikhonov

Cool-cool! Thanks for examples!

Collapse
 
kerry_ritter_8fd809be3265 profile image
Kerry Ritter

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!

Collapse
 
muratkeremozcan profile image
Murat K Ozcan • Edited

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:

  • Fewer flaky selectors (prior to e2e, we get to test components in isolation)
  • Less test e2e effort (we already can cover a lot at ui component level )
  • Faster feedback loops (smaller testing is easier, and faster)

We only move up the pyramid when there are things we cannot fully test at components, like routing, user flows, backend calls.

Collapse
 
moaaz_adel profile image
Moaaz Adel

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?

Collapse
 
muratkeremozcan profile image
Murat K Ozcan

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

const loginPage = new LoginPage(page);
await loginPage.login(username, password);

const cartPage = new CartPage(page);
await cartPage.addToCart(product);

const checkoutPage = new CheckoutPage(page);
await checkoutPage.completePurchase();
Enter fullscreen mode Exit fullscreen mode

This is what I prefer, if the code isn't repeated anywhere else

✅ No class instantiation
✅ No unnecessary helpers
✅ Just readable, direct UI actions

await page.fill('[data-testid="username"]', 'testUser');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');

await page.click(`[data-testid="add-to-cart-laptop"]`);
await page.click('[data-testid="cart-icon"]');

await page.click('[data-testid="checkout-button"]');
await page.fill('[data-testid="address"]', '123 Test St');
await page.click('[data-testid="confirm-order"]');
Enter fullscreen mode Exit fullscreen mode

✅ If this flow is repeated, use functional helpers instead

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"]');
}

export async function addToCart(page, product) {
    await page.click(`[data-testid="add-to-cart-${product}"]`);
    await page.click('[data-testid="cart-icon"]');
}

export async function completePurchase(page) {
    await page.click('[data-testid="checkout-button"]');
    await page.fill('[data-testid="address"]', '123 Test St');
    await page.click('[data-testid="confirm-order"]');
}
Enter fullscreen mode Exit fullscreen mode
import { test } from '@playwright/test';
import { login, addToCart, completePurchase } from './helpers.js';

test('User can complete checkout flow', async ({ page }) => {
    await login(page, 'testUser', 'password123');
    await addToCart(page, 'laptop');
    await completePurchase(page);
});
Enter fullscreen mode Exit fullscreen mode
Collapse
 
duy_le_c6e91ca75dd861cdf8 profile image
Duy Le

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!

Collapse
 
muratkeremozcan profile image
Murat K Ozcan

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

Collapse
 
mohamed_sulaimaansheriff profile image
Mohamed Sulaimaan Sheriff

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

Collapse
 
muratkeremozcan profile image
Murat K Ozcan • Edited

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.

// locators.js : Locators in a Shared File
export const loginLocators = {
    username: '[data-testid="username"]',
    password: '[data-testid="password"]',
    submitButton: '[data-testid="login-button"]',
};

/////////

// Functional Helper Using Shared Locators

import { loginLocators } from './locators.js';

export async function enterUsername(page, username) {
    await page.fill(loginLocators.username, username);
}

export async function login(page, username, password) {
    await enterUsername(page, username);
    await page.fill(loginLocators.password, password);
    await page.click(loginLocators.submitButton);
}


////////////
// Usage in a test
await enterUsername(page, 'testUser'); // Field validation test
await login(page, 'testUser', 'password123'); // Full login test
Enter fullscreen mode Exit fullscreen mode
Collapse
 
timothy_western_ed7594e0a profile image
Timothy Western

Technically Pom can have composition too and still be class based