DEV Community

Mushtaq Mohd Rejowan
Mushtaq Mohd Rejowan

Posted on

Building a Product-Centric Playwright Automation Framework with Functional Programming

Modern UI automation can easily become difficult to maintain.

A test suite may start with a few simple scripts:

login → click → fill → submit → verify
Enter fullscreen mode Exit fullscreen mode

A few years later, it can turn into hundreds or thousands of tests spread across page objects, utilities, fixtures, helpers, data files, and duplicated workflows.

At that point, adding a new feature doesn't necessarily mean writing a new test.

It means figuring out where the existing logic belongs.

This was one of the problems I wanted to solve while building the Redemption Framework — a modular Playwright automation framework designed around the product itself rather than around technical layers.

The result is a framework built around three major ideas:

  • Product-centric feature slicing
  • Functional programming principles
  • A strict 5-file architecture contract

The Problem With Traditional Automation Structures

A common automation project eventually evolves into something like:

pages/
utils/
tests/
fixtures/
helpers/
data/
services/
common/
Enter fullscreen mode Exit fullscreen mode

Initially, this looks clean.

But imagine you're working on a feature called Nomination.

Its selectors might be in:

pages/NominationPage.js
Enter fullscreen mode Exit fullscreen mode

Its test data might be in:

data/nomination.js
Enter fullscreen mode Exit fullscreen mode

Its reusable workflow might be in:

utils/nominationHelper.js
Enter fullscreen mode Exit fullscreen mode

Its fixture might be somewhere else.

And the actual test could live under:

tests/rewards/
Enter fullscreen mode Exit fullscreen mode

The feature is logically one thing, but physically distributed across the repository.

As the application grows, this creates a cognitive problem:

The code structure stops resembling the product structure.

I wanted the framework to make the opposite choice.


Designing Around the Product

The framework uses feature slicing.

Instead of organizing code primarily by technical responsibility, functionality is grouped according to the product.

For example:

modules/
├── auth/
├── dashboard/
├── rewards/
├── redemption/
├── site-admin/
├── wish-anniversary/
└── mobile/
Enter fullscreen mode Exit fullscreen mode

Inside a feature:

modules/
└── rewards/
    └── nomination/
        ├── locators.js
        ├── actions.js
        ├── workflow.js
        ├── fixture.js
        └── data.js
Enter fullscreen mode Exit fullscreen mode

Everything required to understand and maintain the feature is close together.

This gives the repository a useful property:

The architecture mirrors the application's domain.

If I need to work on nomination, I don't need to navigate through five unrelated top-level directories.

I go to:

modules/rewards/nomination/
Enter fullscreen mode Exit fullscreen mode

and the feature is there.


The 5-File Architecture Contract

The most important design decision in the framework is the 5-File Rule.

Every module follows the same structure:

locators.js
actions.js
workflow.js
fixture.js
data.js
Enter fullscreen mode Exit fullscreen mode

Each file has one responsibility.

1. locators.js

This file contains selector definitions.

It intentionally contains no imports and no business logic.

For example:

export const nominationLocators = {
    nominateButton: '[data-testid="nominate-button"]',
    recipientInput: '[data-testid="recipient-input"]',
    submitButton: '[data-testid="submit-button"]'
};
Enter fullscreen mode Exit fullscreen mode

The idea is simple:

Selector knowledge should remain selector knowledge.

If the UI changes, I should be able to inspect the locator layer without navigating through business logic.


2. actions.js

Actions represent interactions with the application.

But there is an important architectural rule:

Every action returns a Result.

Instead of allowing arbitrary exceptions to propagate through every layer, actions communicate success or failure explicitly.

Conceptually:

return ok({
    status: 'success'
});
Enter fullscreen mode Exit fullscreen mode

or:

return fail({
    step: 'selectRecipient',
    reason: 'Recipient was not available'
});
Enter fullscreen mode Exit fullscreen mode

This provides a predictable contract between the action and workflow layers.

It also makes failures easier to reason about.


3. workflow.js

Actions are useful individually.

But real user journeys require multiple actions.

For example:

Open nomination
      ↓
Choose award
      ↓
Select recipient
      ↓
Enter message
      ↓
Submit
      ↓
Verify result
Enter fullscreen mode Exit fullscreen mode

Instead of putting this entire sequence into the test, the workflow layer composes these operations.

The framework uses a pipe() abstraction to create asynchronous pipelines.

Conceptually:

export const nominationWorkflow = (page) => (params) =>
    pipe(
        openModal,
        chooseType,
        addRecipients,
        pickAward,
        submit
    )({
        page,
        ...params
    });
Enter fullscreen mode Exit fullscreen mode

This makes the business flow immediately visible.

A developer reading the workflow can understand what the user journey is without reading every Playwright command.


4. fixture.js

Fixtures provide the environment required by the feature.

Playwright's test.extend() is used to expose module-specific setup and authenticated pages.

For example, instead of every test repeatedly implementing authentication:

login
wait
navigate
configure tenant
prepare environment
Enter fullscreen mode Exit fullscreen mode

the fixture can provide the correct context.

This keeps tests focused on the behavior being validated.


5. data.js

Test data and environment-specific parameters belong here.

This can include:

  • Test credentials
  • Scenarios
  • Tenant configuration
  • Environment parameters
  • Expected values

Separating data from workflows prevents the test implementation from becoming a mixture of:

business logic + selectors + credentials + test scenarios
Enter fullscreen mode Exit fullscreen mode

Functional Programming as the Core

The framework doesn't use functional programming simply because it sounds interesting.

The goal is to make automation behavior composable and predictable.

Three concepts are particularly important:

Result

Actions return:

ok()
Enter fullscreen mode Exit fullscreen mode

or

fail()
Enter fullscreen mode Exit fullscreen mode

Pipe

Workflows compose operations:

action → action → action → action
Enter fullscreen mode Exit fullscreen mode

Currying / Factories

Playwright's page is bound once.

For example:

const A = createNominationActions(page);
Enter fullscreen mode Exit fullscreen mode

Then individual actions can be invoked with their parameters:

await A.addRecipient('user@vc.com')();
Enter fullscreen mode Exit fullscreen mode

The underlying idea is:

Bind the environment once, then work with small composable functions.

This makes individual pieces easier to reuse and reason about.


Why the Result Pattern Matters in Automation

Traditional Playwright code often relies heavily on exceptions:

try {
    await page.click(selector);
    await page.fill(input, value);
} catch (error) {
    // handle failure
}
Enter fullscreen mode Exit fullscreen mode

When this pattern is repeated across hundreds of workflows, error handling can become inconsistent.

The Result abstraction provides a common contract.

A workflow can evaluate:

Did the previous step succeed?
        ↓
Yes → continue
No  → propagate structured failure
Enter fullscreen mode Exit fullscreen mode

And at the test boundary, the result can be unwrapped:

unwrap(await workflow(page)(params));
Enter fullscreen mode Exit fullscreen mode

This keeps the lower layers explicit while allowing the test to fail naturally when a workflow cannot complete.


A Framework Is More Than Test Scripts

One of the lessons from building a larger automation framework is that test execution is only one part of the problem.

A mature automation system also needs:

  • Observability
  • Reporting
  • Debugging
  • Environment management
  • Data preparation
  • Architecture enforcement
  • Maintainability
  • CI integration

The framework therefore includes several supporting systems.


Observability

The framework uses Allure reporting to provide detailed test reports.

Playwright traces are also enabled for failed CI tests.

This creates a useful debugging path:

Test failure
     ↓
Allure report
     ↓
Playwright trace
     ↓
DOM / network / screenshots
     ↓
Root cause
Enter fullscreen mode Exit fullscreen mode

For a large automation suite, this is extremely valuable.

A failed test should ideally answer:

What failed, where did it fail, and what was the application doing at that moment?


Historical Test Intelligence

The framework also includes a custom dashboard built using:

React
   +
Node.js
   +
SQLite
Enter fullscreen mode Exit fullscreen mode

The purpose isn't just to display pass/fail results.

Historical test data can help identify:

  • Flaky tests
  • Recurring failures
  • Regression patterns
  • Module stability
  • Historical trends

This shifts automation from simply being a test execution mechanism toward being a quality intelligence system.


Real Backend and Frontend Validation

Another important architectural decision is avoiding assumptions about the application under test.

For new tests, the framework can be connected to the actual backend and frontend repositories.

This allows automation development to validate things such as:

API endpoints
        ↓
Backend source

UI selectors
        ↓
Frontend source
Enter fullscreen mode Exit fullscreen mode

Rather than inventing an endpoint or guessing a locator, the automation layer can be validated against the actual implementation.

This is particularly useful in a large product where APIs and UI structures evolve continuously.


Database-Backed Test Preparation

Some tests require realistic data.

For UAT-backed scenarios, the framework can connect to remote MySQL databases through an SSH tunnel.

This is useful for workflows where test preparation depends on actual tenant data rather than static fixtures.

For example:

Test
 ↓
Prepare tenant data
 ↓
Database
 ↓
Application
 ↓
Playwright workflow
 ↓
Validation
Enter fullscreen mode Exit fullscreen mode

This allows certain tests to operate closer to real production-like conditions.


From Product Hierarchy to Automation Hierarchy

The resulting architecture looks roughly like this:

Product
│
├── Authentication
│
├── Dashboard
│
├── Rewards
│   ├── Nomination
│   ├── Appreciation
│   └── Feed
│
├── Redemption
│   ├── Gift Cards
│   ├── Merchandise
│   ├── Vouchers
│   └── Wallet
│
├── Site Administration
│
├── Anniversary / Wishes
│
└── Mobile
Enter fullscreen mode Exit fullscreen mode

And each feature follows the same internal contract:

Feature
│
├── locators.js
├── actions.js
├── workflow.js
├── fixture.js
└── data.js
Enter fullscreen mode Exit fullscreen mode

This consistency becomes increasingly valuable as the number of modules grows.


Architecture Guardrails

A convention is only useful if it can be enforced.

That's why the framework also uses architectural guardrails and specialized tooling to validate things such as:

  • Module structure
  • Locator rules
  • Action contracts
  • Cross-repository assumptions
  • Coverage gaps
  • Scaffolding conventions

The repository also contains task-specific AI skills for activities such as:

Scaffolding
Architecture validation
Locator auditing
Coverage analysis
Cross-repository validation
Enter fullscreen mode Exit fullscreen mode

The goal is not to make developers memorize every architectural rule.

The goal is to make the architecture executable.


What a Test Should Look Like

Ideally, the test itself becomes relatively small.

Instead of exposing every implementation detail:

await page.click(...);
await page.fill(...);
await page.waitForSelector(...);
await page.click(...);
await expect(...).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

the test can express the business behavior:

test('user can nominate a colleague', async ({ nomination }) => {
    unwrap(
        await nomination.create({
            recipient: 'user@vc.com',
            award: 'Great Work',
            message: 'Excellent work!'
        })
    );
});
Enter fullscreen mode Exit fullscreen mode

The implementation details stay inside the module.

The test describes what is being validated.

That distinction becomes increasingly important as test suites grow.


The Architecture in One Diagram

The overall design can be thought of as:

                    PRODUCT
                       │
                       ▼
              ┌─────────────────┐
              │ Feature Module  │
              └────────┬────────┘
                       │
          ┌────────────┼────────────┐
          ▼            ▼            ▼
      Locators      Actions       Data
                       │
                       ▼
                   Workflow
                       │
                       ▼
                    Fixture
                       │
                       ▼
                   Playwright
                       │
                       ▼
                 Application
                       │
          ┌────────────┼────────────┐
          ▼            ▼            ▼
       Allure       Trace       Dashboard
Enter fullscreen mode Exit fullscreen mode

Each layer has a defined responsibility.


What I Like About This Approach

The biggest advantage isn't that there are exactly five files.

The important part is the contract.

When developers join the project, they don't need to reverse-engineer every module.

They know:

Selectors → locators.js

Interactions → actions.js

Business flows → workflow.js

Environment/setup → fixture.js

Test data → data.js
Enter fullscreen mode Exit fullscreen mode

That predictability reduces the mental overhead of working on the framework.


Trade-offs

This architecture isn't universally better.

Strict architecture introduces constraints.

Sometimes a five-file contract can feel excessive for a tiny feature.

Functional programming can also introduce a learning curve for developers who are more comfortable with traditional object-oriented Page Object Models.

And not every application needs database-backed preparation, custom dashboards, or cross-repository validation.

The important lesson is that architecture should solve a real problem.

In this case, the problems were:

  • Growing automation complexity
  • Feature ownership becoming unclear
  • Repeated workflows
  • Inconsistent error handling
  • Difficult debugging
  • UI/backend changes causing automation drift

The architecture was designed around those problems.


Lessons Learned

After working with this style of framework, a few principles stand out.

1. Organize around the product

Automation code should be easy to navigate from a product perspective.

2. Make responsibilities explicit

A file should have a reason to exist.

3. Treat automation as software engineering

Large test suites deserve the same architectural discipline as application code.

4. Make failures observable

A failed test without useful diagnostics is expensive.

5. Prefer composition over duplication

Reusable actions and workflows can dramatically reduce repeated automation logic.

6. Enforce conventions

Documentation alone doesn't guarantee architecture.

Guardrails make conventions sustainable.

7. Keep tests expressive

The test should communicate the behavior being validated, not every implementation detail required to perform it.


Final Thoughts

A Playwright framework doesn't need to be complicated to be effective.

But when an automation suite grows into a significant engineering system, structure becomes critical.

The Redemption Framework is an attempt to solve that problem through a combination of:

Product-centric architecture
          +
Functional programming
          +
Composable workflows
          +
Strict module contracts
          +
Observability
          +
Architecture guardrails
Enter fullscreen mode Exit fullscreen mode

The most important idea isn't the 5-File Rule.

It's the principle behind it:

Automation architecture should make the product easier to understand, test, and maintain—not make the automation framework itself the center of the universe.

That's the direction I'm continuing to explore with Playwright, functional programming, and modern quality engineering.

Top comments (0)