Data as of Sep 19, 2026 · Based on 338 AI responses from ChatGPT Search and Google AI Mode · See how Parse measures this
Building a scalable and maintainable test automation framework requires selecting a robust test runner that supports parallel execution, integrating automated testing directly into your CI/CD pipeline, and using tools to visualize test results for stakeholders. Core components include choosing reliable runners like TestNG, automating checks on pull requests via
GitHub Actions or GitLab CI, and utilizing dashboards like ReportPortal for clear reporting.
Brands AI recommends here
Named in 81% of answers
Named in 75% of answers
Named in 64% of answers
Named in 62% of answers
GitHub Actions is a primary recommendation for automating test runs on every pull request. Its native integration ensures that tests remain a mandatory part of the development lifecycle.
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Instead of just writing scripts, you are building a software product whose sole job is to test another software product. - **Design Pattern / Architecture** : Implement the **Page Object Model (POM)**…
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Instead of just writing scripts, you are building a software product whose sole job is to test another software product.
Would you like to dive deeper into a specific layer, such as:
No sources cited
A scalable test automation framework is less about picking a tool and more about designing **clear boundaries, isolated tests, reusable domain abstractions, reliable test data, and fast feedback**. For a modern web application, I’d build it roughly like this. ## 1. Start with the testing strategy, not the framework
A scalable test automation framework is less about picking a tool and more about designing clear boundaries, isolated tests, reusable domain abstractions, reliable test data, and fast feedback.
For a modern web application, I’d build it roughly like this.
First decide what belongs at each testing level:
┌─────────────────────┐
│ E2E / UI Tests │ ← few, critical journeys
├─────────────────────┤
│ Integration / API │ ← most business behavior
├─────────────────────┤
│ Unit Tests │ ← fastest, largest volume
└─────────────────────┘
Don't try to automate everything through the browser. Browser tests are relatively expensive, and Selenium specifically recommends considering whether a test can be performed at a lighter-weight level first.
A good target is:
This single decision has a huge impact on scalability.
For a new web UI framework, one practical stack is:
Language: TypeScript
Test runner: Playwright Test
Browser: Chromium + Firefox + WebKit as needed
API testing: Playwright APIRequestContext or dedicated API client
Assertions: Playwright expect
CI/CD: GitHub Actions / GitLab CI / Jenkins / Azure DevOps
Reporting: HTML + CI artifacts
Code quality: ESLint + Prettier
Package mgr: npm / pnpm
Playwright is particularly convenient because the test runner already provides fixtures, parallel execution, projects, retries, reporting, and browser/device configuration.
Selenium remains a perfectly valid choice, particularly if your organization already has a Selenium/Grid ecosystem. Selenium also provides official guidance around Page Objects, test independence, data generation, locators, reporting, and avoiding shared state.
Avoid putting everything into tests/.
A structure like this scales much better:
automation/
│
├── tests/
│ ├── smoke/
│ ├── regression/
│ ├── checkout/
│ ├── authentication/
│ └── users/
│
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ ├── CheckoutPage.ts
│ └── components/
│ ├── Header.ts
│ └── Navigation.ts
│
├── api/
│ ├── UserApi.ts
│ ├── OrderApi.ts
│ └── AuthApi.ts
│
├── fixtures/
│ ├── test.fixture.ts
│ └── auth.fixture.ts
│
├── data/
│ ├── users.ts
│ └── products.ts
│
├── factories/
│ ├── UserFactory.ts
│ └── OrderFactory.ts
│
├── utils/
│ ├── date.ts
│ ├── random.ts
│ └── retry.ts
│
├── config/
│ ├── environments.ts
│ └── test-config.ts
│
├── playwright.config.ts
│
├── package.json
└── README.md
The important point is that tests describe behavior, while framework code handles implementation details.
Bad:
await page.locator('#username').fill('john');
await page.locator('#password').fill('secret');
await page.locator('.login-button').click();
await page.locator('.dashboard').waitFor();
Better:
await loginPage.loginAs(user);
await expect(dashboardPage.heading).toHaveText('Dashboard');
Best, when the domain abstraction makes sense:
await application.loginAs(user);
await expect(application.dashboard).toBeVisible();
Your test should answer:
What behavior am I verifying? rather than:
Which CSS selector did I click? Selenium's Page Object guidance makes essentially this distinction: page objects should encapsulate the mechanics of interacting with the page, while tests contain the actual assertions.
Don't create giant classes containing every possible interaction.
Instead:
export class LoginPage {
constructor(private readonly page: Page) {}
private username = this.page.getByLabel('Username');
private password = this.page.getByLabel('Password');
private loginButton = this.page.getByRole('button', {
name: 'Sign in'
});
async loginAs(username: string, password: string) {
await this.username.fill(username);
await this.password.fill(password);
await this.loginButton.click();
}
}
Then:
test('valid user can log in', async ({ page }) => {
const login = new LoginPage(page);
await login.loginAs('[email protected]', 'password');
await expect(page.getByRole('heading', {
name: 'Dashboard'
})).toBeVisible();
});
The key principle is:
Page objects expose capabilities, not implementation details.
For complex applications, use component objects too:
CheckoutPage
├── Header
├── AddressForm
├── PaymentForm
└── OrderSummary
That prevents a 3,000-line CheckoutPage class.
Fixtures are one of the most important mechanisms for making a framework maintainable.
For example:
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
type Fixtures = {
loginPage: LoginPage;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
}
});
Now tests simply consume the dependency:
test('user can log in', async ({ loginPage }) => {
await loginPage.loginAs(
'[email protected]',
'password'
);
});
This becomes extremely powerful as the framework grows:
test
├── browser
├── authenticatedUser
├── loginPage
├── dashboardPage
├── apiClient
├── database
└── testData
Playwright fixtures are isolated between tests and can be composed and reused across test files.
Test data is one of the biggest sources of automation failures.
Avoid:
const user = {
email: '[email protected]'
};
when dozens of tests depend on the same account.
Instead, use factories:
export function createUser(overrides = {}) {
return {
firstName: `Test${randomString()}`,
lastName: 'User',
email: `test-${randomString()}@example.com`,
...overrides
};
}
Then:
const user = createUser({
role: 'admin'
});
For larger systems, have explicit lifecycle mechanisms:
Test
│
├── Create required data
│
├── Execute behavior
│
└── Clean up data
Or use disposable test environments/databases where possible.
Never make Test B depend on Test A creating something.
Playwright explicitly recommends test isolation because shared state makes tests break when execution order changes or tests run in parallel.
Suppose you need an order with five products.
Don't do:
Open browser
→ Login
→ Search product
→ Add product
→ Search product
→ Add product
→ Checkout
→ ...
→ Finally test something
Instead:
API:
create user
create products
create order
UI:
open order
verify behavior
This dramatically reduces execution time and flakiness.
The browser should test the behavior that actually needs a browser.
Design the framework so this works:
Worker 1 → Test A
Worker 2 → Test B
Worker 3 → Test C
Worker 4 → Test D
rather than:
Test A → creates shared user
↓
Test B → modifies shared user
↓
Test C → expects Test B's state
Playwright runs test files in parallel by default and provides worker isolation mechanisms.
For example:
export default defineConfig({
workers: process.env.CI ? 4 : undefined,
fullyParallel: true,
});
But don't simply turn on maximum parallelism. Your application, database, CI infrastructure, and test data must be able to handle it.
Never hard-code:
await page.goto('https://staging.mycompany.com');
Instead:
baseURL: process.env.BASE_URL
and:
BASE_URL=https://staging.mycompany.com npm test
For more sophisticated environments:
config/
├── dev
├── qa
├── staging
└── production
Keep environment configuration separate from test behavior.
Also keep secrets out of source control:
USERNAME → CI secret
PASSWORD → CI secret
API_KEY → CI secret
When a test fails in CI, you want:
Test failed
│
├── Screenshot
├── Video (when useful)
├── Trace
├── Browser console
├── Network information
├── Error message
└── Test metadata
Don't make engineers reproduce everything locally before they can diagnose a failure.
For example, configure traces for failures:
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
}
Your framework's failure output should answer:
What failed, where did it fail, with what data, and what was the browser doing at the time?
Retries are useful for infrastructure problems.
They're dangerous when used to hide bad tests.
Bad:
Test fails
→ retry
→ passes
→ CI green
If that happens frequently, you've created a flaky-test masking system.
Instead, track:
Pass rate
Failure rate
Retry rate
Flake rate
Average execution time
A test that passes only after a retry should remain visible as unhealthy.
For example:
test('user can purchase product @smoke @checkout', ...)
Then your CI pipeline can run different suites:
Pull Request
↓
Smoke + impacted tests
Merge
↓
Regression
Nightly
↓
Full cross-browser suite
Release
↓
Full suite + production-safe tests
Playwright projects can also separate groups of tests by configuration, browser, environment, retries, timeouts, or other characteristics.
A useful pipeline looks like:
┌───────────────┐
│ Pull Request│
└───────┬───────┘
↓
Lint / Compile
↓
Unit Tests
↓
API Tests
↓
Smoke Tests
↓
┌──────────┴──────────┐
↓ ↓
Browser A Browser B
↓ ↓
└──────────┬──────────┘
↓
Report
Don't make every developer wait 45 minutes for the entire regression suite.
Use different execution tiers.
This is where maintainability really comes from.
Create rules such as:
This separation is consistent with Selenium's documented Page Object recommendations.
A common mistake is trying to build this:
AutomationFramework
├── BaseTest
├── BasePage
├── BaseComponent
├── BaseAPI
├── BaseDatabase
├── BaseFactory
├── BaseUtils
├── BaseHelper
└── BaseBaseManager
Eventually nobody understands what anything does.
Prefer small abstractions with clear ownership:
Authentication
├── LoginPage
├── AuthApi
└── auth.fixture
Orders
├── OrdersPage
├── OrderApi
└── OrderFactory
Composition is generally easier to maintain than an enormous inheritance hierarchy.
Your CI should fail when framework quality degrades.
For example:
TypeScript compilation ✓
ESLint ✓
Unit tests ✓
API tests ✓
Smoke tests ✓
No focused tests ✓
Test execution time ✓
Artifact generation ✓
Also prevent accidental:
test.only(...)
from reaching CI.
Once you have hundreds or thousands of tests, collect metrics.
Useful metrics include:
| Metric | Why |
|---|---|
| Test execution time | Detect slow suites |
| Flake rate | Detect unreliable tests |
| Failure rate | Detect application/framework problems |
| Retry rate | Detect hidden instability |
| Pass rate | Overall signal |
| Test count | Track growth |
| UI/API/unit ratio | Detect overuse of E2E |
| Mean time to diagnose | Measure observability |
| Mean time to fix | Measure maintainability |
The goal isn't simply:
"We have 5,000 automated tests." The goal is:
"Our automated tests provide trustworthy feedback quickly."
Don't attempt to build the entire framework before writing tests.
I'd build it incrementally:
Repository
TypeScript
Playwright
ESLint
Prettier
CI
Basic reporting
Environment configuration
Build one complete feature:
Test
↓
Fixture
↓
Page Object
↓
API setup
↓
Test data
↓
Reporting
↓
CI
Don't build 50 generic utilities first.
Document:
How to write a test
How to create data
How to add a page
How to add an API client
How to debug failures
How to run locally
How to tag tests
Add:
Parallelization
Cross-browser projects
Sharding
Test data isolation
More fixtures
API coverage
Advanced reporting
Flake detection
Playwright supports both worker-level parallelization and sharding-oriented execution patterns, which become useful as suites grow.
Conceptually:
CI/CD
│
▼
Test Runner
│
┌───────────┼───────────┐
▼ ▼ ▼
Smoke Regression API
│ │ │
└───────────┼───────────┘
▼
Fixtures
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Page Objects API Clients Factories
│ │ │
└─────────────┼─────────────┘
▼
Application
│
┌───────┴───────┐
▼ ▼
UI APIs
The most important architectural rule is:
Tests should describe intent; framework code should hide mechanics. If changing a button's selector requires editing 40 tests, the framework is poorly encapsulated. If changing a selector requires changing one page/component object, the architecture is doing its job. Selenium's Page Object guidance explicitly identifies reducing duplicated UI knowledge as a core maintenance benefit.
If you're starting today, I'd build the first version with TypeScript + Playwright, and deliberately keep it small:
tests/
pages/
fixtures/
api/
factories/
utils/
playwright.config.ts
Then prove the architecture against one real business workflow, run it in CI, make it parallel-safe, and only introduce another abstraction when repeated real-world code demonstrates that you need it.
That approach tends to produce a framework that grows with the application, rather than a giant automation framework that has to be maintained before it has delivered much value.
Better:
await loginPage.loginAs(user);
await expect(dashboardPage.heading).toHaveText('Dashboard');
Best, when the domain abstraction makes sense:
await application.loginAs(user);
await expect(application.dashboard).toBeVisible();
Your test should answer:
What behavior am I verifying? rather than:
Which CSS selector did I click? Selenium's Page Object guidance makes essentially this distinction: page objects should encapsulate the mechanics of interacting with the page, while tests contain the actual assertions.
Don't create giant classes containing every possible interaction.
Instead:
export class LoginPage {
constructor(private readonly page: Page) {}
private username = this.page.getByLabel('Username');
private password = this.page.getByLabel('Password');
private loginButton = this.page.getByRole('button', {
name: 'Sign in'
});
async loginAs(username: string, password: string) {
await this.username.fill(username);
await this.password.fill(password);
await this.loginButton.click();
}
}
Then:
test('valid user can log in', async ({ page }) => {
const login = new LoginPage(page);
await login.loginAs('[email protected]', 'password');
await expect(page.getByRole('heading', {
name: 'Dashboard'
})).toBeVisible();
});
The key principle is:
Page objects expose capabilities, not implementation details.
For complex applications, use component objects too:
CheckoutPage
├── Header
├── AddressForm
├── PaymentForm
└── OrderSummary
That prevents a 3,000-line CheckoutPage class.
Fixtures are one of the most important mechanisms for making a framework maintainable.
For example:
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
type Fixtures = {
loginPage: LoginPage;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
}
});
Now tests simply consume the dependency:
test('user can log in', async ({ loginPage }) => {
await loginPage.loginAs(
'[email protected]',
'password'
);
});
This becomes extremely powerful as the framework grows:
test
├── browser
├── authenticatedUser
├── loginPage
├── dashboardPage
├── apiClient
├── database
└── testData
Playwright fixtures are isolated between tests and can be composed and reused across test files.
Test data is one of the biggest sources of automation failures.
Avoid:
const user = {
email: '[email protected]'
};
when dozens of tests depend on the same account.
Instead, use factories:
export function createUser(overrides = {}) {
return {
firstName: `Test${randomString()}`,
lastName: 'User',
email: `test-${randomString()}@example.com`,
...overrides
};
}
Then:
const user = createUser({
role: 'admin'
});
For larger systems, have explicit lifecycle mechanisms:
Test
│
├── Create required data
│
├── Execute behavior
│
└── Clean up data
Or use disposable test environments/databases where possible.
Never make Test B depend on Test A creating something.
Playwright explicitly recommends test isolation because shared state makes tests break when execution order changes or tests run in parallel.
Suppose you need an order with five products.
Don't do:
Open browser
→ Login
→ Search product
→ Add product
→ Search product
→ Add product
→ Checkout
→ ...
→ Finally test something
Instead:
API:
create user
create products
create order
UI:
open order
verify behavior
This dramatically reduces execution time and flakiness.
The browser should test the behavior that actually needs a browser.
Design the framework so this works:
Worker 1 → Test A
Worker 2 → Test B
Worker 3 → Test C
Worker 4 → Test D
rather than:
Test A → creates shared user
↓
Test B → modifies shared user
↓
Test C → expects Test B's state
Playwright runs test files in parallel by default and provides worker isolation mechanisms.
For example:
export default defineConfig({
workers: process.env.CI ? 4 : undefined,
fullyParallel: true,
});
But don't simply turn on maximum parallelism. Your application, database, CI infrastructure, and test data must be able to handle it.
Never hard-code:
await page.goto('https://staging.mycompany.com');
Instead:
baseURL: process.env.BASE_URL
and:
BASE_URL=https://staging.mycompany.com npm test
For more sophisticated environments:
config/
├── dev
├── qa
├── staging
└── production
Keep environment configuration separate from test behavior.
Also keep secrets out of source control:
USERNAME → CI secret
PASSWORD → CI secret
API_KEY → CI secret
When a test fails in CI, you want:
Test failed
│
├── Screenshot
├── Video (when useful)
├── Trace
├── Browser console
├── Network information
├── Error message
└── Test metadata
Don't make engineers reproduce everything locally before they can diagnose a failure.
For example, configure traces for failures:
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
}
Your framework's failure output should answer:
What failed, where did it fail, with what data, and what was the browser doing at the time?
Retries are useful for infrastructure problems.
They're dangerous when used to hide bad tests.
Bad:
Test fails
→ retry
→ passes
→ CI green
If that happens frequently, you've created a flaky-test masking system.
Instead, track:
Pass rate
Failure rate
Retry rate
Flake rate
Average execution time
A test that passes only after a retry should remain visible as unhealthy.
For example:
test('user can purchase product @smoke @checkout', ...)
Then your CI pipeline can run different suites:
Pull Request
↓
Smoke + impacted tests
Merge
↓
Regression
Nightly
↓
Full cross-browser suite
Release
↓
Full suite + production-safe tests
Playwright projects can also separate groups of tests by configuration, browser, environment, retries, timeouts, or other characteristics.
A useful pipeline looks like:
┌───────────────┐
│ Pull Request│
└───────┬───────┘
↓
Lint / Compile
↓
Unit Tests
↓
API Tests
↓
Smoke Tests
↓
┌──────────┴──────────┐
↓ ↓
Browser A Browser B
↓ ↓
└──────────┬──────────┘
↓
Report
Don't make every developer wait 45 minutes for the entire regression suite.
Use different execution tiers.
This is where maintainability really comes from.
Create rules such as:
This separation is consistent with Selenium's documented Page Object recommendations.
A common mistake is trying to build this:
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Instead of just writing scripts, you are building a software product whose sole job is to test another software product. Follow this step-by-step roadmap to get it right: 1. **Define the Scope and…
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Instead of just writing scripts, you are building a software product whose sole job is to test another software product.
Follow this step-by-step roadmap to get it right:
If you'd like, let me know:
I can provide a recommended folder structure and starter code snippet tailored to your stack.
Winston
No sources cited
A scalable test automation framework is less about clever abstractions and more about **clear boundaries, isolated tests, reliable test data, fast feedback, and good diagnostics**. For a modern web application, a strong starting stack is **Playwright + TypeScript + a CI system**, although the architecture below also…
A scalable test automation framework is less about clever abstractions and more about clear boundaries, isolated tests, reliable test data, fast feedback, and good diagnostics.
For a modern web application, a strong starting stack is Playwright + TypeScript + a CI system, although the architecture below also applies to Selenium, Cypress, Java, Python, etc. Playwright specifically provides isolated browser contexts, fixtures, parallelism, sharding, and CI support that map well to a scalable design.
A useful layered architecture looks like this:
┌──────────────────────┐
│ Test Specs │
│ business scenarios │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Test Flows │
│ reusable workflows │
└──────────┬───────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Page Objects│ │ API Clients │ │ Test Data │
│ Components │ │ / Services │ │ Factories │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└─────────────────┼─────────────────┘
│
┌──────────▼───────────┐
│ Framework Core │
│ config / fixtures / │
│ logging / reporting │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Browser / API / DB │
└──────────────────────┘
The key principle is:
Tests describe behavior; framework code handles mechanics. A test shouldn't contain dozens of selectors, HTTP calls, database queries, waits, and environment checks.
Before writing code, establish requirements.
Decide whether you need:
Define targets for:
For example:
Local:
Smoke tests
Debugging
One browser
Pull request:
Critical regression suite
Chromium
Parallel execution
Nightly:
Full regression
Multiple browsers
API + UI
Extended integration tests
This prevents the framework from becoming a giant collection of features nobody needs.
For example:
Language: TypeScript
UI: Playwright
API: Playwright APIRequestContext / Axios
Assertions: Playwright expect
Test runner: Playwright Test
Linting: ESLint
Formatting: Prettier
CI: GitHub Actions / GitLab / Jenkins / Azure DevOps
Reporting: HTML + CI artifacts
Container: Docker
Playwright's own guidance recommends user-facing locators, isolated tests, linting, and parallelism/sharding for larger suites.
The important thing isn't Playwright itself. The important thing is choosing a stack that gives you:
For example:
automation/
│
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── logout.spec.ts
│ │
│ ├── checkout/
│ │ ├── checkout.spec.ts
│ │ └── payment.spec.ts
│ │
│ └── users/
│ └── users.spec.ts
│
├── pages/
│ ├── LoginPage.ts
│ ├── HomePage.ts
│ ├── CheckoutPage.ts
│ └── components/
│ ├── Header.ts
│ └── ProductCard.ts
│
├── api/
│ ├── UserApi.ts
│ ├── OrderApi.ts
│ └── AuthApi.ts
│
├── fixtures/
│ ├── test.fixture.ts
│ ├── auth.fixture.ts
│ └── data.fixture.ts
│
├── data/
│ ├── users.ts
│ ├── products.ts
│ └── factories/
│ ├── UserFactory.ts
│ └── OrderFactory.ts
│
├── utils/
│ ├── dates.ts
│ ├── files.ts
│ └── random.ts
│
├── config/
│ ├── environments.ts
│ └── browsers.ts
│
├── reports/
│
├── playwright.config.ts
├── package.json
└── tsconfig.json
Don't create dozens of folders on day one. Start small and introduce boundaries when the suite actually needs them.
Keep tests readable:
test('user can place an order', async ({ checkoutPage }) => {
await checkoutPage.addProduct('Laptop');
await checkoutPage.proceedToCheckout();
await checkoutPage.completePayment();
await expect(checkoutPage.confirmationMessage)
.toHaveText('Order confirmed');
});
Someone unfamiliar with the framework should be able to understand what the test is doing.
Instead of this:
await page.locator('#checkout-button').click();
await page.locator('#card-number').fill(cardNumber);
await page.locator('#submit').click();
encapsulate the mechanics:
export class CheckoutPage {
constructor(private readonly page: Page) {}
async completePayment(cardNumber: string) {
await this.page.getByLabel('Card number').fill(cardNumber);
await this.page.getByRole('button', { name: 'Pay' }).click();
}
}
This is essentially the Page Object pattern. Selenium's documentation describes the main benefit as separating page-specific implementation from test code so UI changes can be handled in one place. It also recommends that page objects generally expose application behavior rather than assertions.
Avoid:
ApplicationPage.ts
4,000 lines
200 methods
Prefer:
CheckoutPage
PaymentForm
AddressForm
OrderSummary
Header
ProductCard
Component objects are particularly useful when pieces of UI recur across pages.
Fixtures should establish the environment required by a test.
For example:
export const test = base.extend<{
authenticatedPage: Page;
}>({
authenticatedPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
await login(page);
await use(page);
await context.close();
},
});
Then tests become:
test('user can view profile', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
await expect(
authenticatedPage.getByRole('heading', { name: 'Profile' })
).toBeVisible();
});
Fixtures are valuable because setup/teardown becomes reusable and composable instead of being copied into every test. Playwright specifically recommends fixtures for reusable setup and supports both test- and worker-scoped fixtures.
This is one of the biggest determinants of scalability.
Bad:
Test A creates user
↓
Test B uses that user
↓
Test C modifies that user
Now:
Test A ──> its own user
Test B ──> its own user
Test C ──> its own user
Each test should be runnable independently.
Playwright's browser-context model gives each test an isolated context with separate cookies, local storage, and session storage. Its documentation specifically identifies isolation as important for reproducibility, debugging, and parallel execution.
A good rule:
A test may depend on application state it creates, but never on another test having run first.
Test data becomes one of the biggest maintenance problems in large suites.
Avoid:
const username = "john123";
const email = "[email protected]";
because parallel tests will eventually collide.
Instead:
const user = UserFactory.create();
producing something like:
{
firstName: "Test",
lastName: "User",
email: "[email protected]"
}
Better still, create data through APIs when possible:
const user = await userApi.createUser(
UserFactory.create()
);
Then use the UI to test the UI behavior.
This produces a powerful pattern:
API → setup data
UI → test user behavior
API → validate backend state
You don't need to perform five UI screens of setup every time you want to test one checkout scenario.
Instead of:
test('premium customer receives discount', async () => {
// 100 lines creating customer/product/order...
});
use:
const customer = await customerFactory.create({
type: 'premium'
});
const product = await productFactory.create({
price: 100
});
await checkoutPage.purchase(product);
The test expresses the scenario rather than the mechanics.
There is an important distinction between page objects and business workflows.
Page object:
loginPage.login(username, password)
Workflow:
await userFlows.createAuthenticatedUser();
A workflow might combine several pages and API operations:
class CheckoutFlow {
async purchaseProduct(product: Product) {
await catalog.search(product.name);
await catalog.addToCart(product);
await cart.checkout();
await payment.pay();
}
}
Use workflows for genuinely repeated business processes.
Don't abstract every two lines of code. Excessive abstraction can make tests harder to understand.
Prefer stable, user-facing selectors.
For example:
page.getByRole('button', { name: 'Submit' })
or:
page.getByLabel('Email')
over brittle selectors such as:
page.locator('div:nth-child(3) > span > button')
Playwright recommends prioritizing user-facing attributes and explicit contracts, with its locators providing automatic waiting and retry behavior.
If your team owns the application, establish a selector contract such as:
<button data-testid="checkout-submit">
when semantic selectors aren't appropriate.
This:
await page.waitForTimeout(5000);
is usually a smell.
Prefer waiting for a meaningful condition:
await expect(
page.getByRole('heading', { name: 'Order confirmed' })
).toBeVisible();
The test should wait for the thing that proves the application is ready, not an arbitrary amount of time.
A failed test should answer:
What happened? Your CI artifacts should ideally contain:
Test result
│
├── Screenshot
├── Trace
├── Browser console
├── Network information
├── Application logs
├── Test data ID
└── Environment/version
For example:
FAILED: checkout.spec.ts
Test: user can purchase product
Environment: staging
Browser: Chromium
Commit: a81f32c
User: [email protected]
Product: SKU-123
Screenshot: ...
Trace: ...
Console: ...
This dramatically reduces debugging time.
Retries are useful for infrastructure failures.
They are dangerous when used to hide flaky tests.
Bad:
Test fails
↓
retry
↓
passes
↓
green
and everyone assumes everything is fine.
Instead, track:
PASS
FAIL
FLAKY
BLOCKED
A test that only passes after retry should generate visibility rather than disappearing into a green pipeline.
Your framework should eventually support:
Test Suite
│
┌─────────┼─────────┐
↓ ↓ ↓
Worker 1 Worker 2 Worker 3
│ │ │
100 100 100
tests tests tests
This means avoiding:
Playwright supports both parallel execution and sharding across machines. Its guidance also emphasizes keeping tests independent because state leakage breaks parallel execution and order-independent runs.
A useful taxonomy is:
All tests
│
┌─────────┼─────────┐
↓ ↓ ↓
API UI Integration
│ │
│ ┌────┴─────┐
│ ↓ ↓
│ Smoke Regression
│
└── Contract
For example:
login
homepage
search
checkout
logout
Fast and highly valuable.
Broad functional coverage.
Fast validation of backend behavior.
Interactions between services.
Reserve for scenarios where the complete user journey matters.
This prevents the UI suite from becoming the only form of testing.
Don't scatter URLs and credentials throughout tests.
Use:
export const config = {
baseURL: process.env.BASE_URL!,
environment: process.env.TEST_ENV ?? 'local',
};
Then:
BASE_URL=https://staging.example.com npx playwright test
Secrets should come from your CI secret manager/environment, never from source control.
A mature pipeline might look like:
Developer PR
│
▼
Lint + Typecheck
│
▼
API/Unit checks
│
▼
Smoke UI
│
├── FAIL → PR blocked
│
▼
Merge
│
▼
Regression
│
▼
Nightly cross-browser suite
Playwright provides CI guidance and supports running browsers in CI, while sharding can distribute larger suites across multiple jobs.
Create rules such as:
✓ Tests contain business assertions
✓ Page objects contain UI mechanics
✓ API clients contain API mechanics
✓ Fixtures contain setup/teardown
✓ Factories create test data
✓ No hard waits
✓ No test depends on another test
✓ No credentials in source control
✓ No duplicated selectors
✓ No arbitrary retries
✓ Tests must be runnable individually
Put these in a CONTRIBUTING.md document and enforce what can be enforced automatically.
Once the suite grows, metrics become important.
Track:
Total tests
Pass rate
Failure rate
Flaky rate
Average duration
P95 duration
Execution time by suite
Retry rate
Failure categories
For example:
Current
Tests 1,248
Pass 96.8%
Flaky 1.4%
Avg duration 42 min
P95 duration 71 min
Retries 3.1%
These measurements tell you where scalability problems actually exist.
Don't spend three months building a "perfect framework" before writing tests.
A much better progression is:
Playwright
TypeScript
configuration
one test
CI
basic reporting
Page objects
components
fixtures
test data factories
API helpers
coding standards
parallel execution
sharding
multiple environments
cross-browser
test tagging
advanced reporting
flaky-test tracking
failure diagnostics
automatic artifact collection
test-health metrics
quarantine process
API-based setup
smaller UI suite
worker-level fixtures
test distribution
containerization
execution-time optimization
I'd use something like this as the standard every new test should aspire to:
import { test, expect } from '../fixtures/test.fixture';
test.describe('Checkout', () => {
test('customer can purchase a product', async ({
checkoutFlow,
productFactory,
customerFactory
}) => {
const customer = await customerFactory.create();
const product = await productFactory.create();
await checkoutFlow.login(customer);
await checkoutFlow.purchase(product);
await expect(
checkoutFlow.confirmation
).toContainText('Order confirmed');
});
});
Notice what's not here:
❌ CSS selectors
❌ XPath
❌ arbitrary sleeps
❌ database SQL
❌ environment URLs
❌ passwords
❌ browser creation
❌ test cleanup
❌ implementation details
That's the real goal of the architecture.
If you remember only a few things, make them these:
The biggest mistake is building a huge "framework" full of generic utilities before you have enough tests to know what abstractions are actually needed. Start with a thin architecture, let real tests reveal repetition, then abstract that repetition deliberately.
A useful taxonomy is:
All tests
│
┌─────────┼─────────┐
↓ ↓ ↓
API UI Integration
│ │
│ ┌────┴─────┐
│ ↓ ↓
│ Smoke Regression
│
└── Contract
For example:
login
homepage
search
checkout
logout
Fast and highly valuable.
Broad functional coverage.
Fast validation of backend behavior.
Interactions between services.
Reserve for scenarios where the complete user journey matters.
This prevents the UI suite from becoming the only form of testing.
Don't scatter URLs and credentials throughout tests.
Use:
export const config = {
baseURL: process.env.BASE_URL!,
environment: process.env.TEST_ENV ?? 'local',
};
Then:
BASE_URL=https://staging.example.com npx playwright test
Secrets should come from your CI secret manager/environment, never from source control.
A mature pipeline might look like:
Developer PR
│
▼
Lint + Typecheck
│
▼
API/Unit checks
│
▼
Smoke UI
│
├── FAIL → PR blocked
│
▼
Merge
│
▼
Regression
│
▼
Nightly cross-browser suite
Playwright provides CI guidance and supports running browsers in CI, while sharding can distribute larger suites across multiple jobs.
Create rules such as:
✓ Tests contain business assertions
✓ Page objects contain UI mechanics
✓ API clients contain API mechanics
✓ Fixtures contain setup/teardown
✓ Factories create test data
✓ No hard waits
✓ No test depends on another test
✓ No credentials in source control
✓ No duplicated selectors
✓ No arbitrary retries
✓ Tests must be runnable individually
Put these in a CONTRIBUTING.md document and enforce what can be enforced automatically.
Once the suite grows, metrics become important.
Track:
Total tests
Pass rate
Failure rate
Flaky rate
Average duration
P95 duration
Execution time by suite
Retry rate
Failure categories
For example:
Current
Tests 1,248
Pass 96.8%
Flaky 1.4%
Avg duration 42 min
P95 duration 71 min
Retries 3.1%
These measurements tell you where scalability problems actually exist.
Don't spend three months building a "perfect framework" before writing tests.
A much better progression is:
Playwright
TypeScript
configuration
one test
CI
basic reporting
Page objects
components
fixtures
test data factories
API helpers
coding standards
parallel execution
sharding
multiple environments
cross-browser
test tagging
advanced reporting
flaky-test tracking
failure diagnostics
automatic artifact collection
test-health metrics
quarantine process
API-based setup
smaller UI suite
worker-level fixtures
test distribution
containerization
execution-time optimization
I'd use something like this as the standard every new test should aspire to:
import { test, expect } from '../fixtures/test.fixture';
test.describe('Checkout', () => {
test('customer can purchase a product', async ({
checkoutFlow,
productFactory,
customerFactory
}) => {
const customer = await customerFactory.create();
const product = await productFactory.create();
await checkoutFlow.login(customer);
await checkoutFlow.purchase(product);
await expect(
checkoutFlow.confirmation
).toContainText('Order confirmed');
});
});
Notice what's not here:
❌ CSS selectors
❌ XPath
❌ arbitrary sleeps
❌ database SQL
❌ environment URLs
❌ passwords
❌ browser creation
❌ test cleanup
❌ implementation details
That's the real goal of the architecture.
If you remember only a few things, make them these:
The biggest mistake is building a huge "framework" full of generic utilities before you have enough tests to know what abstractions are actually needed. Start with a thin architecture, let real tests reveal repetition, then abstract that repetition deliberately.
A scalable test automation framework is less about choosing Selenium vs. Playwright and more about **architecture, isolation, test-data strategy, observability, and CI execution**. For a new web application, I’d generally start with **Playwright + TypeScript** unless you have an organizational reason to use Selenium.…
A scalable test automation framework is less about choosing Selenium vs. Playwright and more about architecture, isolation, test-data strategy, observability, and CI execution.
For a new web application, I’d generally start with Playwright + TypeScript unless you have an organizational reason to use Selenium. Playwright has built-in isolation, fixtures, parallel execution, and sharding, which are particularly useful as the suite grows.
Don't begin by creating BaseTest, DriverFactory, and hundreds of utilities.
First decide what belongs at each testing level:
Browser tests are relatively expensive, so Selenium itself recommends considering whether a lower-level test can cover something before putting it into browser automation.
A healthy distribution might look roughly like:
UI / E2E
/ \
API UI smoke
/ \
Integration
/
Unit
The exact percentages aren't important. The principle is: don't make your UI suite responsible for testing everything.
A good framework separates test intent from automation mechanics.
For example:
tests/
├── auth/
│ ├── login.spec.ts
│ └── logout.spec.ts
├── checkout/
│ ├── checkout.spec.ts
│ └── payment.spec.ts
└── users/
└── registration.spec.ts
pages/
├── LoginPage.ts
├── HomePage.ts
├── CheckoutPage.ts
└── components/
├── Header.ts
└── ProductCard.ts
fixtures/
├── test.fixtures.ts
├── auth.fixture.ts
└── data.fixture.ts
api/
├── UserApi.ts
├── OrderApi.ts
└── PaymentApi.ts
data/
├── users.ts
├── products.ts
└── testDataFactory.ts
utils/
├── dates.ts
├── random.ts
└── environment.ts
config/
├── environments.ts
└── test.config.ts
reports/
The important dependency direction is:
Test
↓
Business/domain layer
↓
Page/API objects
↓
Framework/driver
Not:
Test
↓
WebDriver everywhere
↓
CSS selectors everywhere
↓
Random utility classes
A test should read almost like a specification.
Instead of:
await page.locator('#email').fill('[email protected]');
await page.locator('#password').fill('secret');
await page.locator('.login-btn').click();
await expect(page.locator('.welcome')).toHaveText('Welcome John');
prefer:
await loginPage.loginAs(user);
await expect(homePage.welcomeMessage).toHaveText('Welcome John');
Or, even better, expose business-level operations:
await user.login();
await expect(homePage).toShowWelcomeMessage(user);
The underlying selectors and mechanics should live elsewhere.
This is essentially the motivation behind the Page Object pattern: UI changes should generally require changes in one place rather than throughout the test suite. Selenium's guidance also recommends keeping assertions in the tests rather than embedding test verification inside page objects.
BasePageThis is a common framework mistake.
Avoid:
class BasePage {
click();
type();
select();
wait();
screenshot();
login();
logout();
createUser();
deleteUser();
...
}
It eventually becomes a dumping ground.
Instead, use small abstractions:
Page objects
Component objects
API clients
Data factories
Authentication fixtures
Infrastructure utilities
For example:
class LoginPage {
async loginAs(user: User) {
await this.email.fill(user.email);
await this.password.fill(user.password);
await this.loginButton.click();
}
}
And:
class UserApi {
async createUser(data: CreateUserRequest) {
// API interaction
}
async deleteUser(id: string) {
// API interaction
}
}
This keeps responsibilities clear.
This becomes one of the biggest scalability problems.
Don't do this:
const user = {
email: "[email protected]",
name: "Test User"
};
in 200 tests.
Instead create factories:
const user = UserFactory.create({
role: "admin"
});
Or:
const user = await users.createAdmin();
Your test should care about what data it needs, not how that data is constructed.
For example:
test('admin can delete an order', async ({ adminUser, order }) => {
await adminUser.login();
await ordersPage.delete(order);
await expect(ordersPage).not.toContain(order);
});
The fixture can take care of creating the admin and order.
This is probably the single most important scalability rule.
Tests should be able to run:
A
B
C
or:
C
A
B
or:
A + B + C simultaneously
and produce the same result.
Avoid:
let createdUser;
test('create user', ...);
test('edit user', () => {
// relies on previous test
});
Instead:
test('edit user', async () => {
const user = await userFactory.create();
// test
});
Playwright specifically recommends isolated tests with independent storage, sessions, cookies, and backend data. Its browser contexts provide clean isolated environments, and its documentation explicitly warns against tests depending on another test's side effects.
Don't wait until you have 5,000 tests.
Your framework should be able to go from:
100 tests
↓
1 worker
to:
5,000 tests
↓
50 workers
↓
multiple CI machines
without rewriting tests.
This means avoiding:
Generate unique test data:
const orderId = `order-${testInfo.testId}`;
Playwright supports parallel workers and sharding specifically for scaling execution across machines.
Fixtures are one of the cleanest ways to make a framework maintainable.
For example:
test('admin can approve order', async ({
adminUser,
order,
ordersPage
}) => {
await adminUser.login();
await ordersPage.approve(order);
await expect(ordersPage.status(order))
.toHaveText('Approved');
});
The test doesn't need to know:
Those concerns belong in fixtures.
Playwright's fixture model is explicitly designed to establish the environment required by a test while keeping fixtures isolated.
Your locator strategy can make or break maintainability.
Prefer:
page.getByRole('button', { name: 'Submit' })
or:
page.getByLabel('Email')
over:
page.locator('div:nth-child(4) > div > button')
The guiding principle is to interact with the application in ways resembling the user's interaction rather than coupling tests to implementation details.
If necessary, add dedicated test attributes:
<button data-testid="submit-order">
Submit
</button>
Then:
page.getByTestId('submit-order')
A scalable framework shouldn't just say:
FAILED: Login test
It should answer:
What happened, where, and why? Capture automatically:
Your CI report should let a developer go from:
FAILED
to:
FAILED
→ Checkout
→ Payment authorization
→ HTTP 500
→ request ID abc123
→ screenshot
→ trace
without reproducing the failure locally.
Don't hardcode:
page.goto('https://production.myapp.com');
Instead:
const config = environments[process.env.ENVIRONMENT ?? 'qa'];
await page.goto(config.baseUrl);
For example:
local
dev
qa
staging
production
Configuration should control:
Secrets should come from your CI secret manager/environment—not from Git.
A mature pipeline might look like:
Pull Request
│
├── lint
├── type-check
├── unit tests
└── API/smoke tests
│
▼
Build / Deploy
│
▼
E2E smoke tests
│
▼
Parallel regression
│
┌─────┴─────┐
▼ ▼
Browser A Browser B
│ │
└─────┬─────┘
▼
Report
Don't run the entire regression suite on every developer commit if it takes an hour.
Create execution tiers:
@smoke → ~5 minutes
@critical → ~15 minutes
@regression → full suite
@nightly → expensive/cross-browser scenarios
Retries are useful for infrastructure/transient failures.
They're dangerous when they're used to hide flaky tests.
Bad:
Test fails
→ retry
→ passes
→ pipeline green
→ nobody investigates
Instead track:
pass
fail
flaky
blocked
skipped
A test that fails 1% of the time is not "green."
Create a flaky-test quarantine process:
Failure
↓
Classify
├── Product defect
├── Automation defect
├── Environment issue
└── Flaky
↓
Quarantine
↓
Fix + restore
Once the suite becomes important, collect metrics.
Useful metrics include:
| Metric | Why |
|---|---|
| Execution time | Detect scaling problems |
| Pass rate | Overall health |
| Flake rate | Test reliability |
| Failure rate by test | Find problematic areas |
| Retry rate | Detect hidden instability |
| Mean time to diagnose | Framework observability |
| Mean time to fix | Maintenance efficiency |
| Coverage by feature | Test strategy |
| Parallel utilization | CI efficiency |
A framework isn't scalable merely because it can execute 10,000 tests.
It's scalable when 10,000 tests remain understandable and trustworthy.
Create conventions before multiple engineers contribute.
For example:
*.spec.ts → tests
*Page.ts → page objects
*Component.ts → UI components
*Api.ts → API clients
*Factory.ts → test data
*.fixture.ts → fixtures
Define rules for:
Then enforce them with:
ESLint
TypeScript
Prettier
code review
CI checks
Playwright's current guidance, for example, recommends TypeScript and linting, including checks that catch missing async handling.
For a TypeScript/Playwright framework, I'd start around this:
automation/
│
├── tests/
│ ├── smoke/
│ ├── regression/
│ ├── api/
│ └── e2e/
│
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
│
├── components/
│ ├── Header.ts
│ ├── Modal.ts
│ └── ProductCard.ts
│
├── fixtures/
│ ├── base.fixture.ts
│ ├── auth.fixture.ts
│ └── data.fixture.ts
│
├── api/
│ ├── UserApi.ts
│ ├── OrderApi.ts
│ └── AuthApi.ts
│
├── factories/
│ ├── UserFactory.ts
│ ├── OrderFactory.ts
│ └── ProductFactory.ts
│
├── utils/
│ ├── date.ts
│ ├── random.ts
│ └── logger.ts
│
├── config/
│ ├── environments.ts
│ └── test.config.ts
│
├── playwright.config.ts
├── package.json
└── README.md
The key is not the exact directory names. It's that each layer has one clear responsibility.
I would implement the framework in this order:
Build one complete feature:
Test
↓
Fixture
↓
Page Object
↓
API/Data Factory
↓
Assertion
↓
Report
↓
CI
Don't build 50 utilities before proving this works.
Add:
Add:
Add:
Ultimately, your tests should look something like:
test('admin can approve an order', async ({
adminUser,
order,
ordersPage
}) => {
await adminUser.login();
await ordersPage.open(order);
await ordersPage.approve();
await expect(ordersPage.status)
.toHaveText('Approved');
});
While the framework underneath handles:
┌──────────────────────┐
│ TESTS │
│ business behavior │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ FIXTURES │
│ auth / data / setup │
└──────────┬───────────┘
│
┌───────────┴───────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ UI Layer │ │ API Layer │
│ Pages │ │ Clients │
│ Components│ │ │
└─────┬─────┘ └─────┬─────┘
│ │
└───────────┬───────────┘
▼
┌────────────────┐
│ Infrastructure │
│ browser / data │
│ config / logs │
└────────────────┘
The most important principle: make the tests express business behavior, while everything volatile—selectors, browser mechanics, authentication, data creation, environment configuration, and infrastructure—stays behind well-defined abstractions.
That gives you a framework that can grow from dozens to thousands of tests without turning every UI change into a maintenance project.
If you're starting today, I'd choose Playwright + TypeScript + fixtures + API-based test-data setup + isolated tests + parallel CI workers, and only introduce additional abstractions when a real maintenance problem justifies them.
Add:
Add:
Ultimately, your tests should look something like:
const config = environments[process.env.ENVIRONMENT ?? 'qa'];
await page.goto(config.baseUrl);
For example:
local
dev
qa
staging
production
Configuration should control:
Secrets should come from your CI secret manager/environment—not from Git.
A mature pipeline might look like:
Pull Request
│
├── lint
├── type-check
├── unit tests
└── API/smoke tests
│
▼
Build / Deploy
│
▼
E2E smoke tests
│
▼
Parallel regression
│
┌─────┴─────┐
▼ ▼
Browser A Browser B
│ │
└─────┬─────┘
▼
Report
Don't run the entire regression suite on every developer commit if it takes an hour.
Create execution tiers:
@smoke → ~5 minutes
@critical → ~15 minutes
@regression → full suite
@nightly → expensive/cross-browser scenarios
Retries are useful for infrastructure/transient failures.
They're dangerous when they're used to hide flaky tests.
Bad:
Test fails
→ retry
→ passes
→ pipeline green
→ nobody investigates
Instead track:
pass
fail
flaky
blocked
skipped
A test that fails 1% of the time is not "green."
Create a flaky-test quarantine process:
Failure
↓
Classify
├── Product defect
├── Automation defect
├── Environment issue
└── Flaky
↓
Quarantine
↓
Fix + restore
Once the suite becomes important, collect metrics.
Useful metrics include:
| Metric | Why |
|---|---|
| Execution time | Detect scaling problems |
| Pass rate | Overall health |
| Flake rate | Test reliability |
| Failure rate by test | Find problematic areas |
| Retry rate | Detect hidden instability |
| Mean time to diagnose | Framework observability |
| Mean time to fix | Maintenance efficiency |
| Coverage by feature | Test strategy |
| Parallel utilization | CI efficiency |
A framework isn't scalable merely because it can execute 10,000 tests.
It's scalable when 10,000 tests remain understandable and trustworthy.
Create conventions before multiple engineers contribute.
For example:
*.spec.ts → tests
*Page.ts → page objects
*Component.ts → UI components
*Api.ts → API clients
*Factory.ts → test data
*.fixture.ts → fixtures
Define rules for:
Then enforce them with:
ESLint
TypeScript
Prettier
code review
CI checks
Playwright's current guidance, for example, recommends TypeScript and linting, including checks that catch missing async handling.
For a TypeScript/Playwright framework, I'd start around this:
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Instead of just writing scripts, you are building a software product that tests other software products. Here is a step-by-step blueprint to design it right: 1. **Define the Scope and Tech Stack**
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Instead of just writing scripts, you are building a software product that tests other software products.
Here is a step-by-step blueprint to design it right:
login_page.login(user, pass))..env, config.json , or yaml).Thread.sleep ). Rely on intelligent, dynamic waits for elements or API responses.pytest-xdist or Playwright's native workers) to cut down execution time.If you'd like, let me know:
I can give you a tailored folder structure and starter tech stack recommendation.
No sources cited
Building a scalable and maintainable test automation framework from scratch requires treating your test code with the same architectural rigor as your production code. A robust framework ensures high reliability, fast feedback loops, and low maintenance overhead as your product grows. Here is a step-by-step blueprint…
Building a scalable and maintainable test automation framework from scratch requires treating your test code with the same architectural rigor as your production code. A robust framework ensures high reliability, fast feedback loops, and low maintenance overhead as your product grows.
Here is a step-by-step blueprint to build one from the ground up:
Select tools that match your application's architecture and your team's core competency.
Decouple your test scripts from the underlying UI structure.
login(username, password)).Organize your project directory into distinct, logical layers:
Scalability relies heavily on execution speed.
beforeEach, afterEach ) to spin up clean states or reset data via API.Automation left on a local machine provides zero value.
process.env ) to dynamically switch between staging, QA, and production environments.sleep() or wait() statements.If you'd like, let me know:
I can give you a suggested folder structure and sample boilerplate code to get started.
No sources cited
A scalable test automation framework is less about picking a tool and more about designing a **software system for testing**. The goal is to make tests easy to write, reliable to run, simple to debug, and inexpensive to maintain as the application and team grow. A good framework usually separates test intent, reusable…
A scalable test automation framework is less about picking a tool and more about designing a software system for testing. The goal is to make tests easy to write, reliable to run, simple to debug, and inexpensive to maintain as the application and team grow. A good framework usually separates test intent, reusable actions, data, configuration, execution, and reporting.
A practical approach:
Before writing code, decide:
Avoid building a generic "automation platform" before you know the real needs. Start with a small, valuable slice and evolve.
Pick based on your team's skills and application needs.
Common choices:
| Area | Options |
|---|---|
| Language | Java, Python, JavaScript/TypeScript, C# |
| Web UI | Playwright, Selenium, Cypress |
| API | REST clients, HTTP libraries, Postman/Newman |
| Test runner | JUnit, TestNG, pytest, NUnit, Playwright Test |
| Reporting | Allure, HTML reports, dashboards |
| CI/CD | GitHub Actions, Jenkins, GitLab CI, Azure DevOps |
For many modern web applications, Playwright with TypeScript is a popular starting point because of built-in browser automation features and parallel execution support.
A maintainable framework separates responsibilities.
Example structure:
automation-framework/
│
├── tests/
│ ├── login.spec.ts
│ ├── checkout.spec.ts
│
├── pages/
│ ├── LoginPage.ts
│ ├── CheckoutPage.ts
│
├── components/
│ ├── Header.ts
│ └── Modal.ts
│
├── api/
│ ├── UserClient.ts
│ └── OrderClient.ts
│
├── fixtures/
│ └── testFixtures.ts
│
├── data/
│ ├── users.json
│ └── environments.json
│
├── utils/
│ ├── Logger.ts
│ ├── DateHelper.ts
│ └── RetryHelper.ts
│
├── config/
│ ├── dev.config.ts
│ └── prod.config.ts
│
├── reports/
│
└── ci/
└── pipeline.yml
A typical flow:
Test Case
↓
Business Workflow
↓
Page Objects / API Clients
↓
Automation Library
↓
Application
Your tests should describe business behavior, not technical steps.
Poor:
await page.locator("#email").fill("[email protected]");
await page.locator("#password").fill("123456");
await page.locator("#loginButton").click();
Better:
await loginPage.login(validUser);
The test should answer:
"Can a customer log in?" not:
"How do I click three HTML elements?"
Useful patterns:
Encapsulates UI details:
class LoginPage {
constructor(private page) {}
async login(username, password) {
await this.page.fill("#username", username);
await this.page.fill("#password", password);
await this.page.click("#login");
}
}
Benefits:
For larger systems, consider component objects or workflow/service layers instead of putting everything into page classes.
Avoid:
test_user = "[email protected]"
password = "password123"
Instead:
data/
├── users.json
├── products.json
└── permissions.json
Example:
{
"validUser": {
"email": "[email protected]",
"role": "admin"
}
}
Advantages:
Never hardcode:
Example:
config/
├── dev.json
├── staging.json
└── production.json
Run:
ENV=staging npm test
Large suites fail when tests depend on each other.
Avoid:
Prefer:
Parallel execution requires isolation, not just increasing worker count.
A failed test should answer:
Capture:
Example failure artifact:
reports/
└── checkout_failure/
├── screenshot.png
├── video.mp4
└── trace.zip
A mature pipeline might look like:
Pull Request
|
↓
Lint + Static Checks
|
↓
Unit Tests
|
↓
API Tests
|
↓
Critical UI Smoke Tests
|
↓
Full Regression Suite
|
↓
Report + Notifications
Include:
Retries should help identify temporary infrastructure issues, not hide flaky tests.
Treat automation code like production code.
Establish:
Example:
Good:
should_allow_admin_user_to_delete_order()
Bad:
test123()
As the framework grows, create:
Who maintains:
Maintain:
Track:
Build:
Add:
Add:
A scalable framework should feel like an internal product: simple for contributors, reliable for CI, and adaptable as the application changes.
If you are starting from zero, a strong default architecture today would be:
TypeScript + Playwright + Page/Component Objects + API clients + external test data + CI parallel execution + Allure-style reporting.
If you are starting from zero, a strong default architecture today would be:
Building a scalable and maintainable test automation framework from scratch requires treating your test code with the same architectural rigor as your production code. 1. Choose the Right Tech Stack & Architecture - Core Language: Pick a language your team is proficient in (e.g., Python, TypeScript/JavaScript, Java,…
Building a scalable and maintainable test automation framework from scratch requires treating your test code with the same architectural rigor as your production code.
A modular structure keeps the codebase navigable as it grows. A standard enterprise layout looks like this:
config/ - Environment variables, test data configuration, and global setup files.pages/ - Page Object classes containing selectors and component methods.tests/ - Actual test specification files grouped by feature or user journey.utils/ or helpers/ - Reusable API clients, database wrappers, loggers, and driver factories.reports/ & logs/ - Execution output directories (git-ignored).time.sleep() ). Use dynamic, explicit waits to check for element states, which eliminates flakiness caused by network or rendering latency..env files or CI/CD secrets management.If you'd like to dive deeper, let me know:
I can help you sketch out a custom folder structure or write a sample Page Object Model implementation.
No sources cited
A scalable test automation framework is less about creating lots of helper classes and more about **creating clear boundaries**: tests describe behavior, framework code handles mechanics, and CI provides repeatable execution. For a new web application today, I’d generally start with **Playwright + TypeScript**, unless…
A scalable test automation framework is less about creating lots of helper classes and more about creating clear boundaries: tests describe behavior, framework code handles mechanics, and CI provides repeatable execution.
For a new web application today, I’d generally start with Playwright + TypeScript, unless your organization already has a strong Selenium/Java/Python ecosystem. Playwright provides isolation, auto-waiting, parallel execution, tracing, cross-browser support, and CI capabilities out of the box.
A good target architecture looks like this:
┌─────────────────────┐
│ Test Specs │
│ Business scenarios │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Fixtures / Hooks │
│ setup / teardown │
└──────────┬──────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌───────▼───────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Page Objects │ │ API Clients │ │ Test Data │
│ UI behavior │ │ backend │ │ factories │
└───────┬───────┘ └──────┬──────┘ └─────────────┘
│ │
└────────────────┼────────────────┐
│ │
┌─────────▼─────────┐ ┌────▼─────┐
│ Application │ │ External │
│ API/UI │ │ Services │
└───────────────────┘ └──────────┘
Configuration / Logging / Reporting / CI
The most important principle is:
Tests should express intent, not implementation details. For example, prefer:
await checkoutPage.completeOrder(customer);
await checkoutPage.expectOrderConfirmation();
over:
await page.locator('#firstName').fill('John');
await page.locator('#lastName').fill('Smith');
await page.locator('#checkout-button').click();
The latter spreads UI implementation throughout your test suite. Page/component abstractions centralize those details, which is one of the traditional benefits of Page Objects.
For example:
automation/
│
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── logout.spec.ts
│ │
│ ├── checkout/
│ │ ├── checkout.spec.ts
│ │ └── payment.spec.ts
│ │
│ └── orders/
│ └── orders.spec.ts
│
├── pages/
│ ├── LoginPage.ts
│ ├── HomePage.ts
│ ├── CheckoutPage.ts
│ └── OrdersPage.ts
│
├── components/
│ ├── Header.ts
│ ├── Navigation.ts
│ └── ProductCard.ts
│
├── api/
│ ├── AuthApi.ts
│ ├── OrdersApi.ts
│ └── UsersApi.ts
│
├── fixtures/
│ ├── test.ts
│ └── auth.fixture.ts
│
├── data/
│ ├── users.ts
│ └── products.ts
│
├── factories/
│ ├── UserFactory.ts
│ └── OrderFactory.ts
│
├── utils/
│ ├── dates.ts
│ ├── random.ts
│ └── assertions.ts
│
├── config/
│ ├── environments.ts
│ └── test-config.ts
│
├── playwright.config.ts
├── package.json
└── tsconfig.json
Don't create every directory on day one. Add abstractions when there is a real reuse or maintenance problem.
Don't make UI automation responsible for everything.
A healthy distribution might look roughly like:
/\
/ \
/ E2E\ Few
/------\
/ API \ More
/----------\
/ Unit/Comp. \ Many
/--------------\
For example:
If you put hundreds or thousands of scenarios exclusively through the browser, execution time and maintenance cost will eventually become painful.
Tests should tell someone what behavior failed.
Good:
test('customer can place an order using a valid credit card', async ({ ... }) => {
...
});
Bad:
test('testCheckoutButton', async ({ ... }) => {
...
});
I'd also use tags/categories:
test('@smoke customer can log in', async ({ ... }) => {
...
});
test('@regression customer can update billing address', async ({ ... }) => {
...
});
Then CI can execute different suites:
npx playwright test --grep @smoke
A Page Object should encapsulate how to interact with a UI, while the test describes what you're validating.
export class LoginPage {
constructor(private page: Page) {}
private username = this.page.getByLabel('Username');
private password = this.page.getByLabel('Password');
private loginButton = this.page.getByRole('button', { name: 'Log in' });
async login(username: string, password: string) {
await this.username.fill(username);
await this.password.fill(password);
await this.loginButton.click();
}
}
Then:
test('valid customer can log in', async ({ page }) => {
const login = new LoginPage(page);
await login.login(user.email, user.password);
await expect(page.getByText('Welcome back')).toBeVisible();
});
Notice that the assertion remains in the test. This separation is consistent with Selenium's guidance that Page Objects model page behavior while test assertions belong to the test itself.
BasePageA common anti-pattern is:
BasePage
├── click()
├── type()
├── wait()
├── select()
├── screenshot()
├── login()
├── search()
├── database()
└── everything else
Eventually every page inherits hundreds of unrelated methods.
Prefer small, composable abstractions:
pages/
components/
api/
utils/
fixtures/
This is one of the biggest determinants of framework stability.
Prefer user-facing or explicit contracts:
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByRole('textbox', { name: 'Password' })
rather than fragile selectors:
page.locator('div.container > div:nth-child(2) button')
Playwright specifically recommends user-facing locators and explicit contracts, and its locators provide auto-waiting and retryability.
If your organization owns the application, establish a selector contract such as:
<button data-testid="checkout-submit">
Place order
</button>
Then:
page.getByTestId('checkout-submit')
is perfectly reasonable.
Avoid:
await page.waitForTimeout(5000);
This is one of the fastest ways to create slow and flaky tests.
Instead, wait for something meaningful:
await expect(page.getByText('Order confirmed')).toBeVisible();
or:
await page.getByRole('button', { name: 'Submit' }).click();
Modern browser frameworks can perform much of the synchronization automatically.
This is critical for scalability.
Bad:
Test 1 → creates user
↓
Test 2 → assumes user exists
↓
Test 3 → assumes Test 2 succeeded
Now a failure in Test 1 creates failures everywhere.
Instead:
Test 1 → creates its own state
Test 2 → creates its own state
Test 3 → creates its own state
Playwright recommends test isolation because it improves reproducibility and prevents cascading failures.
For expensive setup, create the state through an API/database fixture rather than clicking through the UI every time.
For example:
const user = await usersApi.create({
role: 'customer'
});
await loginPage.login(user.email, user.password);
That can be dramatically faster than:
Open browser
→ Register
→ Verify email
→ Complete profile
→ Navigate to application
Don't bury data inside tests:
await page.getByLabel('First name').fill('John');
await page.getByLabel('Last name').fill('Smith');
await page.getByLabel('Email').fill('[email protected]');
Instead:
const user = UserFactory.createCustomer();
await registrationPage.register(user);
A factory can generate unique data:
export class UserFactory {
static createCustomer() {
const id = crypto.randomUUID();
return {
firstName: 'Test',
lastName: `User-${id}`,
email: `test-${id}@example.com`,
password: 'TestPassword123!'
};
}
}
This prevents tests from fighting over shared records.
Support environments such as:
local
dev
qa
staging
production
Your test shouldn't contain:
await page.goto('https://qa.mycompany.com');
Instead:
await page.goto(config.baseUrl);
Use environment variables/secrets for:
BASE_URL
API_URL
USERNAME
PASSWORD
CLIENT_ID
Never commit credentials into the repository.
This is an important scaling decision.
For example:
class OrdersApi {
constructor(private request: APIRequestContext) {}
async createOrder(data: Order) {
return this.request.post('/api/orders', {
data
});
}
}
Now your UI test can do:
const order = await ordersApi.createOrder(testOrder);
await ordersPage.open(order.id);
await ordersPage.expectOrderStatus('Processing');
This lets you use APIs to:
For external dependencies you don't control, mocking/stubbing can also make tests faster and more deterministic; Playwright explicitly recommends avoiding direct testing of third-party dependencies.
Fixtures are where your framework starts becoming genuinely reusable.
For example:
export const test = base.extend<{
loggedInPage: Page;
}>({
loggedInPage: async ({ page }, use) => {
await loginAsStandardUser(page);
await use(page);
}
});
Now:
test('customer can view orders', async ({ loggedInPage }) => {
const ordersPage = new OrdersPage(loggedInPage);
await ordersPage.open();
await ordersPage.expectOrdersVisible();
});
The test doesn't care how authentication works.
A failed test should answer:
What failed, where, with what data, and why? Capture:
Playwright's trace viewer is particularly useful because it can expose the test timeline, DOM snapshots, and network activity.
Don't merely produce:
FAILED checkout.spec.ts
Aim for:
FAILED: customer can complete checkout
Environment: QA
Browser: Chromium
Build: 2026.09.04.125
Step: Submit payment
Expected: Order confirmation visible
Actual: Payment declined
Trace: available
Screenshot: available
Request ID: abc-123
A scalable pipeline might be:
Pull Request
│
▼
Lint + Type Check
│
▼
Smoke Tests
│
▼
API Tests
│
▼
UI Regression
│
├── Chromium
├── Firefox
└── WebKit
Run fast feedback first.
For example:
npm ci
npx playwright install --with-deps chromium
npx playwright test --grep @smoke
Then run the larger regression suite separately.
Playwright recommends frequent CI execution and supports both parallelism and sharding for larger suites.
Parallelization is powerful, but it exposes bad architecture.
If tests share:
same user
same database records
same shopping cart
same files
same account
parallel execution will cause race conditions.
Design data around:
Worker 1 → User A
Worker 2 → User B
Worker 3 → User C
Worker 4 → User D
rather than:
Worker 1 ─┐
Worker 2 ─┼──> User A
Worker 3 ─┤
Worker 4 ─┘
Playwright supports both worker limits and sharding; CI configuration can therefore scale horizontally as the suite grows.
Never normalize this:
Test failed
→ rerun
→ passed
→ green
That's not reliability; it's hiding failures.
Track:
Pass rate
Flake rate
Execution time
Failure frequency
Retry frequency
A useful rule:
Retries are diagnostic safety nets, not a solution to flaky tests. When a test flakes, determine whether the root cause is:
Then fix the cause.
Treat test automation as production code.
Use:
TypeScript
ESLint
Prettier
Type checking
Code review
Naming conventions
Dependency management
Documentation
For example, CI should fail if:
npm run lint
npm run typecheck
fails.
Playwright's documentation specifically recommends TypeScript/linting and checking types in CI.
Don't measure success by:
"We have 2,000 automated tests." Instead measure:
| Metric | What it tells you |
|---|---|
| Pass rate | Basic stability |
| Flake rate | Framework quality |
| Runtime | Scalability |
| Defect detection | Business value |
| Maintenance hours | Cost |
| Failure diagnosis time | Observability |
| % critical flows automated | Coverage |
| CI feedback time | Developer experience |
A 300-test suite that gives reliable feedback in 10 minutes can be vastly more valuable than a 3,000-test suite that takes three hours and produces 40% flaky failures.
I'd document rules such as:
✓ Describe business behavior
✓ Call page/component methods
✓ Call API clients
✓ Create test data through factories
✓ Make assertions
✗ Contain raw selectors everywhere
✗ Use arbitrary sleeps
✗ Share mutable state
✗ Hard-code environment URLs
✗ Contain passwords
✗ Depend on execution order
✗ Directly manipulate framework internals
✓ Locate elements
✓ Perform UI operations
✓ Encapsulate UI workflows
✓ Expose meaningful actions
✗ Know business test expectations
✗ Contain unrelated application logic
✗ Become giant "god classes"
Don't spend three months building the "perfect framework" before writing tests.
I'd use this progression:
Playwright
TypeScript
Config
Basic reporting
CI
1–2 example tests
Page/component objects
Fixtures
Test data factories
API clients
Environment configuration
Isolation
Parallel execution
Retries
Tracing
Screenshots
Flake detection
Test tagging
Sharding
Multiple browsers
API + UI workflows
Test dashboards
Performance optimization
Coding standards
PR checks
Ownership
Documentation
Framework versioning
Deprecation strategy
Metrics
If I were starting a new web automation project today, I'd choose something like:
Language TypeScript
UI Playwright
API Playwright APIRequest / native HTTP client
Assertions Playwright expect
Test data TypeScript factories
Configuration Environment variables + typed config
Linting ESLint
Formatting Prettier
CI GitHub Actions / GitLab CI / Jenkins
Artifacts HTML report + screenshots + traces
Containers Docker where appropriate
Test tracking Jira/TestRail/etc. if required
The key is not to build a huge custom framework around Playwright. Let Playwright handle browser/test execution, and build only the domain-specific layer your application actually needs.
┌─────────────────┐
│ CI / CD │
└────────┬────────┘
│
┌────────▼────────┐
│ Test Runner │
│ Playwright │
└────────┬────────┘
│
┌─────────────┼─────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼─────┐
│ UI │ │ API │ │ Fixtures │
│ Tests │ │ Tests │ │ & Hooks │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
┌────▼─────────────▼─────────────▼────┐
│ Domain Automation Layer │
│ Pages | Components | APIs | Models │
└──────────────────┬──────────────────┘
│
┌──────────────────▼──────────────────┐
│ Test Data / Factories │
└──────────────────┬──────────────────┘
│
┌──────────────────▼──────────────────┐
│ Application Under Test │
└─────────────────────────────────────┘
The single biggest architectural rule: keep business intent, automation mechanics, test data, configuration, and infrastructure separate. If you do that—and enforce test isolation—you can grow from a handful of tests to thousands without turning the suite into an unmaintainable collection of scripts.