Modern UI automation can easily become difficult to maintain.
A test suite may start with a few simple scripts:
login → click → fill → submit → verify
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/
Initially, this looks clean.
But imagine you're working on a feature called Nomination.
Its selectors might be in:
pages/NominationPage.js
Its test data might be in:
data/nomination.js
Its reusable workflow might be in:
utils/nominationHelper.js
Its fixture might be somewhere else.
And the actual test could live under:
tests/rewards/
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/
Inside a feature:
modules/
└── rewards/
└── nomination/
├── locators.js
├── actions.js
├── workflow.js
├── fixture.js
└── data.js
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/
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
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"]'
};
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'
});
or:
return fail({
step: 'selectRecipient',
reason: 'Recipient was not available'
});
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
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
});
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
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
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()
or
fail()
Pipe
Workflows compose operations:
action → action → action → action
Currying / Factories
Playwright's page is bound once.
For example:
const A = createNominationActions(page);
Then individual actions can be invoked with their parameters:
await A.addRecipient('user@vc.com')();
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
}
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
And at the test boundary, the result can be unwrapped:
unwrap(await workflow(page)(params));
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
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
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
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
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
And each feature follows the same internal contract:
Feature
│
├── locators.js
├── actions.js
├── workflow.js
├── fixture.js
└── data.js
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
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();
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!'
})
);
});
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
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
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
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)