Building a Playwright Framework From Scratch
Walking through the folder structure, page object design and configuration decisions behind a maintainable Playwright framework.
July 30, 202617 min read
Building a Playwright Framework From Scratch
Introduction
Starting a Playwright project is easy.
You can install Playwright, create a test file, and run your first browser test in only a few minutes.
npm init playwright@latest
However, building a Playwright framework is different from writing a few Playwright tests.
A real automation framework must support:
- Multiple environments
- Reusable Page Objects
- Test data management
- API testing
- Fixtures
- Logging
- Reports
- Screenshots
- Parallel execution
- CI/CD integration
- Long-term maintenance
Without a clear structure, a small test project can quickly become difficult to understand and expensive to maintain.
In this article, we will build a scalable Playwright framework from scratch using TypeScript.
What We Are Building
The final framework will include:
playwright-framework/
├── config/
├── data/
├── fixtures/
├── pages/
├── components/
├── api/
├── tests/
├── utils/
├── playwright.config.ts
├── package.json
└── tsconfig.json
The framework will support:
- Page Object Model
- Reusable components
- Custom fixtures
- Environment configuration
- Authentication state
- API-based setup
- Test data separation
- Screenshots, videos, and traces
- Smoke and regression tags
- Parallel execution
- CI execution
Prerequisites
Before starting, make sure you have:
- Node.js installed
- npm installed
- Basic TypeScript knowledge
- Basic Playwright knowledge
- A code editor such as Visual Studio Code
Verify the installation:
node --version
npm --version
Step 1: Create the Project
Create a new project folder.
mkdir playwright-framework
cd playwright-framework
Initialize Node.js:
npm init -y
Install Playwright:
npm install -D @playwright/test
Install the supported browsers:
npx playwright install
For CI environments that require operating-system dependencies:
npx playwright install --with-deps
Step 2: Create the TypeScript Configuration
Create a file named:
tsconfig.json
Add:
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@pages/*": ["pages/*"],
"@components/*": ["components/*"],
"@fixtures/*": ["fixtures/*"],
"@data/*": ["data/*"],
"@utils/*": ["utils/*"],
"@api/*": ["api/*"],
"@config/*": ["config/*"]
}
},
"include": [
"**/*.ts"
]
}
The path aliases keep imports readable.
Instead of:
import { LoginPage } from '../../../pages/LoginPage';
You can use:
import { LoginPage } from '@pages/LoginPage';
Step 3: Create the Initial Folder Structure
Create the following folders:
config/
data/
fixtures/
pages/
components/
api/
tests/
utils/
The structure becomes:
playwright-framework/
├── api/
├── components/
├── config/
├── data/
├── fixtures/
├── pages/
├── tests/
├── utils/
├── package.json
└── tsconfig.json
Each folder should have a clear responsibility.
Step 4: Create the Playwright Configuration
Create:
playwright.config.ts
Add:
import {
defineConfig,
devices,
} from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: true,
forbidOnly: Boolean(
process.env.CI,
),
retries:
process.env.CI ? 2 : 0,
workers:
process.env.CI ? 2 : undefined,
reporter: [
['list'],
[
'html',
{
outputFolder:
'playwright-report',
open: 'never',
},
],
],
use: {
baseURL:
process.env.BASE_URL ??
'https://example.com',
trace:
'retain-on-failure',
screenshot:
'only-on-failure',
video:
'retain-on-failure',
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
projects: [
{
name: 'chromium',
use: {
...devices[
'Desktop Chrome'
],
},
},
{
name: 'firefox',
use: {
...devices[
'Desktop Firefox'
],
},
},
{
name: 'webkit',
use: {
...devices[
'Desktop Safari'
],
},
},
],
});
This configuration enables:
- Parallel execution
- CI retries
- Multiple browsers
- HTML reports
- Screenshots on failure
- Video on failure
- Trace collection
- Configurable base URL
Step 5: Add Environment Configuration
Hardcoding environment URLs directly in the framework makes maintenance difficult.
Create:
config/environment.ts
Add:
export type EnvironmentName =
| 'dev'
| 'qa'
| 'staging';
type EnvironmentConfig = {
baseURL: string;
apiURL: string;
};
const environments:
Record<
EnvironmentName,
EnvironmentConfig
> = {
dev: {
baseURL:
'https://dev.example.com',
apiURL:
'https://api-dev.example.com',
},
qa: {
baseURL:
'https://qa.example.com',
apiURL:
'https://api-qa.example.com',
},
staging: {
baseURL:
'https://staging.example.com',
apiURL:
'https://api-staging.example.com',
},
};
const environmentName =
(
process.env.TEST_ENV ??
'qa'
) as EnvironmentName;
const environment =
environments[environmentName];
if (!environment) {
throw new Error(
`Unsupported environment: ${environmentName}`,
);
}
export {
environment,
environmentName,
};
Update playwright.config.ts:
import {
environment,
} from './config/environment';
Then:
use: {
baseURL: environment.baseURL,
}
Run against a specific environment:
TEST_ENV=staging npx playwright test
On Windows Command Prompt:
set TEST_ENV=staging&& npx playwright test
On PowerShell:
$env:TEST_ENV="staging"
npx playwright test
Step 6: Add Environment Variables
Install dotenv:
npm install dotenv
Create:
.env
Example:
TEST_ENV=qa
TEST_USERNAME=tester@example.com
TEST_PASSWORD=Password123
Load it inside playwright.config.ts:
import 'dotenv/config';
Add .env to .gitignore:
.env
Never commit real credentials to source control.
Step 7: Create a Base Page
Create:
pages/BasePage.ts
Add:
import {
Page,
} from '@playwright/test';
export class BasePage {
constructor(
protected readonly page: Page,
) {}
async open(
path = '/',
): Promise<void> {
await this.page.goto(path);
}
async reload(): Promise<void> {
await this.page.reload();
}
async waitForPageReady():
Promise<void> {
await this.page.waitForLoadState(
'domcontentloaded',
);
}
}
Keep the Base Page small.
It should contain only genuinely shared page behaviour.
Avoid turning it into a massive utility class with unrelated methods.
Step 8: Create the First Page Object
Create:
pages/LoginPage.ts
Add:
import {
Locator,
Page,
} from '@playwright/test';
import {
BasePage,
} from './BasePage';
export class LoginPage
extends BasePage {
readonly emailInput:
Locator;
readonly passwordInput:
Locator;
readonly loginButton:
Locator;
readonly errorMessage:
Locator;
constructor(page: Page) {
super(page);
this.emailInput =
page.getByLabel(
'Email',
);
this.passwordInput =
page.getByLabel(
'Password',
);
this.loginButton =
page.getByRole(
'button',
{
name: 'Login',
},
);
this.errorMessage =
page.getByRole(
'alert',
);
}
async openLoginPage():
Promise<void> {
await this.open('/login');
}
async login(
email: string,
password: string,
): Promise<void> {
await this.emailInput.fill(
email,
);
await this.passwordInput.fill(
password,
);
await this.loginButton.click();
}
}
The page object contains:
- Locators
- Page interactions
- Navigation methods
It does not contain test data or test assertions.
Step 9: Create Another Page Object
Create:
pages/DashboardPage.ts
Add:
import {
Locator,
Page,
} from '@playwright/test';
import {
BasePage,
} from './BasePage';
export class DashboardPage
extends BasePage {
readonly heading:
Locator;
readonly welcomeMessage:
Locator;
constructor(page: Page) {
super(page);
this.heading =
page.getByRole(
'heading',
{
name: 'Dashboard',
},
);
this.welcomeMessage =
page.getByTestId(
'welcome-message',
);
}
}
Tests can now verify the result of a successful login through the dashboard page.
Step 10: Create Reusable Components
Many application elements appear on multiple pages.
Examples include:
- Headers
- Navigation menus
- Sidebars
- Modals
- Toast notifications
- Pagination
Create:
components/HeaderComponent.ts
Add:
import {
Locator,
Page,
} from '@playwright/test';
export class HeaderComponent {
readonly profileMenu:
Locator;
readonly logoutButton:
Locator;
constructor(
private readonly page: Page,
) {
this.profileMenu =
page.getByTestId(
'profile-menu',
);
this.logoutButton =
page.getByRole(
'button',
{
name: 'Logout',
},
);
}
async logout():
Promise<void> {
await this.profileMenu.click();
await this.logoutButton.click();
}
}
Use it inside a page object:
import {
HeaderComponent,
} from '@components/HeaderComponent';
export class DashboardPage
extends BasePage {
readonly header:
HeaderComponent;
constructor(page: Page) {
super(page);
this.header =
new HeaderComponent(page);
}
}
This approach uses composition instead of duplicating header logic across pages.
Step 11: Create Test Data
Create:
data/users.ts
Add:
export type TestUser = {
email: string;
password: string;
};
export const validUser:
TestUser = {
email:
process.env.TEST_USERNAME ??
'',
password:
process.env.TEST_PASSWORD ??
'',
};
export const invalidUser:
TestUser = {
email:
'invalid@example.com',
password:
'WrongPassword',
};
Validate required variables:
if (
!validUser.email ||
!validUser.password
) {
throw new Error(
'TEST_USERNAME and TEST_PASSWORD are required',
);
}
This keeps test data separate from test logic.
Step 12: Create Custom Fixtures
Custom fixtures allow Page Objects to be injected into tests automatically.
Create:
fixtures/app.fixture.ts
Add:
import {
test as base,
expect,
} from '@playwright/test';
import {
LoginPage,
} from '@pages/LoginPage';
import {
DashboardPage,
} from '@pages/DashboardPage';
type AppFixtures = {
loginPage: LoginPage;
dashboardPage:
DashboardPage;
};
const test =
base.extend<AppFixtures>({
loginPage: async (
{ page },
use,
) => {
await use(
new LoginPage(page),
);
},
dashboardPage: async (
{ page },
use,
) => {
await use(
new DashboardPage(page),
);
},
});
export {
test,
expect,
};
Tests no longer need to create page objects manually.
Step 13: Write the First Test
Create:
tests/authentication/login.spec.ts
Add:
import {
test,
expect,
} from '@fixtures/app.fixture';
import {
validUser,
} from '@data/users';
test.describe(
'Login',
() => {
test(
'user can login successfully @smoke',
async ({
loginPage,
dashboardPage,
}) => {
await loginPage
.openLoginPage();
await loginPage.login(
validUser.email,
validUser.password,
);
await expect(
dashboardPage.heading,
).toBeVisible();
await expect(
dashboardPage
.welcomeMessage,
).toContainText(
validUser.email,
);
},
);
},
);
Run it:
npx playwright test
Run only Chromium:
npx playwright test --project=chromium
Run in headed mode:
npx playwright test --headed
Step 14: Add a Negative Test
Add:
import {
invalidUser,
} from '@data/users';
test(
'user cannot login with invalid credentials @regression',
async ({
loginPage,
}) => {
await loginPage
.openLoginPage();
await loginPage.login(
invalidUser.email,
invalidUser.password,
);
await expect(
loginPage.errorMessage,
).toContainText(
'Invalid credentials',
);
},
);
This test remains independent from the successful login test.
Step 15: Add API Support
API calls are useful for:
- Creating test data
- Deleting test data
- Authentication
- Backend validation
- Faster setup
Create:
api/UserApi.ts
Add:
import {
APIRequestContext,
expect,
} from '@playwright/test';
import {
environment,
} from '@config/environment';
export type CreatedUser = {
id: string;
email: string;
password: string;
};
export class UserApi {
constructor(
private readonly request:
APIRequestContext,
) {}
async createUser():
Promise<CreatedUser> {
const uniqueId =
`${Date.now()}-${Math.random()}`;
const user = {
email:
`tester-${uniqueId}@example.com`,
password:
'Password123',
};
const response =
await this.request.post(
`${environment.apiURL}/users`,
{
data: user,
},
);
expect(
response.ok(),
).toBeTruthy();
const body =
await response.json();
return {
id: body.id,
...user,
};
}
async deleteUser(
userId: string,
): Promise<void> {
const response =
await this.request.delete(
`${environment.apiURL}/users/${userId}`,
);
expect(
response.ok(),
).toBeTruthy();
}
}
Step 16: Add the API Client to Fixtures
Update:
fixtures/app.fixture.ts
Add:
import {
UserApi,
} from '@api/UserApi';
Update the fixture type:
type AppFixtures = {
loginPage: LoginPage;
dashboardPage:
DashboardPage;
userApi: UserApi;
};
Add the fixture:
userApi: async (
{ request },
use,
) => {
await use(
new UserApi(request),
);
},
Now tests can use API setup directly.
Step 17: Create Data Through the API
Example:
test(
'new user can login @regression',
async ({
loginPage,
dashboardPage,
userApi,
}) => {
const user =
await userApi.createUser();
try {
await loginPage
.openLoginPage();
await loginPage.login(
user.email,
user.password,
);
await expect(
dashboardPage.heading,
).toBeVisible();
} finally {
await userApi.deleteUser(
user.id,
);
}
},
);
The finally block ensures cleanup happens even if the test fails.
Step 18: Create an Authentication Setup Project
Logging in through the UI before every test is slow.
Create:
tests/setup/auth.setup.ts
Add:
import {
test as setup,
expect,
} from '@playwright/test';
import {
validUser,
} from '@data/users';
const authFile =
'playwright/.auth/user.json';
setup(
'authenticate',
async ({ page }) => {
await page.goto('/login');
await page
.getByLabel('Email')
.fill(validUser.email);
await page
.getByLabel('Password')
.fill(validUser.password);
await page
.getByRole(
'button',
{
name: 'Login',
},
)
.click();
await expect(page)
.toHaveURL(/dashboard/);
await page
.context()
.storageState({
path: authFile,
});
},
);
Create the directory:
playwright/.auth/
Add it to .gitignore:
playwright/.auth/
Step 19: Configure Authentication Dependencies
Update playwright.config.ts.
Add a setup project:
projects: [
{
name: 'setup',
testMatch:
/.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices[
'Desktop Chrome'
],
storageState:
'playwright/.auth/user.json',
},
dependencies: [
'setup',
],
},
],
Authenticated tests now reuse the saved browser state.
Keep dedicated login tests separate so they still validate the login flow itself.
Step 20: Add Meaningful Test Steps
For longer tests, use test.step().
test(
'customer can update profile',
async ({
dashboardPage,
}) => {
await test.step(
'Open profile page',
async () => {
await dashboardPage
.header
.profileMenu
.click();
},
);
await test.step(
'Update customer details',
async () => {
// Test actions
},
);
await test.step(
'Verify update success',
async () => {
// Assertions
},
);
},
);
Steps improve:
- HTML reports
- Trace readability
- Failure investigation
- Business-level documentation
Step 21: Add Utility Functions
Avoid creating one giant Utils.ts.
Create focused utilities instead.
Example:
utils/string.util.ts
export function createUniqueEmail():
string {
return [
'tester',
Date.now(),
Math.random()
.toString(36)
.slice(2),
].join('-') +
'@example.com';
}
Example:
utils/date.util.ts
export function formatDate(
date: Date,
): string {
return date
.toISOString()
.split('T')[0];
}
Utilities should be:
- Small
- Focused
- Reusable
- Free from unrelated business logic
Step 22: Add Automatic Failure Attachments
Playwright automatically captures artifacts based on the configuration.
You can also add custom attachments.
Create:
fixtures/diagnostic.fixture.ts
Example:
import {
test as base,
} from '@playwright/test';
export const test =
base.extend({
page: async (
{ page },
use,
testInfo,
) => {
await use(page);
if (
testInfo.status !==
testInfo.expectedStatus
) {
const screenshot =
await page.screenshot({
fullPage: true,
});
await testInfo.attach(
'failure-screenshot',
{
body: screenshot,
contentType:
'image/png',
},
);
}
},
});
Use this only when default screenshots are not sufficient.
Avoid duplicating artifacts unnecessarily.
Step 23: Add Test Tags
Tags allow different test groups to run in different pipelines.
Example:
test(
'user can login @smoke',
async () => {
// Test
},
);
Run smoke tests:
npx playwright test --grep @smoke
Run regression tests:
npx playwright test --grep @regression
Exclude slow tests:
npx playwright test --grep-invert @slow
Common tags include:
@smoke
@regression
@critical
@api
@mobile
@slow
Step 24: Add npm Scripts
Update package.json.
{
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:smoke": "playwright test --grep @smoke",
"test:regression": "playwright test --grep @regression",
"test:chromium": "playwright test --project=chromium",
"report": "playwright show-report"
}
}
Now you can run:
npm test
Or:
npm run test:smoke
Step 25: Add Linting and Formatting
Install ESLint and Prettier:
npm install -D eslint prettier typescript-eslint
Create:
eslint.config.js
Example:
const tseslint =
require('typescript-eslint');
module.exports =
tseslint.config(
...tseslint.configs
.recommended,
{
files: [
'**/*.ts',
],
rules: {
'@typescript-eslint/no-explicit-any':
'error',
},
},
);
Create:
.prettierrc
{
"singleQuote": true,
"semi": true,
"trailingComma": "all"
}
Add scripts:
{
"scripts": {
"lint": "eslint .",
"format": "prettier --write ."
}
}
Consistent formatting improves collaboration and code review quality.
Step 26: Prepare for Parallel Execution
Playwright can run tests across multiple workers.
To make tests safe for parallel execution:
- Avoid shared global variables
- Avoid shared accounts
- Avoid shared files
- Use unique test data
- Isolate browser state
- Keep tests independent
- Use unique output paths
Example unique test data:
const uniqueEmail =
`tester-${test.info().workerIndex}-${Date.now()}@example.com`;
For output files:
const filePath =
test.info().outputPath(
'export.xlsx',
);
outputPath() automatically isolates artifacts by test.
Step 27: Add Mobile Testing
Add a mobile project:
{
name: 'mobile-chrome',
use: {
...devices[
'Pixel 7'
],
},
},
Run:
npx playwright test --project=mobile-chrome
You can also mark mobile-only tests:
test(
'mobile menu opens @mobile',
async ({ page }) => {
// Test
},
);
Step 28: Add CI/CD Integration
Example GitHub Actions workflow:
.github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Install browsers
run: npx playwright install --with-deps
- name: Run tests
run: npm run test:smoke
env:
TEST_ENV: qa
TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
This pipeline:
- Checks out the project
- Installs Node.js
- Installs dependencies
- Installs browsers
- Runs smoke tests
- Uploads the HTML report
Step 29: Final Project Structure
The framework may now look like this:
playwright-framework/
├── .github/
│ └── workflows/
│ └── playwright.yml
│
├── api/
│ └── UserApi.ts
│
├── components/
│ └── HeaderComponent.ts
│
├── config/
│ └── environment.ts
│
├── data/
│ └── users.ts
│
├── fixtures/
│ ├── app.fixture.ts
│ └── diagnostic.fixture.ts
│
├── pages/
│ ├── BasePage.ts
│ ├── DashboardPage.ts
│ └── LoginPage.ts
│
├── playwright/
│ └── .auth/
│
├── tests/
│ ├── authentication/
│ │ └── login.spec.ts
│ │
│ └── setup/
│ └── auth.setup.ts
│
├── utils/
│ ├── date.util.ts
│ └── string.util.ts
│
├── .env
├── .gitignore
├── .prettierrc
├── eslint.config.js
├── package-lock.json
├── package.json
├── playwright.config.ts
└── tsconfig.json
Important Design Principles
Keep Tests Business-Focused
Tests should describe what the user is doing.
Good:
await checkoutPage
.placeOrder();
Less useful:
await page
.locator('.btn-primary')
.click();
Keep Page Objects Focused
A page object should represent one page or responsibility.
Do not build classes with hundreds of unrelated methods.
Use Components for Shared UI
Reusable UI sections should become component objects.
Examples:
- Header
- Sidebar
- Modal
- Pagination
- Toast
Use APIs for Test Setup
Create test data through APIs whenever possible.
This keeps UI tests focused and reduces execution time.
Avoid Hardcoded Waits
Do not use:
await page.waitForTimeout(
5000,
);
Wait for meaningful conditions:
await expect(
successMessage,
).toBeVisible();
Keep Tests Independent
Every test should create and clean up its own data.
Tests should not depend on execution order.
Avoid Unnecessary Wrappers
Do not wrap every Playwright method without adding meaningful behaviour.
This usually adds complexity rather than value.
Design for Debugging
Enable:
- Traces
- Screenshots
- Videos
- Logs
- HTML reports
A failed test should provide enough evidence to investigate the issue.
Common Mistakes When Starting a Framework
Avoid these common problems:
- Creating a giant
BasePage - Creating one large
Utils.ts - Hardcoding credentials
- Hardcoding environment URLs
- Putting assertions inside every Page Object method
- Using XPath for every locator
- Adding
waitForTimeout()everywhere - Sharing the same user across parallel tests
- Making tests depend on previous tests
- Running the full regression suite for every small code change
- Hiding flaky tests with retries
A framework should reduce complexity, not create more of it.
Framework Readiness Checklist
Before calling the framework production-ready, check:
- Can tests run independently?
- Can tests run safely in parallel?
- Are environments configurable?
- Are secrets stored securely?
- Are Page Objects reusable and focused?
- Is test data separated from test logic?
- Can APIs create and clean up test data?
- Are screenshots, videos, and traces available?
- Can smoke and regression tests run separately?
- Does the framework run in CI?
- Can a new team member understand the project structure?
- Can a failed test be investigated without immediately rerunning it?
If the answer to several questions is no, the framework still needs improvement.
Conclusion
Building a Playwright framework from scratch is not only about installing Playwright and creating Page Object classes.
A maintainable framework requires clear boundaries between:
- Test scenarios
- Page Objects
- Reusable components
- API clients
- Fixtures
- Test data
- Environment configuration
- Utility functions
- Reporting and diagnostics
Start with a simple structure, but design it so the framework can grow.
Use semantic locators, web-first assertions, API-based setup, isolated test data, reusable fixtures, and reliable CI execution. Avoid unnecessary abstraction, shared mutable state, hardcoded waits, and giant classes.
The goal is not to build the most complicated framework.
The goal is to build a framework that your team can understand, trust, and maintain as the application continues to evolve.