Data as of Aug 25, 2026 · Based on 318 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
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.
TestNG is recommended as a core test runner suitable for scaling. Its support for parallel execution makes it a strong choice for managing large, performance-sensitive test suites.
ReportPortal is recommended for test result visualization. It helps teams maintain dashboards that effectively communicate test pass/fail status and trends to diverse project stakeholders.
Building a scalable and maintainable test automation framework from scratch requires treating your test code with the same architectural rigor as your production application.
The foundational layer dictates how easy your framework will be to scale. Avoid writing linear scripts.
Select a mature, well-supported ecosystem that aligns with your team's primary development language.
.env, properties , or YAML files for environment URLs, timeouts, and credentials. Never hardcode data into tests.Thread.sleep() ). Rely entirely on explicit waits or built-in auto-waiting mechanisms provided by modern tools like Playwright.When tests run in a pipeline, clear diagnostics are your only lifeline.
A framework is only as good as its execution frequency.
If you'd like to dive deeper, let me know:
No sources cited
A scalable test automation framework is less about writing automated tests and more about designing a software system for testing. The biggest mistake teams make is creating a collection of scripts instead of building an architecture that can support hundreds or thousands of tests. A maintainable framework usually separates concerns: tests, reusable actions, configuration, test data, reporting, and execution infrastructure.
Below is a practical blueprint.
Start by answering:
Avoid automating everything. Prioritize tests that are stable, repeatable, and business-critical.
Choose based on your application and team skills.
Common choices:
A common modern stack is:
Language: TypeScript
UI: Playwright
API: Playwright API / Axios
Runner: Playwright Test
Reporting: Allure
CI: GitHub Actions / Jenkins / Azure DevOps
Containers: Docker
The tool matters less than the architecture around it. A poorly structured Playwright project can become as difficult to maintain as a poorly structured Selenium project.
A scalable framework should separate responsibilities.
Example structure:
automation-framework/
│
├── tests/
│ ├── smoke/
│ ├── regression/
│ └── integration/
│
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
│
├── api/
│ ├── UserApi.ts
│ └── OrderApi.ts
│
├── components/
│ ├── Navbar.ts
│ └── Modal.ts
│
├── fixtures/
│ └── testFixtures.ts
│
├── data/
│ ├── users.json
│ └── environments.json
│
├── utils/
│ ├── Logger.ts
│ ├── DateHelper.ts
│ └── FileHelper.ts
│
├── config/
│ ├── dev.config.ts
│ └── prod.config.ts
│
├── reports/
│
└── CI/
└── pipeline.yml
Typical layers:
Test Layer
|
Business Flow Layer
|
Page Objects / API Clients
|
Framework Utilities
|
Automation Tool
This keeps product changes from forcing rewrites across the entire suite.
test("login", async ({page}) => {
await page.goto("/login");
await page.fill("#username", "user");
await page.fill("#password", "pass");
await page.click("#submit");
});
This creates duplication.
test("user can login", async ({loginPage}) => {
await loginPage.login(
"user",
"password"
);
await expect(loginPage.dashboard)
.toBeVisible();
});
With:
class LoginPage {
constructor(private page: Page){}
async login(username, password){
await this.page.fill(
"#username",
username
);
await this.page.fill(
"#password",
password
);
await this.page.click(
"#submit"
);
}
}
Common patterns:
Use abstractions where they reduce duplication. Avoid creating layers just because they sound architecturally impressive.
Do not hardcode data inside tests.
Avoid:
createUser("[email protected]");
Prefer:
const user = UserFactory.createPremiumUser();
await userApi.create(user);
Use:
For parallel execution, every test should be able to create and clean up its own data.
Your framework should support:
development
staging
production
Example:
{
"baseUrl": "https://staging.example.com",
"apiUrl": "https://api.staging.example.com",
"timeout": 30000
}
Never hardcode:
Store secrets in:
A good framework answers:
Why did this test fail? Capture:
Example failure package:
Failed Test
|
├── Screenshot
├── Trace file
├── Browser logs
├── API request
└── Environment information
A test that only says "element not found" wastes debugging time.
Your pipeline should support:
Commit
|
Build
|
Unit tests
|
API smoke tests
|
Critical UI tests
|
Merge
Nightly job
|
Full regression suite
|
Parallel execution
|
Report generation
Scaling techniques:
Flaky tests destroy trust.
Avoid:
await sleep(5000);
Prefer:
await expect(
button
).toBeVisible();
Common causes of flaky tests:
Track flaky tests as engineering problems, not as normal failures.
Create rules such as:
Reviewers check:
Measure the framework itself.
Track:
| Metric | Why it matters |
|---|---|
| Execution time | Detect slow growth |
| Pass rate | Confidence |
| Flaky rate | Stability |
| Failure categories | Maintenance focus |
| Coverage of critical flows | Risk management |
| Cost per execution | Scaling decisions |
A good evolution path:
Build:
Add:
Add:
Do not build a huge framework before you have real tests exposing real problems.
✅ New tests can be added quickly ✅ Product UI changes require minimal updates ✅ Tests run reliably in CI ✅ Failures are easy to diagnose ✅ Multiple engineers can contribute safely ✅ The framework can grow without constant rewrites
The core principle: build a testing platform, not a test script collection.
A good framework answers:
Why did this test fail? Capture:
Example failure package:
Failed Test
|
├── Screenshot
├── Trace file
├── Browser logs
├── API request
└── Environment information
A test that only says "element not found" wastes debugging time.
Your pipeline should support:
Commit
|
Build
|
Unit tests
|
API smoke tests
|
Critical UI tests
|
Merge
Nightly job
|
Full regression suite
|
Parallel execution
|
Report generation
Scaling techniques:
Flaky tests destroy trust.
Avoid:
await sleep(5000);
Prefer:
await expect(
button
).toBeVisible();
Common causes of flaky tests:
Track flaky tests as engineering problems, not as normal failures.
Create rules such as:
Reviewers check:
Measure the framework itself.
Track:
| Metric | Why it matters |
|---|---|
| Execution time | Detect slow growth |
| Pass rate | Confidence |
| Flaky rate | Stability |
| Failure categories | Maintenance focus |
| Coverage of critical flows | Risk management |
| Cost per execution | Scaling decisions |
A good evolution path:
Build:
Add:
Add:
Do not build a huge framework before you have real tests exposing real problems.
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.
Here is a step-by-step blueprint to build it right:
LoginSteps.loginAsUser())..env, config.json ) mapped dynamically per environment (QA, Staging, Dev).beforeEach / afterEach or API teardowns).If you'd like, let me know:
I can dive deeper into a specific tech stack recommendation or provide a folder structure template.
A scalable test automation framework is less about clever abstractions and more about clear boundaries, isolated tests, reliable test data, fast feedback, and disciplined maintenance.
For a modern web application, I’d build it roughly like this.
First decide what should be automated at each level.
A healthy test pyramid looks roughly like:
Don't turn every requirement into an end-to-end browser test. Browser tests are comparatively expensive and infrastructure-heavy; Selenium's own guidance recommends considering lighter-weight testing approaches whenever possible.
For a new web framework, one practical choice is:
Playwright already provides useful capabilities for isolation, auto-waiting, browser projects, parallelism, sharding, traces, and CI.
If your organization already has substantial Selenium expertise, Selenium remains a perfectly reasonable choice. The architectural principles below apply to either.
For example:
automation/
├── tests/
│ ├── e2e/
│ │ ├── authentication/
│ │ ├── checkout/
│ │ └── orders/
│ ├── api/
│ └── components/
│
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
│
├── components/
│ ├── Header.ts
│ ├── Navigation.ts
│ └── ProductCard.ts
│
├── fixtures/
│ ├── test.ts
│ └── auth.fixture.ts
│
├── api/
│ ├── UserApi.ts
│ └── OrderApi.ts
│
├── data/
│ ├── users.ts
│ └── products.ts
│
├── factories/
│ ├── UserFactory.ts
│ └── OrderFactory.ts
│
├── utils/
│ ├── dates.ts
│ └── assertions.ts
│
├── config/
│ └── environments.ts
│
├── playwright.config.ts
└── package.json
The important thing isn't the exact folders. It's the separation of responsibilities.
A test should primarily express:
Given this state → perform these user/business actions → verify this outcome. It shouldn't contain hundreds of lines of locator and infrastructure code.
A useful architecture is:
TESTS
│
▼
Business/workflow layer
│
┌────────┴────────┐
▼ ▼
Page Objects API Clients
│ │
▼ ▼
UI Components HTTP/API
│
▼
Playwright
Contains the actual scenarios:
test("customer can complete checkout", async ({ checkout }) => {
await checkout.loginAs(customer);
await checkout.addProduct(product);
await checkout.completeOrder();
await expect(checkout.confirmation).toBeVisible();
});
The test should be readable by someone who understands the product but isn't an automation-framework expert.
Encapsulates UI mechanics:
export class LoginPage {
constructor(private page: Page) {}
async login(username: string, password: string) {
await this.page.getByLabel("Username").fill(username);
await this.page.getByLabel("Password").fill(password);
await this.page.getByRole("button", { name: "Sign in" }).click();
}
}
Page Objects are useful because UI implementation details are centralized rather than duplicated across tests. Selenium explicitly recommends this separation and also recommends that assertions generally remain in the test rather than inside Page Objects.
Don't make tests construct raw HTTP requests everywhere.
Instead:
const user = await usersApi.create({
role: "customer"
});
rather than:
await request.post("/api/users", {
data: {
role: "customer"
}
});
This gives you one place to handle authentication, headers, serialization, endpoints, error handling, and API changes.
One of the most common framework mistakes is:
BasePage
└── Everything
which eventually becomes a 2,000-line class containing:
Avoid that.
Prefer small composable objects:
CheckoutPage
├── Header
├── Cart
├── AddressForm
└── PaymentForm
Selenium's documentation similarly recommends modeling reusable page components rather than forcing every interaction into one enormous page object.
This is probably the most important scalability rule.
A test should be able to run:
test A
without needing:
test B → test C → test A
Avoid:
Test 1 creates user
↓
Test 2 modifies user
↓
Test 3 expects modified user
Instead:
Test 1 → creates its own data
Test 2 → creates its own data
Test 3 → creates its own data
Playwright's browser-context model provides isolated cookies, local storage, session storage, etc., specifically to prevent cascading failures and make parallel execution safer.
Don't scatter this throughout tests:
const username = "john123";
const email = "[email protected]";
const address = "123 Main Street";
Build factories/builders:
const user = UserFactory.create({
role: "customer"
});
For example:
UserFactory
├── createCustomer()
├── createAdmin()
├── createInactiveUser()
└── createUserWithOrders()
Even better, create application state through APIs or database-level mechanisms where appropriate rather than navigating through the UI to create prerequisites.
That keeps tests fast:
API → create state
↓
UI → test behavior
↓
UI → verify result
rather than:
UI → register
UI → verify email
UI → login
UI → create profile
UI → create product
UI → finally test feature
This:
page.locator("#btn-123 > div:nth-child(2)")
is fragile.
Prefer user-facing or explicitly designed test contracts:
page.getByRole("button", { name: "Submit" })
or, when appropriate:
page.getByTestId("checkout-submit")
Playwright specifically recommends user-facing locators and explicit contracts, with built-in auto-waiting and retry behavior.
I'd establish a project-wide locator policy such as:
Avoid:
await sleep(5000);
and don't create a giant collection of:
waitForPage()
waitForAjax()
waitForElement()
waitForNetwork()
waitForWhatever()
unless there is a genuine application-specific need.
Use the automation tool's synchronization mechanisms.
For Playwright, locators automatically wait for relevant actionability conditions, which eliminates a large class of arbitrary timing problems.
Have environment-specific configuration:
local
dev
qa
staging
production-like
For example:
export const environments = {
qa: {
baseUrl: "...",
apiUrl: "..."
},
staging: {
baseUrl: "...",
apiUrl: "..."
}
};
Secrets should come from environment/CI secret management—not source control.
Your tests should say:
await page.goto("/checkout");
rather than:
await page.goto("https://qa.mycompany.com/checkout");
When a test fails in CI, you want to answer:
What happened? without reproducing it manually.
Capture, as appropriate:
Playwright's CI tooling supports HTML reports and traces, which makes this particularly useful for diagnosing remote failures.
Retries are useful for detecting environmental flakiness.
They are dangerous when used to make a broken test suite appear green.
For example:
Test fails
↓
retry
↓
passes
should produce:
⚠️ Flaky test detected rather than:
✅ Everything is healthy Track metrics such as:
Pass rate
Failure rate
Flake rate
Average execution time
95th percentile execution time
Retry rate
A framework becomes scalable when you can measure its health.
A good progression is:
Developer commit
│
▼
Lint + type checking
│
▼
Fast unit/API tests
│
▼
Critical smoke tests
│
▼
Parallel E2E suite
│
▼
Cross-browser/regression suite
Don't make every developer wait 45 minutes to discover a TypeScript error.
Playwright supports parallel execution and sharding, which can distribute large suites across CI machines.
For CI, start conservatively with reproducible execution and then increase parallelism as your test isolation improves. Playwright's CI guidance specifically notes that sequential workers can prioritize stability, while powerful infrastructure can use parallelization and sharding.
For example:
@smoke
@regression
@critical
@payments
@authentication
@slow
@api
Then you can run:
npm test -- --grep @smoke
and have CI pipelines such as:
Pull Request
└── @smoke
Merge
└── smoke + critical
Nightly
└── full regression
Release
└── full regression + cross-browser
A bad test:
Register
Login
Create customer
Create product
Add product
Change settings
Logout
Login
Checkout
Cancel order
Change address
...
A better suite:
Customer can register
Customer can login
Admin can create product
Customer can add product to cart
Customer can checkout
Customer can cancel order
Customer can update address
Each test has one clear purpose.
This also makes failures dramatically easier to diagnose.
Before the suite becomes large, establish rules for:
For example:
❌ locator in test
❌ arbitrary sleep
❌ shared mutable test data
❌ test depends on another test
❌ assertion hidden inside page object
✅ business action in test
✅ stable locator in page/component
✅ factory-generated data
✅ independent test
✅ assertion in test
Automate enforcement wherever possible with linting and TypeScript checks. Playwright's own guidance recommends TypeScript/ESLint and compiler checks as part of maintaining test code quality.
Don't spend three months building a "perfect framework" before writing tests.
I'd use this progression:
Build:
Add:
Add:
Add:
Keep this mental model:
TEST
│
│ business intent
▼
WORKFLOW
│
│ domain actions
├───────────────┐
▼ ▼
PAGE OBJECT API CLIENT
│ │
▼ ▼
COMPONENTS HTTP
│
▼
AUTOMATION TOOL
The further down you go, the more implementation-specific the code becomes.
Your test should therefore read something like:
test("customer can purchase a product", async ({
checkout,
customer
}) => {
await checkout.loginAs(customer);
await checkout.addProduct("Laptop");
await checkout.completePayment();
await expect(checkout.confirmation).toHaveText(
"Order confirmed"
);
});
rather than exposing:
locator("#foo")
wait(3000)
click()
locator("div:nth-child(4)")
fetch(...)
parseJson(...)
That distinction is what allows a suite to grow from 50 tests to 5,000 tests without becoming unmaintainable.
I'd judge the finished framework by five questions:
If the answer to those is yes, you've built a framework rather than merely a collection of automated scripts.
Playwright supports parallel execution and sharding, which can distribute large suites across CI machines.
For CI, start conservatively with reproducible execution and then increase parallelism as your test isolation improves. Playwright's CI guidance specifically notes that sequential workers can prioritize stability, while powerful infrastructure can use parallelization and sharding.
For example:
@smoke
@regression
@critical
@payments
@authentication
@slow
@api
Then you can run:
npm test -- --grep @smoke
and have CI pipelines such as:
Pull Request
└── @smoke
Merge
└── smoke + critical
Nightly
└── full regression
Release
└── full regression + cross-browser
A bad test:
Register
Login
Create customer
Create product
Add product
Change settings
Logout
Login
Checkout
Cancel order
Change address
...
A better suite:
Customer can register
Customer can login
Admin can create product
Customer can add product to cart
Customer can checkout
Customer can cancel order
Customer can update address
Each test has one clear purpose.
This also makes failures dramatically easier to diagnose.
Before the suite becomes large, establish rules for:
For example:
❌ locator in test
❌ arbitrary sleep
❌ shared mutable test data
❌ test depends on another test
❌ assertion hidden inside page object
✅ business action in test
✅ stable locator in page/component
✅ factory-generated data
✅ independent test
✅ assertion in test
Automate enforcement wherever possible with linting and TypeScript checks. Playwright's own guidance recommends TypeScript/ESLint and compiler checks as part of maintaining test code quality.
Don't spend three months building a "perfect framework" before writing tests.
I'd use this progression:
Build:
Add:
Add:
Add:
Keep this mental model:
TEST
│
│ business intent
▼
WORKFLOW
│
│ domain actions
├───────────────┐
▼ ▼
PAGE OBJECT API CLIENT
│ │
▼ ▼
COMPONENTS HTTP
│
▼
AUTOMATION TOOL
The further down you go, the more implementation-specific the code becomes.
Your test should therefore read something like:
test("customer can purchase a product", async ({
checkout,
customer
}) => {
await checkout.loginAs(customer);
await checkout.addProduct("Laptop");
await checkout.completePayment();
await expect(checkout.confirmation).toHaveText(
"Order confirmed"
);
});
rather than exposing:
locator("#foo")
wait(3000)
click()
locator("div:nth-child(4)")
fetch(...)
parseJson(...)
That distinction is what allows a suite to grow from 50 tests to 5,000 tests without becoming unmaintainable.
I'd judge the finished framework by five questions:
If the answer to those is yes, you've built a framework rather than merely a collection of automated scripts.
Building a scalable test automation framework is less about picking a tool and more about designing an architecture that survives growth: more tests, more contributors, more environments, and more frequent releases. A good framework separates responsibilities, minimizes duplication, and makes failures easy to diagnose.
A practical approach:
Start by answering:
Avoid automating everything. Prioritize high-value, stable workflows:
A scalable framework focuses on risk coverage, not maximum test count.
Choose based on your application and team skills.
Example stacks:
A common modern web choice is Playwright with TypeScript because it provides browser automation, parallel execution support, and strong developer tooling.
A maintainable framework usually looks like this:
automation-framework
│
├── tests
│ ├── smoke
│ ├── regression
│ └── integration
│
├── pages
│ ├── LoginPage
│ └── CheckoutPage
│
├── components
│ ├── Header
│ └── Navigation
│
├── api
│ ├── UserApi
│ └── OrderApi
│
├── fixtures
│ └── TestSetup
│
├── data
│ ├── users.json
│ └── environments.json
│
├── utils
│ ├── Logger
│ ├── DateHelper
│ └── FileHelper
│
├── config
│ ├── dev.config
│ └── prod.config
│
├── reports
│
└── CI/CD
The key rule:
Tests describe behavior. Framework code handles implementation.
A test should read like:
test("customer can complete checkout", async () => {
await loginPage.login(user);
await productPage.addProduct(product);
await checkoutPage.completePurchase();
await checkoutPage.verifyConfirmation();
});
Not:
await page.locator("#username").fill("bob");
await page.locator("#password").fill("123");
await page.click("#submit");
Encapsulate UI behavior:
class LoginPage {
constructor(private page: Page) {}
async login(username, password) {
await this.page.getByTestId("username").fill(username);
await this.page.getByTestId("password").fill(password);
await this.page.getByTestId("login").click();
}
}
Benefits:
For very large systems, consider higher-level patterns such as Screenplay or domain-specific action layers.
Avoid:
test("login", () => {
login("[email protected]", "password123");
});
Prefer:
{
"validUser": {
"email": "[email protected]",
"role": "customer"
}
}
Benefits:
Create factories for complex data:
const customer = CustomerFactory.create({
plan: "premium"
});
Never hardcode:
Example:
.env.dev
.env.qa
.env.prod
Use:
config.baseUrl
config.apiUrl
config.browser
This lets the same tests run across environments.
Common utilities:
Capture:
Automatically collect artifacts when failures occur.
Instead of:
expect(element).toBeVisible();
Create domain assertions:
checkoutPage.verifyOrderCompleted();
A framework that works with 20 tests may fail at 2,000 tests.
Design for:
Example:
Run 500 tests
Worker 1 → Tests 1-100
Worker 2 → Tests 101-200
Worker 3 → Tests 201-300
Worker 4 → Tests 301-500
Avoid:
A mature pipeline might look like:
Pull Request
|
↓
Lint + Unit Tests
|
↓
Smoke Automation
|
↓
Merge
|
↓
Regression Suite
|
↓
Release Approval
Typical CI features:
A useful report shows:
A dashboard saying "500 tests failed" is not useful.
A dashboard saying:
"Checkout failures increased after deployment version 4.8.2 because the payment API returned 500 errors"
is useful.
Create a reliability process.
Track:
Rules:
A flaky test suite destroys trust faster than having fewer tests.
Create rules for:
Example:
Every new test must:
✓ Have a meaningful name
✓ Use reusable components
✓ Avoid hardcoded data
✓ Run independently
✓ Include appropriate tags
Do not build a massive framework before proving value.
Start with:
Then evolve based on real pain points.
A large organization often ends up with:
Test Cases
|
↓
Business Action Layer
|
-------------------------
| |
UI Automation API Automation
| |
Browser Service Clients
|
-----------------
| | |
Data Config Reporting
|
CI/CD + Infrastructure
The biggest mistake is treating automation as a collection of scripts. A scalable framework is a software product: it needs architecture, ownership, standards, and continuous maintenance.
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Treating your test code with the same software engineering standards as your production code ensures long-term success.
If you'd like to dive deeper, let me know:
I can provide a tailored directory structure or code boilerplate for your specific use case.
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Following a structured, phased approach ensures your framework remains robust as your test suite and team grow.
Core Implementation Steps
Repository Setup: Initialize a clean version control repository (e.g., Git) with a logical folder structure (e.g., folders for tests, pages, utils, config , and reports).
Configuration Management: Implement a centralized configuration file to handle multi-environment setups (e.g., QA, Staging, Production) and base URLs dynamically.
Reporting and Logging: Integrate robust reporting tools (like Allure, HTML Reporter) and structured logging to capture failures, screenshots, and console logs automatically.
CI/CD Integration: Connect your framework to a continuous integration pipeline (GitHub Actions, GitLab CI, Jenkins) so tests run on every pull request or nightly build.
Long-Term Maintenance & Scaling
sleep commands) and retry mechanisms for transient network or rendering issues.If you'd like to dive deeper, let me know:
A scalable test automation framework is less about picking a tool and more about architecture, test isolation, data management, observability, and CI/CD discipline.
A good target is:
Tests should describe business behavior; the framework should hide infrastructure details.
For a modern web application, I’d start with Playwright + TypeScript unless you have an organizational reason to use Selenium. Playwright provides isolated browser contexts/fixtures, resilient locators, parallel execution capabilities, and API testing support. playwright.devplaywright.dev Selenium is also a strong choice, particularly where you need its broader ecosystem or Grid-based distributed execution.
Before writing code, decide what belongs in automation.
A healthy pyramid might look roughly like:
/\
/ \
/ E2E \ Few, critical user journeys
/------\
/ \
/ API/ \ More integration/API tests
/ Integration\
/--------------\
/ \
/ Unit tests \ Many, fast tests
/____________________\
Don't turn every test case into an end-to-end browser test. Browser tests are comparatively expensive and infrastructure-heavy; Selenium explicitly recommends asking whether a lower-level test can validate the behavior instead.
For example:
Your E2E suite should concentrate on workflows where a real browser adds value.
A clean framework can look like this:
tests/
│
├── e2e/
│ ├── authentication/
│ ├── checkout/
│ ├── orders/
│ └── users/
│
├── api/
│ ├── users/
│ └── orders/
│
├── fixtures/
│ ├── auth.fixture.ts
│ ├── user.fixture.ts
│ └── test.fixture.ts
│
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
│
├── components/
│ ├── Header.ts
│ ├── Navigation.ts
│ └── Modal.ts
│
├── services/
│ ├── UserService.ts
│ ├── OrderService.ts
│ └── AuthService.ts
│
├── data/
│ ├── users.ts
│ └── products.ts
│
├── utils/
│ ├── dates.ts
│ ├── random.ts
│ └── assertions.ts
│
├── config/
│ ├── environments.ts
│ └── test-config.ts
│
└── playwright.config.ts
The important separation is:
Test → business abstraction → page/API/service → browser/infrastructure
rather than:
Test → locator → browser
A good test should read almost like a requirement.
For example:
test('customer can complete checkout', async ({
loginPage,
checkoutPage,
testUser,
}) => {
await loginPage.login(testUser);
await checkoutPage.addProduct('Laptop');
await checkoutPage.checkout();
await expect(checkoutPage.confirmationMessage)
.toHaveText('Order placed successfully');
});
Compare that with a test containing dozens of:
page.locator(...)
page.click(...)
page.fill(...)
page.waitForTimeout(...)
The second style becomes painful to maintain.
Selenium's guidance similarly recommends encapsulating page-specific details in page objects so tests focus on actions from the user's perspective.
A Page Object should represent meaningful behavior, not every possible DOM operation.
Good:
class LoginPage {
constructor(private page: Page) {}
async login(user: User) {
await this.page.getByLabel('Email').fill(user.email);
await this.page.getByLabel('Password').fill(user.password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
}
Avoid:
loginPage.clickEmail();
loginPage.enterEmail();
loginPage.clickPassword();
loginPage.enterPassword();
loginPage.clickSubmit();
The latter exposes implementation details to tests.
Also avoid putting assertions for every possible scenario inside Page Objects. Keep the division roughly:
Page Object
↓
How do I interact with the application?
Test
↓
What behavior am I verifying?
Playwright's own examples support using Page Objects alongside fixtures rather than treating them as a substitute for the test framework.
Test data is one of the biggest causes of flaky automation.
Don't do this everywhere:
const user = {
email: '[email protected]',
name: 'John',
...
};
Instead, create factories/builders:
function createUser(overrides = {}): User {
return {
firstName: 'Test',
lastName: `User-${Date.now()}`,
email: `test-${Date.now()}@example.com`,
role: 'customer',
...overrides,
};
}
Then:
const admin = createUser({ role: 'admin' });
Even better, where possible:
Test
↓
UserFactory
↓
API
↓
Known application state
↓
Browser test
This is much faster than creating every piece of state through the UI.
Selenium specifically calls out generating application state as an important automation pattern.
Fixtures are one of the most important mechanisms for keeping the framework maintainable.
For example:
export const test = base.extend<{
loggedInUser: User;
checkoutPage: CheckoutPage;
}>({
loggedInUser: async ({}, use) => {
const user = await createUser();
await use(user);
},
checkoutPage: async ({ page }, use) => {
await use(new CheckoutPage(page));
},
});
Now tests simply request what they need:
test('checkout works', async ({
loggedInUser,
checkoutPage,
}) => {
// ...
});
This is preferable to giant beforeEach blocks that initialize everything for every test.
Both Playwright and pytest emphasize fixtures as reusable, composable mechanisms for establishing isolated test environments.
This is probably the single most important scalability principle.
Every test should be capable of running:
alone
+
in random order
+
in parallel
+
on a fresh environment
Avoid:
Test A → creates user
↓
Test B → assumes user exists
↓
Test C → modifies user's state
Instead:
Test A → creates its own state
Test B → creates its own state
Test C → creates its own state
Playwright explicitly recommends isolated tests with their own storage/session state because isolation improves reproducibility and prevents cascading failures.
Suppose your test is:
Admin can deactivate an existing customer.
Don't spend 45 seconds navigating:
Login
→ Users
→ Create User
→ Fill form
→ Submit
→ Search
→ Open
→ ...
Instead:
API → create customer
↓
Browser → navigate to customer
↓
Browser → deactivate
↓
API → verify state
This makes tests dramatically faster and less fragile.
Use the UI to test the UI—not as your universal mechanism for constructing test state.
For UI automation, create explicit rules.
Prefer:
page.getByRole('button', { name: 'Submit' })
or:
page.getByLabel('Email')
over:
page.locator('.btn-primary:nth-child(2)')
The latter couples your test to implementation details.
Playwright specifically recommends user-facing attributes and explicit contracts, and its locators provide auto-waiting/retry behavior.
For difficult applications, establish dedicated test IDs:
<button data-testid="checkout-submit">
Place order
</button>
Then:
page.getByTestId('checkout-submit')
Don't make every element a test ID by default. Use them where semantic/user-facing locators aren't stable enough.
One of the fastest ways to destroy a framework:
await page.waitForTimeout(5000);
Instead wait for something meaningful:
await expect(page.getByText('Order confirmed')).toBeVisible();
or:
await page.waitForResponse('**/api/orders');
or use the framework's normal action/assertion waiting.
Your rule should be:
Never wait for time; wait for state.
Don't scatter environment URLs throughout the test suite.
Use something like:
ENV=staging
BROWSER=chromium
HEADLESS=true
WORKERS=4
and centrally resolve:
const environments = {
dev: {
baseURL: '...',
},
staging: {
baseURL: '...',
},
production: {
baseURL: '...',
},
};
Secrets should come from your CI/CD secret store or environment variables—not source control.
When a test fails in CI, you want to answer:
What happened?
not:
"It failed somewhere in checkout."
Collect:
Your failure report should ideally say:
❌ checkout.spec.ts
Test: customer can complete checkout
Environment: staging
Browser: Chromium
Duration: 12.4s
Failure:
Expected "Order placed"
Received "Payment failed"
Screenshot: ...
Trace: ...
API logs: ...
That turns debugging from detective work into diagnosis.
Don't run everything on every commit.
A useful model:
Lint
↓
Unit tests
↓
API/integration tests
↓
Small smoke E2E suite
Full regression
+
Cross-browser
+
Parallel execution
Full regression
+
Extended scenarios
+
Compatibility matrix
+
Long-running tests
If you have hundreds/thousands of tests, distribute them across workers/machines. Selenium Grid, for example, is specifically intended for running tests across multiple machines and browsers.
Don't normalize:
"The test sometimes fails; just rerun it."
Track:
Pass rate
Flake rate
Execution time
Failure categories
Retry rate
A retry can be useful for infrastructure resilience, but it shouldn't conceal bad tests.
For example:
Test:
passed first attempt → healthy
Test:
failed → passed on retry → investigate
Test:
failed twice → real failure
Set a framework-level goal such as:
<1% unexplained flake rate
and continuously work toward it.
A common mistake is building:
BasePage
↓
BaseComponent
↓
BaseTest
↓
BaseService
↓
GenericUtils
↓
GenericFactory
↓
GenericHelper
before you've written ten tests.
You end up building a software framework nobody understands.
Start with:
Test
Page Object
Fixture
Test Data
Config
Reporting
Then extract abstractions when repetition or complexity proves they are needed.
A useful rule:
Duplication is cheaper than the wrong abstraction.
Treat automation code like production code.
Require:
Add CI checks so bad practices don't slowly accumulate.
At scale, the framework needs ownership.
Decide:
Framework team
↓
Infrastructure / fixtures / configuration
Feature teams
↓
Feature-specific tests
QA/automation team
↓
Test strategy / quality standards
DevOps
↓
CI infrastructure / execution capacity
Without ownership, frameworks eventually become collections of abandoned utilities.
Don't measure success by:
"We have 2,000 automated tests."
Measure:
| Metric | What it tells you |
|---|---|
| Pass rate | Basic stability |
| Flake rate | Framework/test quality |
| Runtime | Feedback speed |
| Failure diagnosis time | Observability |
| Defects caught | Business value |
| Maintenance hours | Cost |
| CI resource consumption | Scalability |
| Coverage of critical flows | Risk reduction |
A suite with 300 reliable tests can be much more valuable than one with 5,000 flaky tests.
I'd build it in phases.
Choose language/tool
Set up repository
Configure test runner
Add environment config
Add linting/formatting
Create first smoke tests
Page/component objects
Fixtures
API clients
Test-data factories
Authentication handling
Reusable assertions
Test isolation
Stable locators
Remove sleeps
Deterministic data
Retries only where justified
Failure artifacts
Tracing/logging
PR suite
Regression suite
Parallel workers
Browser matrix
Test reporting
Failure notifications
Distributed execution
Test tagging
Sharding
Quarantine workflow
Flake analytics
Performance optimization
Framework versioning
I'd aim for something like:
┌─────────────────────┐
│ CI/CD │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Test Runner │
└──────────┬──────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌──────▼──────┐
│ E2E │ │ API │ │ Integration │
└─────┬─────┘ └─────┬─────┘ └──────┬──────┘
│ │ │
┌─────▼─────────────────▼─────────────────▼─────┐
│ Fixtures / Services │
└─────────────────────┬─────────────────────────┘
│
┌────────────▼────────────┐
│ Test Data Layer │
│ factories / builders / │
│ API setup / cleanup │
└────────────┬────────────┘
│
┌──────────────▼──────────────┐
│ Application │
│ UI / API / DB / Services │
└─────────────────────────────┘
The key architectural principle is separation of concerns:
Tests describe behavior.
Page objects/components describe UI interaction.
Services describe APIs.
Fixtures construct dependencies.
Factories create data.
Configuration controls environments.
CI controls execution.
Reporting explains failures.
If you get those boundaries right, adding the 500th test should feel roughly like adding the 50th—not like fighting the framework.
Building a scalable and maintainable test automation framework from scratch requires a solid architectural foundation. Treating your test code with the same software engineering standards as your production code ensures it stays reliable as your product grows.
Would you like to dive deeper into a specific part of this, such as:
A scalable test automation framework is less about picking a tool and more about architecture, test design, isolation, observability, and CI execution.
For a modern web application, I’d build it roughly like this:
┌─────────────────────┐
│ CI/CD │
│ PR / Nightly / Prod │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Test Runner │
│ Playwright / pytest │
└──────────┬──────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Tests │ │ Fixtures │ │ Test Data │
│ Business │ │ & Hooks │ │ Factory │
│ scenarios │ │ │ │ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌──────────▼──────────┐
│ Domain / Page │
│ Objects │
└──────────┬──────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ UI │ │ API │ │ DB │
│ automation│ │ clients │ │ utilities │
└───────────┘ └───────────┘ └───────────┘
Before writing automation, decide what belongs at each testing level.
A good distribution is roughly:
Don't turn every test into a browser test. Browser tests are relatively expensive and infrastructure-heavy; Selenium explicitly recommends considering lower-level tests when a browser isn't necessary.
For example:
Number of tests
▲
│ █████████████████
Unit │ █████████████████
│
API │ ██████████
│
E2E │ ███
│
└──────────────────► Execution cost
The goal isn't "100% automated UI coverage." It's maximum useful coverage at sustainable cost.
For a new web application today, I'd strongly consider:
TypeScript + Playwright Test
Playwright Test already provides the test runner, assertions, isolation, parallel execution, browser support, and reporting/tooling.
A typical stack:
| Concern | Choice |
|---|---|
| Language | TypeScript |
| UI | Playwright |
| API | Playwright request or dedicated HTTP client |
| Assertions | Playwright expect |
| Test data | Factory/builders |
| Configuration | Environment variables + config |
| Reporting | HTML + CI artifacts |
| CI | GitHub Actions / GitLab / Jenkins / Azure DevOps |
| Linting | ESLint |
| Formatting | Prettier |
| Type checking | TypeScript |
| Containerization | Docker, when useful |
Selenium is still an excellent choice if your organization already has substantial Selenium expertise/infrastructure, needs its ecosystem, or has requirements Playwright doesn't fit. The architectural principles below apply to either.
Don't start with a giant utils/ folder.
Something like:
automation/
│
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── logout.spec.ts
│ │
│ ├── checkout/
│ │ ├── checkout.spec.ts
│ │ └── payments.spec.ts
│ │
│ └── users/
│ └── user-management.spec.ts
│
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── CheckoutPage.ts
│
├── components/
│ ├── Header.ts
│ ├── Navigation.ts
│ └── ProductCard.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
│
├── config/
│ └── environments.ts
│
├── utils/
│ ├── dates.ts
│ └── assertions.ts
│
├── playwright.config.ts
└── package.json
The important principle is:
Organize around responsibility, not convenience.
If every helper ends up in utils.ts, your architecture is already starting to decay.
A test should read almost like a requirement.
Bad:
test('checkout', async ({ page }) => {
await page.locator('#product-123').click();
await page.locator('.cart-icon').click();
await page.locator('#checkout-button').click();
await page.locator('#firstName').fill('John');
// ...
});
Better:
test('customer can purchase a product', async ({ checkout }) => {
await checkout.addProduct('Laptop');
await checkout.completePurchase(customer);
await expect(checkout).toShowConfirmation();
});
The test describes what the user is doing, while the framework handles how the application implements it.
This separation is one of the biggest factors in long-term maintainability.
Page Object Model is useful because it centralizes UI knowledge and reduces duplication. Selenium's current guidance specifically recommends using page objects to separate page-specific implementation from test code.
For example:
export class LoginPage {
constructor(private page: Page) {}
async login(username: string, password: string) {
await this.page
.getByLabel('Username')
.fill(username);
await this.page
.getByLabel('Password')
.fill(password);
await this.page
.getByRole('button', { name: 'Sign in' })
.click();
}
}
Then:
test('valid user can sign in', async ({ loginPage, page }) => {
await loginPage.login(user.email, user.password);
await expect(
page.getByRole('heading', { name: 'Dashboard' })
).toBeVisible();
});
Notice that the assertion remains in the test.
That's intentional. Selenium recommends that page objects generally shouldn't contain test assertions; they should model the services/behavior of the page.
BasePageThis is a common framework failure:
BasePage
├── click()
├── type()
├── wait()
├── select()
├── login()
├── databaseQuery()
├── createUser()
├── screenshot()
├── ...
Eventually every page inherits everything.
Instead, use small page objects + reusable components + domain services.
Avoid brittle selectors like:
page.locator('div:nth-child(3) > span > button')
or:
page.locator('.css-8df72a')
Prefer:
page.getByRole('button', { name: 'Submit' })
or:
page.getByLabel('Email')
Playwright specifically recommends user-facing attributes and explicit contracts, and its locators provide automatic waiting/retry behavior.
Even better, establish an application-wide convention for stable test identifiers when semantic locators aren't appropriate:
<button data-testid="checkout-submit">
Place order
</button>
Then:
page.getByTestId('checkout-submit')
This is probably the single most important scalability rule.
Bad:
Test A → creates user
↓
Test B → modifies user
↓
Test C → deletes user
Now you can't safely parallelize them.
Instead:
Test A → creates its own user
Test B → creates its own user
Test C → creates its own user
Playwright explicitly recommends isolated tests with their own state, storage, cookies, etc., because isolation improves reproducibility and prevents cascading failures.
Think:
Any test should be runnable by itself, in any order, on any worker.
Don't scatter this throughout tests:
const username = 'testuser123';
const email = '[email protected]';
const phone = '5551234567';
Instead use factories:
const user = UserFactory.create({
role: 'admin'
});
For example:
class UserFactory {
static create(overrides = {}) {
return {
name: `Test User ${crypto.randomUUID()}`,
email: `test-${crypto.randomUUID()}@example.com`,
role: 'customer',
...overrides
};
}
}
For larger systems, go one step further:
Test
│
├── UserFactory
│
├── ProductFactory
│
└── OrderFactory
│
▼
API setup
│
▼
Application
Use APIs or direct backend mechanisms to create test state whenever possible.
Don't make every test navigate through five UI screens just to create a user.
A scalable E2E test often looks like:
Arrange
↓
API / fixture / factory
↓
Act
↓
UI
↓
Assert
Example:
const user = await userApi.createUser();
await loginPage.login(user.email, user.password);
await dashboard.openProfile();
await expect(
dashboard.profileName
).toHaveText(user.name);
This can dramatically reduce execution time.
Playwright also recommends avoiding tests of third-party dependencies and provides network interception mechanisms when external dependencies need to be controlled.
Fixtures should provide capabilities, not hide the entire test.
For example:
const test = base.extend<{
loggedInUser: User;
dashboard: DashboardPage;
}>({
loggedInUser: async ({ userApi }, use) => {
const user = await userApi.createUser();
await use(user);
},
dashboard: async ({ page }, use) => {
await use(new DashboardPage(page));
}
});
Then:
test('user can view dashboard', async ({
loggedInUser,
dashboard
}) => {
await dashboard.loginAs(loggedInUser);
await expect(dashboard.heading)
.toHaveText('Dashboard');
});
The test becomes dramatically easier to understand.
Avoid:
if (environment === 'qa') {
// ...
}
if (environment === 'staging') {
// ...
}
throughout the codebase.
Centralize it:
export const config = {
baseUrl: process.env.BASE_URL!,
apiUrl: process.env.API_URL!,
environment: process.env.TEST_ENV ?? 'local'
};
Then:
local
qa
staging
production
becomes configuration rather than branching business logic.
Never hard-code credentials in the repository.
A framework that works with 10 tests can collapse at 10,000 tests.
Think about parallelism immediately:
Test suite
│
┌─────────┼─────────┐
▼ ▼ ▼
Worker 1 Worker 2 Worker 3
│ │ │
Tests Tests Tests
That requires:
Playwright supports parallel execution and sharding across multiple machines.
For example:
npx playwright test --shard=1/4
can divide the suite across four CI jobs.
When a test fails in CI, you should be able to answer:
What happened?
without reproducing it locally.
Collect:
Playwright's trace viewer can capture a timeline, DOM snapshots, network information, and other debugging data.
Your failure report should ideally look like:
❌ Checkout / expired card
Environment: staging
Browser: Chromium
Duration: 14.2s
Expected:
Payment error displayed
Actual:
Checkout page remained loading
Artifacts:
✓ Screenshot
✓ Trace
✓ Console log
✓ Network log
That's much more valuable than:
ElementNotFoundException
Be careful with:
test fails
↓
retry
↓
passes
↓
"Great!"
You've potentially just hidden a flaky test.
Instead, track:
Pass
Fail
Flaky
Skipped
Blocked
A test that passes only after retry should be visible as flaky.
Track framework health metrics such as:
This turns test automation into an engineering system rather than a pile of scripts.
Don't run 5,000 tests on every developer commit.
Use tiers:
Lint
↓
Type check
↓
Unit/API tests
↓
Small E2E smoke suite
Full API suite
+
Full E2E suite
Full cross-browser
+
Regression
+
Visual
+
Long-running scenarios
Deploy
↓
Smoke tests
↓
Monitor
Playwright recommends running tests frequently in CI and supports parallelism/sharding to reduce execution time.
Write these down.
For example:
sleep() callsThis is how you prevent five engineers from creating five incompatible automation styles.
sleep(5000)Replace:
await page.waitForTimeout(5000);
with an actual condition:
await expect(page.getByText('Order completed'))
.toBeVisible();
Playwright's actionability checks and web-first assertions are specifically designed to avoid unnecessary manual waiting.
login
→ create account
→ create product
→ add product
→ checkout
→ payment
→ refund
→ logout
Split these into focused scenarios.
01-create-user
02-update-user
03-delete-user
Bad.
Use API/database mechanisms for setup where appropriate.
Prefer small domain-specific services.
Keep the test's expected behavior visible.
I'd aim for these layers:
┌───────────────────────────────────────────┐
│ TESTS │
│ "Customer can complete checkout" │
├───────────────────────────────────────────┤
│ DOMAIN SERVICES │
│ CheckoutService / UserService / etc. │
├───────────────────────────────────────────┤
│ PAGE / COMPONENT OBJECTS │
│ CheckoutPage / Header / ProductCard │
├───────────────────────────────────────────┤
│ API / DATA / INFRASTRUCTURE │
│ API clients / factories / DB utilities │
├───────────────────────────────────────────┤
│ TEST FRAMEWORK │
│ Playwright / Selenium / etc. │
└───────────────────────────────────────────┘
The dependency direction should generally be:
Tests
↓
Domain abstractions
↓
Page/API/data abstractions
↓
Framework
Not:
Tests
↕
Page Objects
↕
Utilities
↕
Random global state
Don't spend three months building "the perfect framework" before writing tests.
I'd use this sequence:
If I had to reduce the whole framework to one rule:
Tests should describe business behavior; framework code should hide implementation details.
For example, this:
await loginPage.enterUsername(...);
await loginPage.enterPassword(...);
await loginPage.clickSubmit();
await dashboard.waitForHeader();
is already better than raw browser calls.
But this is even better:
await account.loginAs(user);
await expect(account).toBeLoggedIn();
The first version exposes how the application works.
The second expresses what the user is doing.
That's the abstraction boundary that lets a test suite grow from 50 tests to thousands without becoming unmaintainable.
For a new web project, I'd therefore start with Playwright + TypeScript, isolated tests, API-assisted test-data setup, page/component objects, domain-level fixtures, CI parallelism, and first-class failure diagnostics. That combination aligns well with the current Playwright guidance around isolation, resilient locators, CI, parallelism, and tooling.