Google AI ModeSep 28, 2026
Data as of Oct 18, 2026Based on 340 AI responses from ChatGPT Search and Google AI Mode
Reviewed by Dimitry Apollonsky
AI summary
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.
Mentioned in · Sep 8 – Sep 28, 2026
Google AI ModeSep 28, 2026
ChatGPT SearchSep 28, 2026
Google AI ModeSep 20, 2026
ChatGPT SearchSep 20, 2026
Google AI ModeSep 16, 2026
ChatGPT SearchSep 16, 2026
ChatGPT SearchSep 12, 2026
Google AI ModeSep 12, 2026
Google AI ModeSep 8, 2026
ChatGPT SearchSep 8, 2026
12% of citations to these sources link to brands' own websites.
testrail.com
medium.com
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 blueprint to design your framework:
config/ : Environment-specific settings, URLs, and credentials (managed via environment variables).pages/ or screens/ : Page object classes containing locators and page-specific methods.tests/ : Actual test specs/cases, keeping assertions readable and clean.utils/ or helpers/ : Reusable functions for data parsing, database connections, API wrappers, or custom loggers.reports/ or logs/ : Execution artifacts (git-ignored).logging ) to track step-by-step execution.If you'd like to dive deeper, tell me:
I can give you a tailored folder structure and code snippets for your exact setup.
A scalable test automation framework is less about picking a test library and more about designing clear boundaries, deterministic tests, reusable infrastructure, and fast feedback.
For a modern web/API application, I’d start with TypeScript + Playwright. Playwright has built-in fixtures, browser/device projects, parallelism, retries, tracing, and test isolation, which removes a lot of infrastructure you otherwise have to build yourself.
A useful architecture looks like this:
┌──────────────────────┐
│ Test Cases │
│ Business scenarios │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Domain / Workflows │
│ Login, Checkout, etc │
└──────────┬───────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌──────▼─────┐ ┌──────▼──────┐ ┌─────▼─────┐
│ UI Objects │ │ API Clients │ │ Test Data │
│ / Pages │ │ │ │ Builders │
└──────┬─────┘ └──────┬───────┘ └─────┬─────┘
│ │ │
└────────────────┼────────────────┘
│
┌──────────▼───────────┐
│ Fixtures │
│ Auth / DB / Context │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Playwright / Runtime │
└──────────────────────┘The key principle is:
Tests should describe behavior; framework code should handle mechanics. A test shouldn't contain 30 lines of locator manipulation, authentication setup, database cleanup, and random-data generation.
Don't make every test an end-to-end UI test.
A healthy distribution might look roughly like:
/\
/ \ Small number of E2E
/ E2E\
/------\
/ \ API / integration
/ API \
/------------\
/ \ Unit/component
/ UNIT \
/__________________\For example:
The mistake to avoid is building an enormous UI suite because UI tests are easy to write initially.
For a TypeScript/Playwright implementation, I'd start with something like:
automation/
│
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── logout.spec.ts
│ │
│ ├── checkout/
│ │ ├── checkout.spec.ts
│ │ └── payment.spec.ts
│ │
│ └── smoke/
│ └── critical-paths.spec.ts
│
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
│
├── api/
│ ├── UserApi.ts
│ ├── OrderApi.ts
│ └── PaymentApi.ts
│
├── workflows/
│ ├── LoginWorkflow.ts
│ ├── CheckoutWorkflow.ts
│ └── UserWorkflow.ts
│
├── fixtures/
│ ├── auth.fixture.ts
│ ├── api.fixture.ts
│ └── test.fixture.ts
│
├── data/
│ ├── users.ts
│ └── products.ts
│
├── factories/
│ ├── UserFactory.ts
│ └── OrderFactory.ts
│
├── utils/
│ ├── dates.ts
│ ├── assertions.ts
│ └── environment.ts
│
├── config/
│ ├── environments.ts
│ └── test-config.ts
│
├── playwright.config.ts
├── package.json
└── tsconfig.jsonThe important separation is:
tests → workflows → framework components
rather than:
tests → giant collection of helper functions
A good test should look almost like a requirement.
test('customer can complete checkout', async ({
checkout,
testUser,
product,
}) => {
await checkout.loginAs(testUser);
await checkout.addProduct(product);
await checkout.completePurchase();
await expect(checkout.confirmation)
.toContainText('Order confirmed');
});Compare that with:
await page.goto('/login');
await page.locator('#username').fill(user.username);
await page.locator('#password').fill(user.password);
await page.locator('button[type=submit]').click();
await page.goto('/products');
await page.locator('[data-product-id="123"]').click();
// ...The second approach works, but it couples the test to implementation details.
When the UI changes, dozens of tests become maintenance problems.
Page objects are useful for encapsulating UI mechanics:
export class LoginPage {
constructor(private readonly page: Page) {}
readonly username = this.page.getByLabel('Username');
readonly password = this.page.getByLabel('Password');
readonly submit = this.page.getByRole('button', {
name: 'Sign in',
});
async login(username: string, password: string) {
await this.username.fill(username);
await this.password.fill(password);
await this.submit.click();
}
}But don't put business scenarios into every page object.
Prefer:
LoginPage
↓
LoginWorkflow
↓
Testrather than:
LoginPage
↓
contains login
contains registration
contains password reset
contains user creation
contains database cleanup
contains API calls
...Page objects should primarily answer:
"How do I interact with this UI?" Workflows should answer:
"How do I accomplish this business action?"
This is one of the biggest scalability improvements.
Playwright's fixture system is designed specifically to establish isolated test environments and compose reusable dependencies.
For example:
import { test as base } from '@playwright/test';
type Fixtures = {
loginPage: LoginPage;
api: UserApi;
testUser: TestUser;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
api: async ({ request }, use) => {
await use(new UserApi(request));
},
testUser: async ({}, use) => {
const user = UserFactory.create();
await use(user);
},
});Now tests simply request what they need:
test('user can log in', async ({
loginPage,
testUser,
}) => {
await loginPage.login(
testUser.username,
testUser.password
);
});This scales much better than manually constructing dependencies inside every test.
Test data becomes one of the biggest problems in mature automation suites.
Avoid:
const username = '[email protected]';everywhere.
Instead use factories/builders:
const user = UserFactory.create({
role: 'customer',
});
const order = OrderFactory.create({
user,
});Your factory can generate unique data:
export class UserFactory {
static create(overrides = {}): User {
return {
username: `test-${crypto.randomUUID()}@example.com`,
firstName: 'Test',
lastName: 'User',
...overrides,
};
}
}This gives you:
Suppose you're testing checkout.
Don't make every test do:
Open application
→ login
→ create customer
→ navigate to products
→ add product
→ configure order
→ ...
→ finally test checkoutInstead:
API:
Create customer
Create product
Create order
↓
UI:
Open checkout
→ perform checkout behavior
→ verify resultThis dramatically reduces test execution time and eliminates unnecessary UI dependencies.
Use the UI to test the UI.
Use APIs/database/service interfaces to establish state whenever appropriate.
A scalable suite should allow:
npx playwright test --workers=8without tests corrupting one another.
That means avoiding:
Test A modifies shared user
↓
Test B expects original user
↓
💥Instead:
Test A → User A
Test B → User B
Test C → User CUse unique test data, isolated browser contexts, and independent setup/cleanup.
Playwright's standard fixtures provide isolated Page/browser contexts, and its retry documentation specifically recommends making tests independent so they can be retried efficiently.
Never scatter this throughout your code:
if (environment === 'staging') {
...
}Have configuration:
export const environments = {
dev: {
baseUrl: 'https://dev.example.com',
},
staging: {
baseUrl: 'https://staging.example.com',
},
production: {
baseUrl: 'https://example.com',
},
};Then:
TEST_ENV=staging npx playwright testSecrets should come from your CI secret store/environment—not from source control.
Playwright projects are particularly useful here. They can represent browsers, devices, environments, smoke suites, or different retry configurations.
For example:
projects: [
{
name: 'smoke',
testMatch: '**/*.smoke.spec.ts',
retries: 0,
},
{
name: 'chromium',
use: { browserName: 'chromium' },
},
{
name: 'firefox',
use: { browserName: 'firefox' },
},
{
name: 'webkit',
use: { browserName: 'webkit' },
},
]Then:
npx playwright test --project=smokeor:
npx playwright test --project=chromiumThis is much cleaner than creating separate configuration files for everything.
A failed test should answer:
What failed, where, and why? At minimum collect:
Playwright's Trace Viewer lets you inspect the actions and state around a test failure, and its documentation supports recording traces on retries.
For example:
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
}Don't blindly record everything all the time if storage and execution costs become significant.
One of the most damaging patterns is:
Test fails
↓
Add retry
↓
Pipeline green
↓
Nobody investigatesRetries are useful for diagnosing/intermittent infrastructure failures, but they shouldn't become the definition of test reliability.
Track:
Test
├── pass rate
├── retry rate
├── average duration
├── failure frequency
└── last failureA test that passes after retry should be visible as flaky, not indistinguishable from a clean pass. Playwright explicitly categorizes tests that fail initially but pass on retry as flaky.
I would use several layers.
Lint
↓
Unit tests
↓
API tests
↓
Critical UI smokeGoal: fast feedback
API
+
Full UI regression
+
Multiple browsersFull regression
+
Extended browser/device matrix
+
Long-running tests
+
Compatibility testsCI systems such as GitHub Actions support matrix jobs, allowing the same test job to execute across different browser/runtime/environment combinations.
Suppose you have:
1,000 tests × 10 seconds
= 10,000 seconds
≈ 167 minutesWith 20 effective workers:
≈ 8–9 minutesThe exact result depends on setup costs and contention, but the principle is important:
Horizontal parallelism is one of the primary scaling mechanisms for automation. Playwright supports parallel execution and sharding; CI matrix jobs can also distribute work across runners.
But don't blindly maximize workers.
If 20 workers all hammer the same database, your test environment may become the bottleneck.
For example:
test('user can checkout', {
tag: ['@smoke', '@checkout'],
}, async () => {
...
});Then your pipeline can run:
@smoke
@regression
@api
@critical
@slow
@integrationThis allows you to create different execution policies without duplicating tests.
Your automation repository should have engineering standards just like production code.
For example:
PR checks
├── TypeScript compilation
├── ESLint
├── unit tests
├── framework tests
└── smoke testsAnd enforce things such as:
test.onlyA particularly useful rule is:
Ban arbitrary sleeps.
Bad:
await page.waitForTimeout(5000);Prefer waiting for an observable condition:
await expect(page.getByText('Order confirmed'))
.toBeVisible();This is a surprisingly common failure mode.
You start with:
await checkout.login();Then eventually:
await framework
.application()
.customer()
.authenticatedUser()
.checkout()
.withPayment()
.usingDefaultAddress()
.complete();Now the framework is harder to understand than the application.
A good rule:
Abstract repeated behavior, not hypothetical future behavior. If something happens twice, consider abstraction.
If it happens once, keep it simple.
Your framework itself is production software.
For example:
tests/
framework/
pages/
fixtures/
API clients/
factories/You should unit-test important framework components.
If UserFactory, API clients, data generators, reporters, or custom assertions become complex, test them independently.
Otherwise your test suite can fail because of a framework bug rather than an application bug.
This is extremely valuable at scale.
Every failure should ideally be classified as:
PRODUCT
└── application defect
TEST
└── incorrect assertion/test logic
AUTOMATION
└── framework defect
DATA
└── invalid/contaminated test data
ENVIRONMENT
└── infrastructure/service issue
FLAKY
└── intermittent/unreliable behaviorThen your dashboards become meaningful.
For example:
1,000 tests
│
├── 950 passed
├── 20 product failures
├── 10 test failures
├── 8 environment failures
└── 12 flakyThat tells the team much more than simply:
❌ 50 tests failedKeep things like:
test-results/
├── HTML report
├── screenshots
├── traces
├── videos
├── logs
└── junit.xmlCI artifact storage is specifically intended for persisting test output such as logs, screenshots, test results, and coverage data after a workflow completes.
playwright.config.tsA starting point could look like:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
['html'],
['junit', { outputFile: 'results/junit.xml' }],
],
use: {
baseURL: process.env.BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
],
});This gives you a solid baseline without creating a massive custom framework.
Conceptually:
jobs:
validate:
- install dependencies
- lint
- typecheck
smoke:
needs: validate
- run smoke tests
- upload report
regression:
needs: validate
strategy:
matrix:
browser:
- chromium
- firefox
- webkit
- run tests
- upload traces/screenshots/results
report:
needs:
- smoke
- regression
- aggregate results
- publish reportThe important architectural idea is that validation, execution, and reporting are separate concerns.
Don't try to build the entire framework before writing tests.
Build:
Playwright
+ TypeScript
+ environment config
+ 5–10 representative tests
+ basic fixtures
+ Page Objects
+ HTML report
+ CIAdd:
API clients
test-data factories
workflow layer
custom fixtures
authentication state
custom assertionsAdd:
parallel execution
sharding
browser matrix
test tagging
artifact collection
failure classification
flaky-test tracking
dashboardsAdd:
lint rules
test-writing standards
PR checks
ownership
documentation
test review guidelines
maintenance SLAs CI / Developer
│
▼
┌─────────────────┐
│ Test Runner │
│ Playwright │
└────────┬────────┘
│
┌───────────▼───────────┐
│ Fixtures │
│ Auth / API / DB / Data│
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ Workflows │
│ Business operations │
└─────┬─────────┬───────┘
│ │
┌─────────▼──┐ ┌──▼─────────┐
│ UI Layer │ │ API Layer │
│ Page objs │ │ API clients│
└─────────┬──┘ └──┬─────────┘
│ │
└────┬────┘
│
┌──────▼──────┐
│ Application │
└─────────────┘
┌──────────────────────────────────┐
│ Observability │
│ reports / traces / logs / trends │
└──────────────────────────────────┘If you're building this for a real project, I can also lay out a complete production-ready starter repository—including the package.json, playwright.config.ts, fixtures, Page Objects, API client, test-data factory, GitHub Actions pipeline, reporting, and 2–3 example tests.
await page.waitForTimeout(5000);Prefer waiting for an observable condition:
await expect(page.getByText('Order confirmed'))
.toBeVisible();This is a surprisingly common failure mode.
You start with:
await checkout.login();Then eventually:
await framework
.application()
.customer()
.authenticatedUser()
.checkout()
.withPayment()
.usingDefaultAddress()
.complete();Now the framework is harder to understand than the application.
A good rule:
Abstract repeated behavior, not hypothetical future behavior. If something happens twice, consider abstraction.
If it happens once, keep it simple.
Your framework itself is production software.
For example:
tests/
framework/
pages/
fixtures/
API clients/
factories/You should unit-test important framework components.
If UserFactory, API clients, data generators, reporters, or custom assertions become complex, test them independently.
Otherwise your test suite can fail because of a framework bug rather than an application bug.
This is extremely valuable at scale.
Every failure should ideally be classified as:
PRODUCT
└── application defect
TEST
└── incorrect assertion/test logic
AUTOMATION
└── framework defect
DATA
└── invalid/contaminated test data
ENVIRONMENT
└── infrastructure/service issue
FLAKY
└── intermittent/unreliable behaviorThen your dashboards become meaningful.
For example:
1,000 tests
│
├── 950 passed
├── 20 product failures
├── 10 test failures
├── 8 environment failures
└── 12 flakyThat tells the team much more than simply:
❌ 50 tests failedKeep things like:
test-results/
├── HTML report
├── screenshots
├── traces
├── videos
├── logs
└── junit.xmlCI artifact storage is specifically intended for persisting test output such as logs, screenshots, test results, and coverage data after a workflow completes.
playwright.config.tsA starting point could look like:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
['html'],
['junit', { outputFile: 'results/junit.xml' }],
],
use: {
baseURL: process.env.BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
],
});This gives you a solid baseline without creating a massive custom framework.
Conceptually:
jobs:
validate:
- install dependencies
- lint
- typecheck
smoke:
needs: validate
- run smoke tests
- upload report
regression:
needs: validate
strategy:
matrix:
browser:
- chromium
- firefox
- webkit
- run tests
- upload traces/screenshots/results
report:
needs:
- smoke
- regression
- aggregate results
- publish reportThe important architectural idea is that validation, execution, and reporting are separate concerns.
Don't try to build the entire framework before writing tests.
Build:
Playwright
+ TypeScript
+ environment config
+ 5–10 representative tests
+ basic fixtures
+ Page Objects
+ HTML report
+ CIAdd:
API clients
test-data factories
workflow layer
custom fixtures
authentication state
custom assertionsAdd:
parallel execution
sharding
browser matrix
test tagging
artifact collection
failure classification
flaky-test tracking
dashboardsAdd:
lint rules
test-writing standards
PR checks
ownership
documentation
test review guidelines
maintenance SLAs CI / Developer
│
▼
┌─────────────────┐
│ Test Runner │
│ Playwright │
└────────┬────────┘
│
┌───────────▼───────────┐
│ Fixtures │
│ Auth / API / DB / Data│
└───────────┬───────────┘
│
┌───────────▼───────────┐
│ Workflows │
│ Business operations │
└─────┬─────────┬───────┘
│ │
┌─────────▼──┐ ┌──▼─────────┐
│ UI Layer │ │ API Layer │
│ Page objs │ │ API clients│
└─────────┬──┘ └──┬─────────┘
│ │
└────┬────┘
│
┌──────▼──────┐
│ Application │
└─────────────┘
┌──────────────────────────────────┐
│ Observability │
│ reports / traces / logs / trends │
└──────────────────────────────────┘If you're building this for a real project, I can also lay out a complete production-ready starter repository—including the package.json, playwright.config.ts, fixtures, Page Objects, API client, test-data factory, GitHub Actions pipeline, reporting, and 2–3 example tests.
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:
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 / pnpmPlaywright 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.mdThe 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
└── OrderSummaryThat 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
└── testDataPlaywright 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 dataOr 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 somethingInstead:
API:
create user
create products
create order
UI:
open order
verify behaviorThis 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 Drather than:
Test A → creates shared user
↓
Test B → modifies shared user
↓
Test C → expects Test B's statePlaywright 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_URLand:
BASE_URL=https://staging.mycompany.com npm testFor more sophisticated environments:
config/
├── dev
├── qa
├── staging
└── productionKeep environment configuration separate from test behavior.
Also keep secrets out of source control:
USERNAME → CI secret
PASSWORD → CI secret
API_KEY → CI secretWhen a test fails in CI, you want:
Test failed
│
├── Screenshot
├── Video (when useful)
├── Trace
├── Browser console
├── Network information
├── Error message
└── Test metadataDon'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 greenIf that happens frequently, you've created a flaky-test masking system.
Instead, track:
Pass rate
Failure rate
Retry rate
Flake rate
Average execution timeA 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 testsPlaywright 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
↓ ↓
└──────────┬──────────┘
↓
ReportDon'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
└── BaseBaseManagerEventually nobody understands what anything does.
Prefer small abstractions with clear ownership:
Authentication
├── LoginPage
├── AuthApi
└── auth.fixture
Orders
├── OrdersPage
├── OrderApi
└── OrderFactoryComposition 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 |
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 configurationBuild one complete feature:
Test
↓
Fixture
↓
Page Object
↓
API setup
↓
Test data
↓
Reporting
↓
CIDon'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 testsAdd:
Parallelization
Cross-browser projects
Sharding
Test data isolation
More fixtures
API coverage
Advanced reporting
Flake detectionPlaywright 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 APIsThe 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.tsThen 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.
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.
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 testsThis 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: DockerPlaywright'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.jsonDon'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 methodsPrefer:
CheckoutPage
PaymentForm
AddressForm
OrderSummary
Header
ProductCardComponent 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 userNow:
Test A ──> its own user
Test B ──> its own user
Test C ──> its own userEach 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 stateYou 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/versionFor 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
↓
greenand everyone assumes everything is fine.
Instead, track:
PASS
FAIL
FLAKY
BLOCKEDA 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 testsThis 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
│
└── ContractFor example:
login
homepage
search
checkout
logoutFast 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 testSecrets 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 suitePlaywright 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 individuallyPut 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 categoriesFor 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 reportingPage objects
components
fixtures
test data factories
API helpers
coding standardsparallel execution
sharding
multiple environments
cross-browser
test tagging
advanced reportingflaky-test tracking
failure diagnostics
automatic artifact collection
test-health metrics
quarantine processAPI-based setup
smaller UI suite
worker-level fixtures
test distribution
containerization
execution-time optimizationI'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 detailsThat'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. 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
/
UnitThe 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/driverNot:
Test
↓
WebDriver everywhere
↓
CSS selectors everywhere
↓
Random utility classesA 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 utilitiesFor 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
Cor:
C
A
Bor:
A + B + C simultaneouslyand 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 workerto:
5,000 tests
↓
50 workers
↓
multiple CI machineswithout 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 testIt should answer:
What happened, where, and why? Capture automatically:
Your CI report should let a developer go from:
FAILEDto:
FAILED
→ Checkout
→ Payment authorization
→ HTTP 500
→ request ID abc123
→ screenshot
→ tracewithout 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
productionConfiguration 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
│ │
└─────┬─────┘
▼
ReportDon'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 scenariosRetries 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 investigatesInstead track:
pass
fail
flaky
blocked
skippedA 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 + restoreOnce 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 |
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 → fixturesDefine rules for:
Then enforce them with:
ESLint
TypeScript
Prettier
code review
CI checksPlaywright'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.mdThe 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
↓
CIDon'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:
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.
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.
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 |
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.ymlA typical flow:
Test Case
↓
Business Workflow
↓
Page Objects / API Clients
↓
Automation Library
↓
ApplicationYour 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.jsonExample:
{
"validUser": {
"email": "[email protected]",
"role": "admin"
}
}Advantages:
Never hardcode:
Example:
config/
├── dev.json
├── staging.json
└── production.jsonRun:
ENV=staging npm testLarge 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.zipA mature pipeline might look like:
Pull Request
|
↓
Lint + Static Checks
|
↓
Unit Tests
|
↓
API Tests
|
↓
Critical UI Smoke Tests
|
↓
Full Regression Suite
|
↓
Report + NotificationsInclude:
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:
| UI/API/unit ratio | Detect overuse of E2E |
| Mean time to diagnose | Measure observability |
| Mean time to fix | Measure maintainability |
| Mean time to fix | Maintenance efficiency |
| Coverage by feature | Test strategy |
| Parallel utilization | CI efficiency |
| Test runner | JUnit, TestNG, pytest, NUnit, Playwright Test |
| Reporting | Allure, HTML reports, dashboards |
| CI/CD | GitHub Actions, Jenkins, GitLab CI, Azure DevOps |