
Playwright TypeScript Folder Structure: The Complete Guide (2026)-QaTribe
Setting up your Playwright TypeScript folder structure correctly is the single most important decision you will make when starting an automation project.
Get it right — and your framework scales from 10 tests to 5,000 tests without friction. Your team onboards fast. Your CI pipelines stay clean. Your code reviews are smooth.
Get it wrong — and you will spend more time fighting the framework than writing tests.
I have reviewed dozens of Playwright projects over the years. The ones that fail are not failing because of bad test logic. They fail because nobody thought about the Playwright TypeScript folder structure before writing the first test. Files pile up. Helpers get duplicated. Nobody knows where anything lives.
This guide fixes that — permanently.
By the time you finish reading, you will have a complete, production-grade Playwright TypeScript folder structure that you can use immediately — whether you are starting fresh or migrating an existing messy project.
What You Will Learn
- Why your Playwright TypeScript folder structure is more important than your test logic
- The complete folder tree — explained section by section
- How to implement Page Objects, Fixtures, Helpers, and Config correctly
- Real, working TypeScript code for every folder and file
- How to scale the structure from a solo project to an enterprise team
- CI/CD setup that aligns with your folder conventions
- The most common mistakes and exactly how to avoid them.
Creating the proper Playwright TypeScript project structure, can help to make framework more reliable.
Table of Contents
- Why Folder Structure Matters More Than You Think
- Project Initialization
- The Complete Folder Tree
- The
/testsFolder — Organizing Your Spec Files - The
/pagesFolder — Page Object Model - The
/componentsFolder — Reusable UI Pieces - The
/fixturesFolder — Test Setup Done Right - The
/utilsFolder — Shared Helpers - The
/dataFolder — Test Data Management - The
/configFolder — Environment Management - The
/typesFolder — TypeScript Interfaces - The
/constantsFolder — No More Magic Strings - The
/hooksFolder — Global Setup and Teardown - playwright.config.ts — Deep Dive
- tsconfig.json — TypeScript Configuration
- package.json Scripts — Run Everything
- Environment Strategy — Dev, Staging, Production
- CI/CD Integration
- Scaling for Enterprise Projects
- Common Mistakes
- Migration Guide — Fixing an Existing Project
- Final Complete Structure — Copy and Use
- FAQs
1. Why Folder Structure Matters More Than You Think {#why-folder-structure-matters}
Before we touch a single file, let us understand why the Playwright TypeScript folder structure is so critical.
Imagine this scenario. A new QA engineer joins your team today. You give them access to the repo. You ask them to write a test for the checkout page. One hour later, they come back with questions:
- “Where do I put the test file?”
- “Is there already a Page Object for checkout?”
- “Where do test users come from?”
- “Which base URL should I use for staging?”
If your team cannot answer these questions by pointing to your folder structure — you have a problem.
A great Playwright TypeScript folder structure is living documentation. It is the first thing a new engineer reads, and the last thing a senior engineer changes without team alignment. It answers these questions automatically, before anyone has to ask.
What a Bad Structure Costs You
| Problem | Real-World Impact |
|---|---|
| No Page Objects | One selector change breaks 40 test files |
| No fixture system | Login logic is copy-pasted across 30 tests |
| No config management | Can’t run tests against staging without editing code |
| No data separation | Hardcoded emails cause parallel test failures |
| No type definitions | Refactoring breaks at runtime, not compile time |
What a Good Structure Gives You
A well-designed Playwright TypeScript folder structure gives you five things that no amount of clever test logic can replace:
1. Maintainability — When a developer renames a button, you change one file. Not thirty.
2. Scalability — Going from 50 to 5,000 tests does not require restructuring the project.
3. Onboarding Speed — A new engineer can contribute in hours, not days.
4. CI/CD Reliability — Pipelines work predictably because everything has a defined place.
5. Team Collaboration — No merge conflicts from two engineers accidentally editing the same helper function.
The rule is simple: Your test framework should be as well-architected as your production code. Treat it with the same respect.
2. Project Initialization {#project-initialization}
Let us start from scratch and build the best Playwright TypeScript folder structure step by step.
Step 1 — Create the Project
bash
mkdir my-playwright-project cd my-playwright-project npm init -y
Step 2 — Install Playwright with TypeScript
bash
npm init playwright@latest
Answer the wizard like this:
✔ Do you want to use TypeScript or JavaScript? » TypeScript ✔ Where to put your end-to-end tests? » tests ✔ Add a GitHub Actions workflow? » Yes ✔ Install Playwright browsers (chromium, firefox)? » Yes
Step 3 — Install Supporting Packages
bash
# Test data generation npm install --save-dev @faker-js/faker # Environment variable loading npm install dotenv # Linting npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-playwright # Code formatting npm install --save-dev prettier
Step 4 — Verify Installation
bash
npx playwright test --version # Expected: Version 1.44.x or higher npx tsc --version # Expected: Version 5.x.x
Once this is done, you have a blank canvas. The next sections show you exactly what to build on top of it.
For more details please refer official Playwright installation guide
3. The Complete Folder Tree {#complete-folder-tree}
Here is the full Playwright TypeScript folder structure we will build in this guide. Every folder and file will be explained in detail.
my-playwright-project/ │ ├── 📁 tests/ │ ├── 📁 e2e/ │ │ ├── 📁 auth/ │ │ │ ├── login.spec.ts │ │ │ ├── logout.spec.ts │ │ │ └── forgot-password.spec.ts │ │ ├── 📁 checkout/ │ │ │ ├── cart.spec.ts │ │ │ └── payment.spec.ts │ │ ├── 📁 dashboard/ │ │ │ └── dashboard.spec.ts │ │ └── 📁 profile/ │ │ └── profile-update.spec.ts │ ├── 📁 api/ │ │ ├── user.api.spec.ts │ │ └── product.api.spec.ts │ └── 📁 smoke/ │ └── smoke.spec.ts │ ├── 📁 pages/ │ ├── base.page.ts │ ├── login.page.ts │ ├── dashboard.page.ts │ ├── checkout.page.ts │ └── profile.page.ts │ ├── 📁 components/ │ ├── modal.component.ts │ ├── navbar.component.ts │ ├── datatable.component.ts │ └── toast.component.ts │ ├── 📁 fixtures/ │ ├── index.ts │ ├── auth.fixture.ts │ └── api.fixture.ts │ ├── 📁 utils/ │ ├── api.helper.ts │ ├── date.helper.ts │ ├── string.helper.ts │ ├── wait.helper.ts │ └── logger.ts │ ├── 📁 data/ │ ├── users.json │ ├── products.json │ └── factory/ │ ├── user.factory.ts │ └── product.factory.ts │ ├── 📁 config/ │ ├── app.config.ts │ └── environments/ │ ├── dev.ts │ ├── staging.ts │ └── production.ts │ ├── 📁 types/ │ ├── user.types.ts │ ├── product.types.ts │ └── config.types.ts │ ├── 📁 constants/ │ ├── routes.ts │ ├── messages.ts │ └── timeouts.ts │ ├── 📁 hooks/ │ ├── global-setup.ts │ └── global-teardown.ts │ ├── 📁 reporters/ │ └── slack.reporter.ts │ ├── 📁 .auth/ │ └── user.json ← (gitignored, auto-generated) │ ├── 📁 reports/ ← (gitignored, auto-generated) │ ├── playwright.config.ts ├── tsconfig.json ├── .env ├── .env.example ├── .gitignore └── package.json
This is the complete Playwright TypeScript folder structure used in production-grade projects. Every single folder has a specific responsibility. Nothing bleeds into anything else.
Let us go through each one.
4. The /tests Folder — Organizing Your Spec Files {#tests-folder}
The tests/ folder is where your .spec.ts files live. The most important decision here is how you organize them.
The Wrong Way
tests/ ├── test1.spec.ts ├── loginTest.spec.ts ├── checkoutFinal.spec.ts ├── newCheckout.spec.ts └── dashboardV2.spec.ts
This is a nightmare. Three months in, nobody knows what is current, what is old, or what anything tests.
The Right Way — Feature-Based Organization
Organize by feature, then by test type:
tests/ ├── e2e/ ← Browser-based end-to-end tests │ ├── auth/ ← Authentication feature │ ├── checkout/ ← Checkout feature │ ├── dashboard/ ← Dashboard feature │ └── profile/ ← Profile feature ├── api/ ← API tests using Playwright request context └── smoke/ ← Quick deployment verification tests
Naming Convention for Spec Files
| File Type | Pattern | Example |
|---|---|---|
| Feature test | feature-name.spec.ts | login.spec.ts |
| API test | resource.api.spec.ts | user.api.spec.ts |
| Smoke test | smoke.spec.ts | smoke.spec.ts |
| Regression | feature.regression.spec.ts | checkout.regression.spec.ts |
A Real Spec File — login.spec.ts
Here is what a properly written spec file looks like with this Playwright TypeScript folder structure:
typescript
// tests/e2e/auth/login.spec.ts
import { test, expect } from '../../../fixtures';
import { ROUTES } from '../../../constants/routes';
import { MESSAGES } from '../../../constants/messages';
test.describe('Login — Authentication Tests', () => {
test.beforeEach(async ({ loginPage }) => {
await loginPage.navigate();
});
test('@smoke valid credentials redirect to dashboard', async ({ loginPage, page }) => {
await loginPage.login('valid@qatribe.in', 'Test@12345');
await expect(page).toHaveURL(ROUTES.DASHBOARD);
await expect(page.getByTestId('welcome-banner')).toBeVisible();
});
test('invalid email shows error message', async ({ loginPage }) => {
await loginPage.login('notanemail', 'Test@12345');
const error = await loginPage.getErrorMessage();
expect(error).toContain(MESSAGES.LOGIN.INVALID_EMAIL);
});
test('wrong password shows invalid credentials error', async ({ loginPage }) => {
await loginPage.login('valid@qatribe.in', 'wrongpassword');
const error = await loginPage.getErrorMessage();
expect(error).toContain(MESSAGES.LOGIN.INVALID_CREDENTIALS);
});
test('empty form submit shows validation errors', async ({ loginPage }) => {
await loginPage.clickLogin();
expect(await loginPage.isEmailErrorVisible()).toBe(true);
expect(await loginPage.isPasswordErrorVisible()).toBe(true);
});
test('forgot password link navigates correctly', async ({ loginPage, page }) => {
await loginPage.clickForgotPassword();
await expect(page).toHaveURL(ROUTES.FORGOT_PASSWORD);
});
test('remember me checkbox persists session', async ({ loginPage, page }) => {
await loginPage.checkRememberMe();
await loginPage.login('valid@qatribe.in', 'Test@12345');
// Verify session cookie has extended expiry
const cookies = await page.context().cookies();
const sessionCookie = cookies.find(c => c.name === 'session_token');
expect(sessionCookie?.expires).toBeGreaterThan(Date.now() / 1000 + 86400);
});
});Notice what this spec file does NOT have:
- No raw
page.fill()calls — those live in the Page Object - No hardcoded URLs — those live in
constants/routes.ts - No hardcoded error strings — those live in
constants/messages.ts - No login setup inside the test — that lives in the fixture
Each test is clean, readable, and focused only on what it is testing.
A Real API Spec — user.api.spec.ts
typescript
// tests/api/user.api.spec.ts
import { test, expect } from '../../fixtures';
import { TestUserFactory } from '../../data/factory/user.factory';
test.describe('User API — CRUD Operations', () => {
test('@smoke GET /users returns 200 with array', async ({ apiContext }) => {
const response = await apiContext.get('/users');
expect(response.status()).toBe(200);
const body = await response.json();
expect(Array.isArray(body.data)).toBe(true);
});
test('GET /users/:id returns correct user schema', async ({ apiContext }) => {
const response = await apiContext.get('/users/1');
expect(response.status()).toBe(200);
const user = await response.json();
// Schema assertions
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('email');
expect(user).toHaveProperty('name');
expect(user).not.toHaveProperty('password'); // Security check
expect(typeof user.email).toBe('string');
expect(user.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
});
test('POST /users creates new user and returns 201', async ({ apiContext }) => {
const newUser = TestUserFactory.create();
const response = await apiContext.post('/users', { data: newUser });
expect(response.status()).toBe(201);
const created = await response.json();
expect(created.email).toBe(newUser.email);
expect(created).toHaveProperty('id');
});
test('DELETE /users/:id returns 204', async ({ apiContext }) => {
// First create a user to delete
const newUser = TestUserFactory.create();
const created = await apiContext.post('/users', { data: newUser });
const user = await created.json();
const response = await apiContext.delete(`/users/${user.id}`);
expect(response.status()).toBe(204);
});
});5. The /pages Folder — Page Object Model {#pages-folder}
The pages/ folder implements the Page Object Model (POM) — the most important design pattern in any serious Playwright TypeScript folder structure.
The core idea: Every page of your application gets its own TypeScript class that encapsulates all selectors and interactions for that page. Tests use the class — they never write raw selectors themselves.
Why POM is Non-Negotiable
Imagine you have 50 tests that all use page.getByPlaceholder('Enter email') directly. The developer renames the placeholder to Email address. Now you have 50 broken tests to fix.
With POM, you update one line in login.page.ts. Every test is fixed automatically.
The Base Page — pages/base.page.ts
Every page object extends a BasePage that handles common behaviors:
typescript
// pages/base.page.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
protected readonly page: Page;
constructor(page: Page) {
this.page = page;
}
/**
* Navigate to a given path
*/
async navigate(path: string = '/'): Promise<void> {
await this.page.goto(path);
await this.waitForPageReady();
}
/**
* Wait for the page to be fully loaded
*/
async waitForPageReady(): Promise<void> {
await this.page.waitForLoadState('domcontentloaded');
}
/**
* Get the current page title
*/
async getTitle(): Promise<string> {
return this.page.title();
}
/**
* Take a full-page screenshot
*/
async screenshot(name: string): Promise<void> {
await this.page.screenshot({
path: `reports/screenshots/${name}-${Date.now()}.png`,
fullPage: true,
});
}
/**
* Scroll an element into view before interacting
*/
async scrollTo(locator: Locator): Promise<void> {
await locator.scrollIntoViewIfNeeded();
}
/**
* Wait for an element to be visible
*/
async waitForVisible(locator: Locator, timeout = 10000): Promise<void> {
await locator.waitFor({ state: 'visible', timeout });
}
/**
* Get text content safely (returns empty string if null)
*/
async getText(locator: Locator): Promise<string> {
return (await locator.textContent()) ?? '';
}
}The Login Page — pages/login.page.ts
typescript
// pages/login.page.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './base.page';
import { ROUTES } from '../constants/routes';
export class LoginPage extends BasePage {
// ─── Locators ──────────────────────────────────────────────────────────────
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly loginButton: Locator;
private readonly forgotPasswordLink: Locator;
private readonly rememberMeCheckbox: Locator;
private readonly errorMessage: Locator;
private readonly emailErrorMessage: Locator;
private readonly passwordErrorMessage: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByTestId('email-input');
this.passwordInput = page.getByTestId('password-input');
this.loginButton = page.getByRole('button', { name: 'Login' });
this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot password?' });
this.rememberMeCheckbox = page.getByRole('checkbox', { name: 'Remember me' });
this.errorMessage = page.getByTestId('login-error');
this.emailErrorMessage = page.getByTestId('email-field-error');
this.passwordErrorMessage = page.getByTestId('password-field-error');
}
// ─── Navigation ────────────────────────────────────────────────────────────
async navigate(): Promise<void> {
await super.navigate(ROUTES.LOGIN);
}
// ─── Actions ───────────────────────────────────────────────────────────────
async typeEmail(email: string): Promise<void> {
await this.emailInput.fill(email);
}
async typePassword(password: string): Promise<void> {
await this.passwordInput.fill(password);
}
async clickLogin(): Promise<void> {
await this.loginButton.click();
}
async login(email: string, password: string): Promise<void> {
await this.typeEmail(email);
await this.typePassword(password);
await this.clickLogin();
}
async clickForgotPassword(): Promise<void> {
await this.forgotPasswordLink.click();
}
async checkRememberMe(): Promise<void> {
await this.rememberMeCheckbox.check();
}
// ─── Getters ───────────────────────────────────────────────────────────────
async getErrorMessage(): Promise<string> {
await this.waitForVisible(this.errorMessage);
return this.getText(this.errorMessage);
}
async isEmailErrorVisible(): Promise<boolean> {
return this.emailErrorMessage.isVisible();
}
async isPasswordErrorVisible(): Promise<boolean> {
return this.passwordErrorMessage.isVisible();
}
async isLoginButtonEnabled(): Promise<boolean> {
return this.loginButton.isEnabled();
}
}The Dashboard Page — pages/dashboard.page.ts
typescript
// pages/dashboard.page.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './base.page';
import { NavbarComponent } from '../components/navbar.component';
export class DashboardPage extends BasePage {
// ─── Components ────────────────────────────────────────────────────────────
readonly navbar: NavbarComponent;
// ─── Locators ──────────────────────────────────────────────────────────────
private readonly welcomeBanner: Locator;
private readonly statsCard: Locator;
private readonly recentActivityList: Locator;
private readonly notificationBell: Locator;
constructor(page: Page) {
super(page);
this.navbar = new NavbarComponent(page);
this.welcomeBanner = page.getByTestId('welcome-banner');
this.statsCard = page.getByTestId('stats-card');
this.recentActivityList = page.getByTestId('recent-activity');
this.notificationBell = page.getByTestId('notification-bell');
}
// ─── Actions ───────────────────────────────────────────────────────────────
async navigateTo(section: string): Promise<void> {
await this.navbar.clickNavItem(section);
await this.waitForPageReady();
}
async openNotifications(): Promise<void> {
await this.notificationBell.click();
}
// ─── Getters ───────────────────────────────────────────────────────────────
async getWelcomeMessage(): Promise<string> {
return this.getText(this.welcomeBanner);
}
async getStatValue(statName: string): Promise<string> {
return this.getText(
this.statsCard.filter({ hasText: statName }).getByTestId('stat-value')
);
}
async getNotificationCount(): Promise<number> {
const count = await this.notificationBell.getAttribute('data-count');
return count ? parseInt(count, 10) : 0;
}
async getRecentActivityItems(): Promise<string[]> {
const items = this.recentActivityList.getByRole('listitem');
const count = await items.count();
const texts: string[] = [];
for (let i = 0; i < count; i++) {
texts.push(await this.getText(items.nth(i)));
}
return texts;
}
}Golden Rule of Page Objects: Page objects contain actions (what you can do on the page) and getters (what you can read from the page). They never contain
expect()assertions. Assertions belong in test files only.
6. The /components Folder — Reusable UI Pieces {#components-folder}
Modern SPAs have UI components — modals, navbars, data tables, toasts — that appear across multiple pages. Rather than repeating their selectors in every page object, you extract them into the components/ folder.
This is what separates a beginner’s Playwright TypeScript folder structure from an expert’s.
Navbar Component — components/navbar.component.ts
typescript
// components/navbar.component.ts
import { Page, Locator } from '@playwright/test';
export class NavbarComponent {
private readonly page: Page;
private readonly nav: Locator;
private readonly userMenuButton: Locator;
private readonly logoutButton: Locator;
private readonly userNameDisplay: Locator;
constructor(page: Page) {
this.page = page;
this.nav = page.getByRole('navigation');
this.userMenuButton = page.getByTestId('user-menu-button');
this.logoutButton = page.getByRole('menuitem', { name: 'Logout' });
this.userNameDisplay = page.getByTestId('user-display-name');
}
async clickNavItem(itemName: string): Promise<void> {
await this.nav.getByRole('link', { name: itemName }).click();
}
async openUserMenu(): Promise<void> {
await this.userMenuButton.click();
}
async logout(): Promise<void> {
await this.openUserMenu();
await this.logoutButton.click();
}
async getDisplayedUserName(): Promise<string> {
return (await this.userNameDisplay.textContent()) ?? '';
}
async isNavItemActive(itemName: string): Promise<boolean> {
const item = this.nav.getByRole('link', { name: itemName });
const classes = await item.getAttribute('class');
return classes?.includes('active') ?? false;
}
}Modal Component — components/modal.component.ts
typescript
// components/modal.component.ts
import { Page, Locator } from '@playwright/test';
export class ModalComponent {
private readonly page: Page;
private readonly modal: Locator;
private readonly modalTitle: Locator;
private readonly confirmButton: Locator;
private readonly cancelButton: Locator;
private readonly closeButton: Locator;
constructor(page: Page) {
this.page = page;
this.modal = page.getByRole('dialog');
this.modalTitle = this.modal.getByRole('heading');
this.confirmButton = this.modal.getByRole('button', { name: 'Confirm' });
this.cancelButton = this.modal.getByRole('button', { name: 'Cancel' });
this.closeButton = this.modal.getByLabel('Close modal');
}
async waitForOpen(): Promise<void> {
await this.modal.waitFor({ state: 'visible' });
}
async waitForClose(): Promise<void> {
await this.modal.waitFor({ state: 'hidden' });
}
async getTitle(): Promise<string> {
return (await this.modalTitle.textContent()) ?? '';
}
async confirm(): Promise<void> {
await this.confirmButton.click();
await this.waitForClose();
}
async cancel(): Promise<void> {
await this.cancelButton.click();
await this.waitForClose();
}
async close(): Promise<void> {
await this.closeButton.click();
await this.waitForClose();
}
async isOpen(): Promise<boolean> {
return this.modal.isVisible();
}
}DataTable Component — components/datatable.component.ts
typescript
// components/datatable.component.ts
import { Page, Locator } from '@playwright/test';
export class DataTableComponent {
private readonly table: Locator;
private readonly rows: Locator;
private readonly searchInput: Locator;
private readonly paginationNext: Locator;
private readonly paginationPrev: Locator;
constructor(page: Page, tableTestId: string) {
this.table = page.getByTestId(tableTestId);
this.rows = this.table.getByRole('row');
this.searchInput = this.table.getByRole('searchbox');
this.paginationNext = page.getByRole('button', { name: 'Next page' });
this.paginationPrev = page.getByRole('button', { name: 'Previous page' });
}
async getRowCount(): Promise<number> {
// Subtract 1 for header row
return (await this.rows.count()) - 1;
}
async getCellText(rowIndex: number, columnName: string): Promise<string> {
const headers = this.table.getByRole('columnheader');
const headerCount = await headers.count();
let columnIndex = -1;
for (let i = 0; i < headerCount; i++) {
const text = await headers.nth(i).textContent();
if (text?.trim() === columnName) {
columnIndex = i;
break;
}
}
if (columnIndex === -1) {
throw new Error(`Column "${columnName}" not found in table`);
}
return (
(await this.rows
.nth(rowIndex + 1) // +1 to skip header
.getByRole('cell')
.nth(columnIndex)
.textContent()) ?? ''
);
}
async search(term: string): Promise<void> {
await this.searchInput.fill(term);
await this.searchInput.press('Enter');
}
async sortByColumn(columnName: string): Promise<void> {
await this.table
.getByRole('columnheader', { name: columnName })
.click();
}
async goToNextPage(): Promise<void> {
await this.paginationNext.click();
}
async goToPreviousPage(): Promise<void> {
await this.paginationPrev.click();
}
async rowExists(searchText: string): Promise<boolean> {
const matchingRow = this.table.getByRole('row').filter({ hasText: searchText });
return matchingRow.isVisible();
}
}Page objects use these components like building blocks:
typescript
// pages/users-management.page.ts
import { Page } from '@playwright/test';
import { BasePage } from './base.page';
import { DataTableComponent } from '../components/datatable.component';
import { ModalComponent } from '../components/modal.component';
export class UsersManagementPage extends BasePage {
// Compose components — no duplicate selector logic
readonly usersTable: DataTableComponent;
readonly confirmModal: ModalComponent;
constructor(page: Page) {
super(page);
this.usersTable = new DataTableComponent(page, 'users-table');
this.confirmModal = new ModalComponent(page);
}
async deleteUser(email: string): Promise<void> {
await this.usersTable.rowExists(email);
await this.page
.getByRole('row', { name: email })
.getByRole('button', { name: 'Delete' })
.click();
await this.confirmModal.confirm();
}
}7. The /fixtures Folder — Test Setup Done Right {#fixtures-folder}
Fixtures are Playwright’s built-in dependency injection system. They are the cleanest way to share setup and teardown logic across tests — and they deserve a dedicated folder in your Playwright TypeScript folder structure.
What Are Fixtures?
A fixture is a reusable piece of setup that runs before a test and optionally tears down after. Playwright’s test.extend() lets you add your own fixtures on top of the built-in page, browser, and request fixtures.
fixtures/index.ts — The Central Export
All fixtures are merged here and re-exported as a single test import. Every spec file imports test and expect from this file, not from @playwright/test directly.
typescript
// fixtures/index.ts
import { test as base, expect, mergeTests } from '@playwright/test';
import { authFixtures } from './auth.fixture';
import { apiFixtures } from './api.fixture';
// Merge all fixtures into one extended test object
export const test = mergeTests(base, authFixtures, apiFixtures);
export { expect };fixtures/auth.fixture.ts — Authentication Fixtures
typescript
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';
import { TestUser } from '../types/user.types';
// Define the types for the fixtures this file provides
type AuthFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
testUser: TestUser;
authenticatedPage: Page;
};
export const authFixtures = base.extend<AuthFixtures>({
// ─── loginPage fixture ───────────────────────────────────────────────────
// Creates a LoginPage instance and provides it to the test
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
// Teardown (if needed) goes here — after use()
},
// ─── dashboardPage fixture ───────────────────────────────────────────────
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
// ─── testUser fixture ────────────────────────────────────────────────────
// Provides standard test user credentials from environment
testUser: async ({}, use) => {
const user: TestUser = {
email: process.env.TEST_USER_EMAIL ?? 'test@qatribe.in',
password: process.env.TEST_USER_PASSWORD ?? 'Test@12345',
name: 'QA Test User',
role: 'standard',
};
await use(user);
},
// ─── authenticatedPage fixture ───────────────────────────────────────────
// Performs login before the test starts — test gets an already-logged-in page
authenticatedPage: async ({ page, testUser }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login(testUser.email, testUser.password);
// Wait until login is confirmed
await page.waitForURL('**/dashboard');
await use(page); // ← test runs here
// Optional: clear cookies / local storage after test
await page.context().clearCookies();
},
});fixtures/api.fixture.ts — API Fixtures
typescript
// fixtures/api.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { appConfig } from '../config/app.config';
type ApiFixtures = {
apiContext: APIRequestContext;
authenticatedApiContext: APIRequestContext;
};
export const apiFixtures = base.extend<ApiFixtures>({
// ─── apiContext ───────────────────────────────────────────────────────────
// Unauthenticated API context — for testing public endpoints
apiContext: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: appConfig.apiBaseURL,
extraHTTPHeaders: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
await use(context);
await context.dispose(); // Always dispose after test
},
// ─── authenticatedApiContext ──────────────────────────────────────────────
// Pre-authenticated API context — for testing protected endpoints
authenticatedApiContext: async ({ playwright }, use) => {
// Get a token first
const tempContext = await playwright.request.newContext({
baseURL: appConfig.apiBaseURL,
});
const loginResponse = await tempContext.post('/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
});
const { token } = await loginResponse.json();
await tempContext.dispose();
// Create authenticated context with token
const context = await playwright.request.newContext({
baseURL: appConfig.apiBaseURL,
extraHTTPHeaders: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
await use(context);
await context.dispose();
},
});How Tests Use Fixtures
typescript
// tests/e2e/dashboard/dashboard.spec.ts
import { test, expect } from '../../../fixtures'; // ← import from fixtures, not playwright/test
test.describe('Dashboard — Post-Login Tests', () => {
// Uses authenticatedPage — login happens automatically, no boilerplate in the test
test('@smoke dashboard loads after login', async ({ authenticatedPage }) => {
await expect(authenticatedPage).toHaveURL(/dashboard/);
await expect(authenticatedPage.getByTestId('welcome-banner')).toBeVisible();
});
// Uses both authenticatedPage and dashboardPage
test('welcome message shows correct user name', async ({ authenticatedPage, dashboardPage }) => {
const message = await dashboardPage.getWelcomeMessage();
expect(message).toContain('Welcome');
});
});8. The /utils Folder — Shared Helpers {#utils-folder}
The utils/ folder contains stateless helper functions that can be used anywhere — in page objects, fixtures, or test files. These are not Playwright-specific. They are just TypeScript utility classes.
utils/api.helper.ts
typescript
// utils/api.helper.ts
import { APIRequestContext } from '@playwright/test';
export class ApiHelper {
private readonly request: APIRequestContext;
constructor(request: APIRequestContext) {
this.request = request;
}
async get<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
const response = await this.request.get(endpoint, { params });
if (!response.ok()) {
throw new Error(
`GET ${endpoint} failed — Status: ${response.status()}, Body: ${await response.text()}`
);
}
return response.json() as Promise<T>;
}
async post<T>(endpoint: string, body: object): Promise<T> {
const response = await this.request.post(endpoint, { data: body });
if (!response.ok()) {
throw new Error(
`POST ${endpoint} failed — Status: ${response.status()}, Body: ${await response.text()}`
);
}
return response.json() as Promise<T>;
}
async put<T>(endpoint: string, body: object): Promise<T> {
const response = await this.request.put(endpoint, { data: body });
if (!response.ok()) {
throw new Error(`PUT ${endpoint} failed — Status: ${response.status()}`);
}
return response.json() as Promise<T>;
}
async delete(endpoint: string): Promise<void> {
const response = await this.request.delete(endpoint);
if (!response.ok()) {
throw new Error(`DELETE ${endpoint} failed — Status: ${response.status()}`);
}
}
}utils/date.helper.ts
typescript
// utils/date.helper.ts
export class DateHelper {
/**
* Returns current date formatted as DD/MM/YYYY
*/
static todayDDMMYYYY(): string {
return DateHelper.format(new Date(), 'DD/MM/YYYY');
}
/**
* Returns current date formatted as MM-DD-YYYY
*/
static todayMMDDYYYY(): string {
return DateHelper.format(new Date(), 'MM-DD-YYYY');
}
/**
* Returns an ISO string of today
*/
static todayISO(): string {
return new Date().toISOString();
}
/**
* Add days to a given date
*/
static addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/**
* Subtract days from a given date
*/
static subtractDays(date: Date, days: number): Date {
return DateHelper.addDays(date, -days);
}
/**
* Format a date with a given pattern
* Supports: DD, MM, YYYY, HH, mm, ss
*/
static format(date: Date, pattern: string): string {
return pattern
.replace('DD', date.getDate().toString().padStart(2, '0'))
.replace('MM', (date.getMonth() + 1).toString().padStart(2, '0'))
.replace('YYYY', date.getFullYear().toString())
.replace('HH', date.getHours().toString().padStart(2, '0'))
.replace('mm', date.getMinutes().toString().padStart(2, '0'))
.replace('ss', date.getSeconds().toString().padStart(2, '0'));
}
/**
* Check if a date is in the past
*/
static isInPast(date: Date): boolean {
return date < new Date();
}
/**
* Check if a date is in the future
*/
static isInFuture(date: Date): boolean {
return date > new Date();
}
}utils/wait.helper.ts
typescript
// utils/wait.helper.ts
import { Page } from '@playwright/test';
export class WaitHelper {
/**
* Wait for a specified number of seconds
* Use sparingly — prefer Playwright's built-in waits
*/
static async seconds(seconds: number): Promise<void> {
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
}
/**
* Wait for a condition to be true
* Polls every `interval` ms until `timeout` ms is reached
*/
static async forCondition(
condition: () => Promise<boolean>,
{ timeout = 10000, interval = 500 } = {}
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (await condition()) return;
await WaitHelper.seconds(interval / 1000);
}
throw new Error(`Condition not satisfied within ${timeout}ms`);
}
/**
* Wait for network to be idle (no requests for 500ms)
*/
static async networkIdle(page: Page, timeout = 30000): Promise<void> {
await page.waitForLoadState('networkidle', { timeout });
}
/**
* Wait for a specific API response
*/
static async apiResponse(
page: Page,
urlPattern: string | RegExp,
timeout = 15000
): Promise<void> {
await page.waitForResponse(
response => {
const url = response.url();
return typeof urlPattern === 'string'
? url.includes(urlPattern)
: urlPattern.test(url);
},
{ timeout }
);
}
}utils/logger.ts
typescript
// utils/logger.ts
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
export class Logger {
private readonly context: string;
constructor(context: string) {
this.context = context;
}
private emit(level: LogLevel, message: string, data?: unknown): void {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level}] [${this.context}]`;
if (data !== undefined) {
console.log(`${prefix} ${message}`, data);
} else {
console.log(`${prefix} ${message}`);
}
}
debug(message: string, data?: unknown): void { this.emit('DEBUG', message, data); }
info (message: string, data?: unknown): void { this.emit('INFO', message, data); }
warn (message: string, data?: unknown): void { this.emit('WARN', message, data); }
error(message: string, data?: unknown): void { this.emit('ERROR', message, data); }
}
// Usage:
// const logger = new Logger('LoginPage');
// logger.info('Attempting login', { email });9. The /data Folder — Test Data Management {#data-folder}
Handling test data properly is one of the clearest signs of a mature Playwright TypeScript folder structure. There are three types of test data you will use:
| Type | When to Use | Location |
|---|---|---|
| Static JSON | Fixed lookup data, predefined users | data/users.json |
| Factory (dynamic) | Unique data per test run | data/factory/*.ts |
| API-seeded | Complex scenarios, faster than UI | Created in beforeEach via API |
Static Data — data/users.json
json
{
"standard": {
"email": "standard@qatribe.in",
"password": "Test@12345",
"name": "Standard User",
"role": "standard"
},
"admin": {
"email": "admin@qatribe.in",
"password": "Admin@12345",
"name": "Admin User",
"role": "admin"
},
"readonly": {
"email": "readonly@qatribe.in",
"password": "Read@12345",
"name": "Read Only User",
"role": "readonly"
}
}User Factory — data/factory/user.factory.ts
Use this when you need unique data per test — especially important when tests run in parallel.
typescript
// data/factory/user.factory.ts
import { faker } from '@faker-js/faker';
import { TestUser, UserRole } from '../../types/user.types';
export class TestUserFactory {
/**
* Create a standard test user with random unique data
*/
static create(overrides: Partial<TestUser> = {}): TestUser {
return {
email: `test_${Date.now()}_${faker.string.alphanumeric(5).toLowerCase()}@qatribe.in`,
password: 'Test@12345',
name: faker.person.fullName(),
role: 'standard',
phone: faker.phone.number(),
...overrides,
};
}
/**
* Create an admin user
*/
static createAdmin(overrides: Partial<TestUser> = {}): TestUser {
return TestUserFactory.create({
role: 'admin',
email: `admin_${Date.now()}@qatribe.in`,
...overrides,
});
}
/**
* Create a read-only user
*/
static createReadOnly(overrides: Partial<TestUser> = {}): TestUser {
return TestUserFactory.create({ role: 'readonly', ...overrides });
}
/**
* Create multiple users at once
*/
static createMany(count: number, overrides: Partial<TestUser> = {}): TestUser[] {
return Array.from({ length: count }, () => TestUserFactory.create(overrides));
}
}Product Factory — data/factory/product.factory.ts
typescript
// data/factory/product.factory.ts
import { faker } from '@faker-js/faker';
import { TestProduct } from '../../types/product.types';
export class TestProductFactory {
static create(overrides: Partial<TestProduct> = {}): TestProduct {
return {
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: parseFloat(faker.commerce.price({ min: 5, max: 999 })),
category: faker.commerce.department(),
sku: `SKU-${faker.string.alphanumeric(8).toUpperCase()}`,
stock: faker.number.int({ min: 1, max: 500 }),
...overrides,
};
}
static createOutOfStock(overrides: Partial<TestProduct> = {}): TestProduct {
return TestProductFactory.create({ stock: 0, ...overrides });
}
static createExpensive(overrides: Partial<TestProduct> = {}): TestProduct {
return TestProductFactory.create({ price: 9999.99, ...overrides });
}
}Using Factories in Tests
typescript
// tests/e2e/checkout/cart.spec.ts
import { test, expect } from '../../../fixtures';
import { TestProductFactory } from '../../../data/factory/product.factory';
test('add out-of-stock item shows unavailable message', async ({ authenticatedPage, page }) => {
const product = TestProductFactory.createOutOfStock();
// Seed via API before UI test
await page.request.post('/api/products', { data: product });
await page.goto(`/products/${product.sku}`);
await expect(page.getByTestId('add-to-cart-button')).toBeDisabled();
await expect(page.getByText('Out of Stock')).toBeVisible();
});10. The /config Folder — Environment Management {#config-folder}
Managing environments is where many teams get stuck. The config folder solves this cleanly.
types/config.types.ts — Define the Shape First
typescript
// types/config.types.ts
export interface Credentials {
email: string;
password: string;
}
export interface Timeouts {
navigation: number; // ms
element: number; // ms
api: number; // ms
}
export interface AppConfig {
baseURL: string;
apiBaseURL: string;
credentials: Credentials;
timeouts: Timeouts;
retries: number;
}config/environments/dev.ts
typescript
// config/environments/dev.ts
import { AppConfig } from '../../types/config.types';
export const devConfig: AppConfig = {
baseURL: 'https://dev.qatribe.in',
apiBaseURL: 'https://api-dev.qatribe.in',
credentials: {
email: process.env.DEV_USER_EMAIL ?? 'test@dev.qatribe.in',
password: process.env.DEV_USER_PASSWORD ?? 'DevTest@123',
},
timeouts: {
navigation: 30000,
element: 10000,
api: 15000,
},
retries: 1,
};config/environments/staging.ts
typescript
// config/environments/staging.ts
import { AppConfig } from '../../types/config.types';
export const stagingConfig: AppConfig = {
baseURL: 'https://staging.qatribe.in',
apiBaseURL: 'https://api-staging.qatribe.in',
credentials: {
email: process.env.STAGING_USER_EMAIL ?? '',
password: process.env.STAGING_USER_PASSWORD ?? '',
},
timeouts: {
navigation: 45000,
element: 15000,
api: 20000,
},
retries: 2,
};config/app.config.ts — The Resolver
This file reads TEST_ENV from the environment and returns the correct config:
typescript
// config/app.config.ts
import { devConfig } from './environments/dev';
import { stagingConfig } from './environments/staging';
import { AppConfig } from '../types/config.types';
type Environment = 'dev' | 'staging' | 'production';
function resolveEnvironment(): Environment {
const env = process.env.TEST_ENV as Environment;
if (!env) {
console.warn('[Config] TEST_ENV not set — defaulting to "dev"');
return 'dev';
}
const valid: Environment[] = ['dev', 'staging', 'production'];
if (!valid.includes(env)) {
throw new Error(`[Config] Invalid TEST_ENV: "${env}". Must be one of: ${valid.join(', ')}`);
}
return env;
}
const configs: Record<Environment, AppConfig> = {
dev: devConfig,
staging: stagingConfig,
production: stagingConfig, // Replace with prodConfig when ready
};
export const appConfig: AppConfig = configs[resolveEnvironment()];11. The /types Folder — TypeScript Interfaces {#types-folder}
Strong typing is what separates a TypeScript project from a JavaScript-with-types project. Every shared data shape gets its own file in types/.
types/user.types.ts
typescript
// types/user.types.ts
export type UserRole = 'admin' | 'standard' | 'readonly' | 'manager';
export interface TestUser {
email: string;
password: string;
name: string;
role: UserRole;
phone?: string;
address?: string;
createdAt?: string;
}
export interface ApiUser {
id: number;
email: string;
name: string;
role: UserRole;
createdAt: string;
updatedAt: string;
}
export interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
statusCode: number;
}types/product.types.ts
typescript
// types/product.types.ts
export interface TestProduct {
name: string;
description: string;
price: number;
category: string;
sku: string;
stock: number;
}
export interface ApiProduct extends TestProduct {
id: number;
imageUrl: string;
createdAt: string;
}12. The /constants Folder — No More Magic Strings {#constants-folder}
Magic strings scattered across hundreds of test files are a maintenance nightmare. The constants/ folder centralizes them all.
constants/routes.ts
typescript
// constants/routes.ts
export const ROUTES = {
HOME: '/',
LOGIN: '/login',
LOGOUT: '/logout',
FORGOT_PASSWORD: '/forgot-password',
DASHBOARD: '/dashboard',
PROFILE: '/profile',
SETTINGS: '/settings',
CHECKOUT: {
CART: '/checkout/cart',
PAYMENT: '/checkout/payment',
CONFIRMATION: '/checkout/confirmation',
},
ADMIN: {
USERS: '/admin/users',
PRODUCTS: '/admin/products',
REPORTS: '/admin/reports',
},
} as const;constants/messages.ts
typescript
// constants/messages.ts
export const MESSAGES = {
LOGIN: {
INVALID_CREDENTIALS: 'Invalid email or password',
INVALID_EMAIL: 'Please enter a valid email address',
ACCOUNT_LOCKED: 'Account temporarily locked. Try again in 15 minutes.',
SESSION_EXPIRED: 'Your session has expired. Please log in again.',
},
REGISTRATION: {
SUCCESS: 'Registration successful! Please verify your email.',
EMAIL_EXISTS: 'An account with this email already exists',
WEAK_PASSWORD: 'Password must be at least 8 characters with one uppercase, one number',
},
CHECKOUT: {
OUT_OF_STOCK: 'This item is out of stock',
PAYMENT_FAILED: 'Payment failed. Please check your card details.',
ORDER_CONFIRMED: 'Your order has been confirmed!',
},
GENERAL: {
SAVE_SUCCESS: 'Changes saved successfully',
DELETE_SUCCESS: 'Item deleted successfully',
NETWORK_ERROR: 'Network error. Please check your connection and try again.',
LOADING: 'Loading...',
},
} as const;constants/timeouts.ts
typescript
// constants/timeouts.ts
export const TIMEOUTS = {
SHORT: 5_000, // 5 seconds — quick interactions
MEDIUM: 10_000, // 10 seconds — standard page loads
LONG: 30_000, // 30 seconds — heavy operations
API: 15_000, // 15 seconds — API responses
ANIMATION: 1_000, // 1 second — CSS animations
} as const;13. The /hooks Folder — Global Setup and Teardown {#hooks-folder}
Global hooks run once before your entire test suite starts and once after it finishes. They are perfect for:
- Logging in once and saving browser auth state (so tests skip login)
- Seeding a test database via API
- Setting up test accounts with specific roles
- Cleaning up after all tests are done
hooks/global-setup.ts
typescript
// hooks/global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
import { appConfig } from '../config/app.config';
import * as fs from 'fs';
import * as path from 'path';
async function globalSetup(config: FullConfig): Promise<void> {
console.log('\n🚀 Global Setup Starting...');
console.log(` Environment : ${process.env.TEST_ENV ?? 'dev'}`);
console.log(` Base URL : ${appConfig.baseURL}\n`);
// ── Create .auth directory ─────────────────────────────────────────────────
const authDir = path.join(process.cwd(), '.auth');
if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}
// ── Save Standard User Auth State ─────────────────────────────────────────
const browser = await chromium.launch();
const page = await browser.newPage();
try {
await page.goto(`${appConfig.baseURL}/login`);
await page.getByTestId('email-input').fill(appConfig.credentials.email);
await page.getByTestId('password-input').fill(appConfig.credentials.password);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
// Save auth state — tests will use this instead of logging in every time
await page.context().storageState({ path: '.auth/user.json' });
console.log(' ✅ Standard user auth state saved');
} catch (error) {
console.error(' ❌ Global setup failed:', error);
throw error;
} finally {
await browser.close();
}
console.log('\n✅ Global Setup Complete\n');
}
export default globalSetup;hooks/global-teardown.ts
typescript
// hooks/global-teardown.ts
import * as fs from 'fs';
async function globalTeardown(): Promise<void> {
console.log('\n🧹 Global Teardown Starting...');
// ── Clean up auth files ────────────────────────────────────────────────────
const authFile = '.auth/user.json';
if (fs.existsSync(authFile)) {
fs.unlinkSync(authFile);
console.log(' ✅ Auth state file deleted');
}
// ── Log test run summary ───────────────────────────────────────────────────
const timestamp = new Date().toISOString();
console.log(` ✅ Test run completed at: ${timestamp}`);
console.log('\n✅ Global Teardown Complete\n');
}
export default globalTeardown;14. playwright.config.ts — Deep Dive {#playwright-config}
This is the command center of your Playwright TypeScript folder structure. A poorly written config causes flaky tests, missing reports, and CI failures.
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { appConfig } from './config/app.config';
export default defineConfig({
// ── Test Discovery ─────────────────────────────────────────────────────────
testDir: './tests',
testMatch: '**/*.spec.ts',
// ── Execution ──────────────────────────────────────────────────────────────
fullyParallel: true,
forbidOnly: !!process.env.CI, // Catch accidental .only in CI
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
// ── Reporting ──────────────────────────────────────────────────────────────
reporter: [
['html', { outputFolder: 'reports/html', open: 'never' }],
['junit', { outputFile: 'reports/junit/results.xml' }],
['list'],
],
// ── Shared Settings ────────────────────────────────────────────────────────
use: {
baseURL: appConfig.baseURL,
// Tracing and debugging
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
// Timeouts
actionTimeout: appConfig.timeouts.element,
navigationTimeout: appConfig.timeouts.navigation,
// Viewport
viewport: { width: 1280, height: 720 },
// Ignore HTTPS errors in non-production
ignoreHTTPSErrors: process.env.TEST_ENV !== 'production',
},
// ── Browser Projects ───────────────────────────────────────────────────────
projects: [
// Setup — runs global-setup once before all tests
{
name: 'setup',
testMatch: /global-setup\.ts/,
},
// Desktop Chrome — Primary
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
// Desktop Firefox
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
// Mobile Chrome (Pixel 5)
{
name: 'mobile-chrome',
use: {
...devices['Pixel 5'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
// API tests — no browser needed
{
name: 'api',
testMatch: '**/api/**/*.spec.ts',
},
// Smoke tests — chromium only, no auth setup dependency
{
name: 'smoke',
testMatch: '**/smoke/**/*.spec.ts',
use: { ...devices['Desktop Chrome'] },
},
],
// ── Global Hooks ───────────────────────────────────────────────────────────
globalSetup: './hooks/global-setup.ts',
globalTeardown: './hooks/global-teardown.ts',
// ── Output ─────────────────────────────────────────────────────────────────
outputDir: 'reports/test-results',
});15. tsconfig.json — TypeScript Configuration {#tsconfig}
json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./",
"baseUrl": ".",
"paths": {
"@pages/*": ["pages/*"],
"@components/*":["components/*"],
"@fixtures/*": ["fixtures/*"],
"@utils/*": ["utils/*"],
"@data/*": ["data/*"],
"@config/*": ["config/*"],
"@types/*": ["types/*"],
"@constants/*": ["constants/*"],
"@hooks/*": ["hooks/*"]
}
},
"include": [
"tests/**/*.ts",
"pages/**/*.ts",
"components/**/*.ts",
"fixtures/**/*.ts",
"utils/**/*.ts",
"data/**/*.ts",
"config/**/*.ts",
"types/**/*.ts",
"constants/**/*.ts",
"hooks/**/*.ts",
"reporters/**/*.ts"
],
"exclude": ["node_modules", "dist", "reports"]
}The paths section is the most important part. Instead of writing:
typescript
import { LoginPage } from '../../../pages/login.page';You write:
typescript
import { LoginPage } from '@pages/login.page';Clean, readable, and refactor-safe.
For more details refer Playwright TypeScript official docs
16. package.json Scripts — Run Everything {#package-scripts}
json
{
"scripts": {
"test": "playwright test",
"test:dev": "TEST_ENV=dev playwright test",
"test:staging": "TEST_ENV=staging playwright test",
"test:smoke": "playwright test --grep @smoke",
"test:regression": "playwright test --grep @regression",
"test:auth": "playwright test tests/e2e/auth/",
"test:api": "playwright test tests/api/",
"test:chrome": "playwright test --project=chromium",
"test:firefox": "playwright test --project=firefox",
"test:mobile": "playwright test --project=mobile-chrome",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:ui": "playwright test --ui",
"report": "playwright show-report reports/html",
"codegen": "playwright codegen",
"type-check": "tsc --noEmit",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix"
}
}17. Environment Strategy — Dev, Staging, Production {#environment-strategy}
.env Files
bash
# .env (development — default) TEST_ENV=dev TEST_USER_EMAIL=test@dev.qatribe.in TEST_USER_PASSWORD=DevTest@123 # .env.staging TEST_ENV=staging TEST_USER_EMAIL=test@staging.qatribe.in TEST_USER_PASSWORD=StagingTest@456 # .env.example (committed to git — shows required variables, no real values) TEST_ENV= TEST_USER_EMAIL= TEST_USER_PASSWORD=
Running Against Each Environment
bash
# Development npm run test:dev # Staging (using dotenv-cli) npx dotenv -e .env.staging -- playwright test # Specific feature on staging TEST_ENV=staging playwright test tests/e2e/auth/ --project=chromium # Smoke tests for post-deploy verification npm run test:smoke
18. CI/CD Integration {#cicd-integration}
Your Playwright TypeScript folder structure is designed to be CI-ready. Here is the complete GitHub Actions workflow:
yaml
# .github/workflows/playwright.yml
name: Playwright E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
name: "Tests — ${{ matrix.project }}"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project: [chromium, firefox]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.project }}
- name: Type check
run: npm run type-check
- name: Run Playwright Tests
run: npx playwright test --project=${{ matrix.project }}
env:
TEST_ENV: staging
TEST_USER_EMAIL: ${{ secrets.STAGING_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.STAGING_PASSWORD }}
- name: Upload Test Reports
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.project }}
path: |
reports/html/
reports/junit/
retention-days: 14
smoke:
name: "Smoke Tests — Post Deploy"
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run Smoke Tests
run: npm run test:smoke
env:
TEST_ENV: staging
TEST_USER_EMAIL: ${{ secrets.STAGING_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.STAGING_PASSWORD }}19. Scaling for Enterprise Projects {#scaling}
When your team grows to 5+ QA engineers and 500+ tests, you need to scale the structure. Here is how.
Three Stages of Growth
Stage 1 — Solo / Small Team (< 100 tests) Use the full structure from this guide, but keep it flat. tests/e2e/, pages/, fixtures/. No sub-folders needed inside pages or utils.
Stage 2 — Mid-Size Team (100–500 tests) Add the components/ folder. Split fixtures into multiple files. Add tagging with @smoke, @regression. Set up CI sharding across 2–4 runners.
Stage 3 — Enterprise (500+ tests, multiple teams) Introduce a feature-module pattern. Each major feature gets its own self-contained directory:
tests/
├── features/
│ ├── authentication/
│ │ ├── tests/
│ │ ├── pages/
│ │ └── data/
│ ├── checkout/
│ │ ├── tests/
│ │ ├── pages/
│ │ └── data/
│ └── inventory/
│ ├── tests/
│ ├── pages/
│ └── data/
└── shared/
├── fixtures/
├── utils/
├── types/
└── constants/Tagging Strategy
typescript
// Every test should have at least one tag
test('@smoke @critical login redirects to dashboard', async () => { /* ... */ });
test('@regression @auth session expires after timeout', async () => { /* ... */ });
test('@api @contract GET /users returns correct schema', async () => { /* ... */ });CI Sharding for Large Suites
yaml
strategy:
matrix:
shard: [1, 2, 3, 4]
- name: Run shard ${{ matrix.shard }} of 4
run: npx playwright test --shard=${{ matrix.shard }}/4With 400 tests that take 40 minutes total, sharding across 4 runners brings it down to 10 minutes.
20. Advanced Patterns for Senior Engineers {#advanced-patterns}
Once the foundation is solid, these advanced patterns take your Playwright TypeScript folder structure to expert level.
The Builder Pattern for Complex Test Data
The factory pattern we covered works great for simple data. For complex entities with many optional fields and relationships, the Builder pattern is more expressive and readable.
typescript
// data/builders/user.builder.ts
import { faker } from '@faker-js/faker';
import { TestUser, UserRole } from '../../types/user.types';
export class UserBuilder {
private user: TestUser;
constructor() {
// Sensible defaults for every field
this.user = {
email: `user_${Date.now()}@qatribe.in`,
password: 'Test@12345',
name: faker.person.fullName(),
role: 'standard',
};
}
withEmail(email: string): this {
this.user.email = email;
return this;
}
withName(name: string): this {
this.user.name = name;
return this;
}
withRole(role: UserRole): this {
this.user.role = role;
return this;
}
asAdmin(): this {
this.user.role = 'admin';
this.user.email = `admin_${Date.now()}@qatribe.in`;
return this;
}
asReadOnly(): this {
this.user.role = 'readonly';
return this;
}
withPhone(phone: string): this {
this.user.phone = phone;
return this;
}
build(): TestUser {
return { ...this.user };
}
}Usage in tests — notice how readable and expressive this is:
typescript
// In a test file
const adminUser = new UserBuilder().asAdmin().withName('Test Admin').build();
const readOnlyUser = new UserBuilder().asReadOnly().withEmail('readonly@custom.com').build();
const regularUser = new UserBuilder().withRole('standard').build();Compare this to the factory approach with overrides — the builder pattern communicates intent, not just data.
Retry Logic for Flaky Third-Party Integrations
Sometimes tests that interact with payment gateways, email services, or third-party widgets need retry logic at the test level — not just at the Playwright config level.
typescript
// utils/retry.helper.ts
export class RetryHelper {
/**
* Retry an async operation up to maxAttempts times
* Waits delayMs between attempts
*/
static async withRetry<T>(
operation: () => Promise<T>,
maxAttempts: number = 3,
delayMs: number = 1000,
context: string = 'Operation'
): Promise<T> {
let lastError: Error | unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
console.warn(`[Retry] ${context} — Attempt ${attempt}/${maxAttempts} failed`);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delayMs * attempt));
}
}
}
throw new Error(`[Retry] ${context} failed after ${maxAttempts} attempts: ${lastError}`);
}
/**
* Retry with exponential backoff
*/
static async withExponentialBackoff<T>(
operation: () => Promise<T>,
maxAttempts: number = 3
): Promise<T> {
return RetryHelper.withRetry(
operation,
maxAttempts,
1000, // base delay — doubles each attempt
'ExponentialBackoff'
);
}
}Usage in tests:
typescript
// In a test that checks an email inbox (can be slow)
test('password reset email arrives within 30 seconds', async ({ apiContext }) => {
await loginPage.clickForgotPassword();
await forgotPasswordPage.submitEmail('user@test.com');
// Retry checking the inbox every 5 seconds, up to 6 times (30 seconds total)
const emailReceived = await RetryHelper.withRetry(
async () => {
const inbox = await apiHelper.get('/email/inbox?to=user@test.com');
if (!inbox.emails.length) throw new Error('Email not yet received');
return true;
},
6, // 6 attempts
5000, // 5 seconds between attempts
'Password Reset Email'
);
expect(emailReceived).toBe(true);
});Custom Playwright Reporter
A custom reporter lets you send test results directly to Slack, Teams, or your internal dashboard the moment the test run completes.
typescript
// reporters/slack.reporter.ts
import {
Reporter,
TestCase,
TestResult,
FullResult,
} from '@playwright/test/reporter';
interface TestSummary {
passed: number;
failed: number;
skipped: number;
failedTestNames: string[];
duration: number;
}
export default class SlackReporter implements Reporter {
private summary: TestSummary = {
passed: 0,
failed: 0,
skipped: 0,
failedTestNames: [],
duration: 0,
};
onTestEnd(test: TestCase, result: TestResult): void {
switch (result.status) {
case 'passed': this.summary.passed++; break;
case 'failed':
this.summary.failed++;
this.summary.failedTestNames.push(test.title);
break;
case 'skipped': this.summary.skipped++; break;
}
}
async onEnd(result: FullResult): Promise<void> {
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!webhookUrl) return; // Skip if no webhook configured
const isPassed = result.status === 'passed';
const emoji = isPassed ? '✅' : '❌';
const color = isPassed ? 'good' : 'danger';
const durationS = Math.round((result.duration || 0) / 1000);
const fields = [
{ title: 'Environment', value: process.env.TEST_ENV ?? 'dev', short: true },
{ title: 'Status', value: result.status.toUpperCase(), short: true },
{ title: 'Duration', value: `${durationS}s`, short: true },
{ title: 'Passed', value: String(this.summary.passed), short: true },
{ title: 'Failed', value: String(this.summary.failed), short: true },
{ title: 'Skipped', value: String(this.summary.skipped), short: true },
];
if (this.summary.failedTestNames.length > 0) {
fields.push({
title: 'Failed Tests',
value: this.summary.failedTestNames.slice(0, 5).join('\n'),
short: false,
});
}
const payload = {
text: `${emoji} *Playwright Tests — ${process.env.TEST_ENV ?? 'dev'}*`,
attachments: [{ color, fields }],
};
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (error) {
console.error('[SlackReporter] Failed to send notification:', error);
}
}
}Register it in playwright.config.ts:
typescript
reporter: [
['html', { outputFolder: 'reports/html' }],
['junit', { outputFile: 'reports/junit/results.xml' }],
['./reporters/slack.reporter.ts'],
['list'],
],Performance Assertions in Tests
Your tests can verify page performance as part of the same test run — no separate tool needed for basic baseline checks.
typescript
// utils/performance.helper.ts
import { Page } from '@playwright/test';
export interface CoreWebVitals {
fcp: number; // First Contentful Paint (ms)
lcp: number; // Largest Contentful Paint (ms)
ttfb: number; // Time To First Byte (ms)
domContentLoaded: number; // DOMContentLoaded (ms)
}
export class PerformanceHelper {
static async captureMetrics(page: Page): Promise<CoreWebVitals> {
return page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const paint = performance.getEntriesByType('paint');
const fcp = paint.find(p => p.name === 'first-contentful-paint')?.startTime ?? 0;
return {
fcp: Math.round(fcp),
lcp: 0, // Needs PerformanceObserver for live measurement
ttfb: Math.round(nav.responseStart - nav.requestStart),
domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
};
});
}
static assertBaseline(
metrics: CoreWebVitals,
baselines: Partial<CoreWebVitals>,
pageName: string
): void {
for (const [key, limit] of Object.entries(baselines)) {
const actual = metrics[key as keyof CoreWebVitals];
if (actual > limit) {
throw new Error(
`[Performance] ${pageName} — ${key} is ${actual}ms, exceeds baseline of ${limit}ms`
);
}
}
}
}Usage in a smoke test:
typescript
// tests/smoke/performance.spec.ts
import { test } from '../../fixtures';
import { PerformanceHelper } from '../../utils/performance.helper';
const BASELINES = {
fcp: 2000, // 2 seconds
ttfb: 500, // 500ms
domContentLoaded: 3000, // 3 seconds
};
test('@smoke homepage meets performance baselines', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const metrics = await PerformanceHelper.captureMetrics(page);
console.log('[Performance] Metrics:', metrics);
PerformanceHelper.assertBaseline(metrics, BASELINES, 'Homepage');
});This gives you lightweight performance regression detection baked directly into your existing test suite.
Accessibility Testing with axe
Add accessibility testing to your existing Playwright tests with zero additional framework overhead:
bash
npm install --save-dev axe-playwright
typescript
// tests/e2e/auth/login.accessibility.spec.ts
import { test, expect } from '../../../fixtures';
import { checkA11y, injectAxe } from 'axe-playwright';
test.describe('Login — Accessibility Audit', () => {
test('login page has no critical accessibility violations', async ({ page, loginPage }) => {
await loginPage.navigate();
await injectAxe(page);
await checkA11y(page, undefined, {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa'],
},
detailedReport: true,
detailedReportOptions: { html: true },
});
});
test('error state has no accessibility violations', async ({ page, loginPage }) => {
await loginPage.navigate();
await loginPage.login('bad@email.com', 'wrongpassword');
await page.getByTestId('login-error').waitFor({ state: 'visible' });
await injectAxe(page);
await checkA11y(page);
});
});One command runs both functional and accessibility tests simultaneously. No extra tooling, no separate pipeline step.
21. Common Mistakes {#common-mistakes}
Here are the most common issues I see in real-world projects — and how to fix each one.
❌ Mistake 1 — Everything in One Folder
tests/ ├── login.spec.ts ├── loginPage.ts ← page object in wrong place ├── helpers.ts ← generic name, unclear purpose ├── checkout.spec.ts └── testData.json ← data mixed in
Fix: Follow the folder structure in this guide. Every concern has its own folder.
❌ Mistake 2 — Hardcoded Selectors in Tests
typescript
// ❌ Wrong — raw selectors in test files
test('login test', async ({ page }) => {
await page.fill('#email', 'user@test.com'); // Hardcoded selector
await page.fill('#password', 'password'); // Hardcoded selector
await page.click('button[type="submit"]'); // Hardcoded selector
});typescript
// ✅ Right — selectors owned by page objects
test('login redirects to dashboard', async ({ loginPage, page }) => {
await loginPage.login('user@test.com', 'password');
await expect(page).toHaveURL(ROUTES.DASHBOARD);
});❌ Mistake 3 — Assertions Inside Page Objects
typescript
// ❌ Wrong — assertions inside page object
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
await expect(this.page).toHaveURL('/dashboard'); // ← WRONG
}typescript
// ✅ Right — page object returns, test asserts
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
// No assertion here — let the test decide what to verify
}
// In the test:
await loginPage.login(email, password);
await expect(page).toHaveURL(ROUTES.DASHBOARD); // ← assertion in test❌ Mistake 4 — Using waitForTimeout
typescript
// ❌ Wrong — arbitrary sleep await page.waitForTimeout(3000);
typescript
// ✅ Right — wait for what you actually need
await page.waitForURL('/dashboard');
await page.getByTestId('welcome-banner').waitFor({ state: 'visible' });
await page.waitForLoadState('networkidle');❌ Mistake 5 — No Type Safety
typescript
// ❌ Wrong — any type everywhere
const user: any = { email: 'test@test.com' };
const response: any = await apiContext.get('/users');typescript
// ✅ Right — proper interfaces
const user: TestUser = TestUserFactory.create();
const response: ApiResponse<ApiUser[]> = await apiHelper.get<ApiResponse<ApiUser[]>>('/users');❌ Mistake 6 — No .gitignore for Test Artifacts
gitignore
# .gitignore node_modules/ dist/ # Test artifacts — never commit these reports/ .auth/ test-results/ playwright-report/ # Environment files with secrets .env .env.staging .env.production # Keep this in git — template only !.env.example
22. Migration Guide — Fixing an Existing Project {#migration}
If you already have a messy project, here is a practical six-week plan to migrate it to a proper Playwright TypeScript folder structure without disrupting ongoing work.
Week 1 — Guardrails First
Do not move anything yet. First, stop new bad code from being added:
bash
# Add TypeScript strict mode # Add ESLint with playwright plugin # Add .gitignore with proper entries # Create .env.example
Week 2 — Create the New Structure
Create all new folders as empty shells alongside the existing code. Do not move files yet.
bash
mkdir -p pages components fixtures utils data/factory config/environments types constants hooks reporters .auth touch pages/base.page.ts touch fixtures/index.ts touch config/app.config.ts
Week 3–4 — Migrate Page Objects
Go feature by feature. Start with Login — it is touched by nearly every test.
- Create
pages/login.page.tswith proper class structure - Update
tests/e2e/auth/login.spec.tsto use the new page object - Delete the old inline selectors from test files
- Repeat for each page
Week 5 — Extract Utilities and Fixtures
- Find all duplicated
page.goto('/login') + fill + clickpatterns → move to auth fixture - Find all repeated date/string manipulation → move to
utils/ - Find all hardcoded test users → move to
data/factory/ - Update all test files to import from new locations
Week 6 — Config, Constants, CI
- Move all hardcoded URLs to
constants/routes.ts - Move all hardcoded strings to
constants/messages.ts - Create
config/environments/files - Update
playwright.config.tsto useappConfig - Update CI pipeline
- Write
ARCHITECTURE.md
23. Final Complete Structure — Copy and Use {#final-structure}
Here is the final, complete Playwright TypeScript folder structure ready to use as your project template:
my-playwright-project/ │ ├── .auth/ │ └── user.json ← gitignored; auto-generated by global-setup │ ├── .github/ │ └── workflows/ │ └── playwright.yml ← CI pipeline │ ├── components/ │ ├── datatable.component.ts │ ├── modal.component.ts │ ├── navbar.component.ts │ └── toast.component.ts │ ├── config/ │ ├── app.config.ts ← reads TEST_ENV, returns correct config │ └── environments/ │ ├── dev.ts │ ├── staging.ts │ └── production.ts │ ├── constants/ │ ├── messages.ts │ ├── routes.ts │ └── timeouts.ts │ ├── data/ │ ├── users.json │ ├── products.json │ └── factory/ │ ├── user.factory.ts │ └── product.factory.ts │ ├── fixtures/ │ ├── index.ts ← central export; all spec files import from here │ ├── auth.fixture.ts │ └── api.fixture.ts │ ├── hooks/ │ ├── global-setup.ts │ └── global-teardown.ts │ ├── pages/ │ ├── base.page.ts │ ├── checkout.page.ts │ ├── dashboard.page.ts │ ├── login.page.ts │ └── profile.page.ts │ ├── reporters/ │ └── slack.reporter.ts │ ├── reports/ ← gitignored; auto-generated │ ├── tests/ │ ├── e2e/ │ │ ├── auth/ │ │ │ ├── forgot-password.spec.ts │ │ │ ├── login.spec.ts │ │ │ └── logout.spec.ts │ │ ├── checkout/ │ │ │ ├── cart.spec.ts │ │ │ └── payment.spec.ts │ │ ├── dashboard/ │ │ │ └── dashboard.spec.ts │ │ └── profile/ │ │ └── profile-update.spec.ts │ ├── api/ │ │ ├── product.api.spec.ts │ │ └── user.api.spec.ts │ └── smoke/ │ └── smoke.spec.ts │ ├── types/ │ ├── config.types.ts │ ├── product.types.ts │ └── user.types.ts │ ├── utils/ │ ├── api.helper.ts │ ├── date.helper.ts │ ├── logger.ts │ ├── string.helper.ts │ └── wait.helper.ts │ ├── .env ← gitignored; real secrets ├── .env.example ← committed; shows required variables ├── .gitignore ├── package.json ├── playwright.config.ts └── tsconfig.json
24. FAQs {#faqs}
Q1: Every spec file should import from fixtures/index.ts — why?
This is the central rule of this Playwright TypeScript folder structure.
This is the central rule of this Playwright TypeScript folder structure. When you import test from @playwright/test directly in a spec file, you lose all your custom fixtures — loginPage, testUser, authenticatedPage, apiContext. Everything goes through fixtures/index.ts so every spec file has full access to the complete fixture context.
typescript
// ❌ Wrong — loses all custom fixtures
import { test, expect } from '@playwright/test';
// ✅ Correct — full fixture context available
import { test, expect } from '../../../fixtures';Q2: When should I create a component vs add a method to a page object?
Create a component when:
- The UI element appears on 3 or more different pages (navbar, modal, toast)
- The element has its own internal state and interactions independent of the page
- Two page objects would otherwise duplicate the same selectors
Add a method to a page object when:
- The interaction is specific to that one page
- The interaction only makes sense in the context of that page
A modal on the checkout page that only appears during payment is a page method. A confirmation modal that appears across the entire app is a component.
Q3: How do I run only tests tagged with @smoke?
bash
# Run smoke tests only npx playwright test --grep @smoke # Run smoke tests on specific browser npx playwright test --grep @smoke --project=chromium # Run everything EXCEPT work-in-progress tests npx playwright test --grep-invert @wip # Run smoke AND critical together npx playwright test --grep "@smoke|@critical"
Q4: Should I use test.only() during development?
During local development, test.only() is fine for focusing on the test you are working on. But it must NEVER be committed. The ESLint rule playwright/no-focused-test: error will fail your CI build if .only is committed, acting as a safety net.
typescript
// Fine locally, must remove before committing
test.only('the test I am debugging right now', async ({ page }) => { /* ... */ });Q5: What is the recommended number of retries?
- Local development: 0 retries — you want to see failures immediately
- CI (stable environments): 1–2 retries — handles occasional infrastructure hiccups
- CI (flaky environment or third-party integrations): up to 3 retries, but investigate the root cause
Retries mask problems. A test that consistently needs 2 retries to pass is a flaky test that needs to be fixed, not a stable test. Track your retry rate. If more than 2% of runs need retries, you have a framework quality problem.
Q6: How do I handle file downloads in Playwright?
Add a download helper method to the relevant page object:
typescript
// In a page object
async downloadReport(reportName: string): Promise<string> {
const [download] = await Promise.all([
this.page.waitForEvent('download'),
this.page.getByRole('button', { name: `Download ${reportName}` }).click(),
]);
const filePath = `reports/downloads/${download.suggestedFilename()}`;
await download.saveAs(filePath);
return filePath;
}Q7: How do I test across multiple users in the same test?
Create separate browser contexts:
typescript
test('admin can see all users, standard user cannot', async ({ browser }) => {
// Admin context
const adminCtx = await browser.newContext({ storageState: '.auth/admin.json' });
const adminPage = await adminCtx.newPage();
await adminPage.goto('/admin/users');
await expect(adminPage.getByTestId('users-table')).toBeVisible();
// Standard user context
const userCtx = await browser.newContext({ storageState: '.auth/user.json' });
const userPage = await userCtx.newPage();
await userPage.goto('/admin/users');
await expect(userPage).toHaveURL('/403'); // Forbidden redirect
await adminCtx.close();
await userCtx.close();
});Q8: How do I share data between global-setup.ts and tests?
Use environment variables for simple values, or write to a JSON file for complex state:
typescript
// In global-setup.ts
process.env.CREATED_TEST_USER_ID = '12345';
// Or write to a file
fs.writeFileSync('.auth/setup-data.json', JSON.stringify({ userId: '12345' }));typescript
// In a test
const userId = process.env.CREATED_TEST_USER_ID;
// Or read from file
const setupData = JSON.parse(fs.readFileSync('.auth/setup-data.json', 'utf-8'));Q9: What is the difference between page.waitForLoadState() and page.waitForURL()?
waitForURL()— waits until the browser’s address bar shows the expected URL. Use after clicking a link or submitting a form that triggers navigation.waitForLoadState('domcontentloaded')— waits until the DOM is parsed. Fast, but JS may not have run yet.waitForLoadState('networkidle')— waits until there are no network requests for 500ms. Use when a page makes API calls on load that populate the UI.
For most post-login redirects, waitForURL is the right choice:
typescript
await loginPage.login(email, password);
await page.waitForURL('**/dashboard'); // Wait for URL
await page.getByTestId('welcome-banner').waitFor(); // Then wait for elementQ10: How do I mock API responses in Playwright?
Use page.route() to intercept and override API responses:
typescript
test('shows error state when API returns 500', async ({ page }) => {
// Intercept the users endpoint and return a 500 error
await page.route('**/api/users', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ message: 'Internal Server Error' }),
});
});
await page.goto('/users');
await expect(page.getByTestId('error-state')).toBeVisible();
await expect(page.getByText('Something went wrong')).toBeVisible();
});Create a utils/mock.helper.ts for commonly mocked endpoints:
typescript
// utils/mock.helper.ts
import { Page } from '@playwright/test';
export class MockHelper {
static async mockEndpoint(
page: Page,
pattern: string | RegExp,
response: { status: number; body: object }
): Promise<void> {
await page.route(pattern, route => {
route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(response.body),
});
});
}
static async mockNetworkError(page: Page, pattern: string | RegExp): Promise<void> {
await page.route(pattern, route => route.abort('failed'));
}
}Q11: How do I test error boundaries and loading states?
Use page.route() combined with deliberate delays:
typescript
test('shows loading spinner during API call', async ({ page }) => {
// Delay the API response by 2 seconds
await page.route('**/api/products', async route => {
await new Promise(resolve => setTimeout(resolve, 2000));
await route.continue();
});
await page.goto('/products');
// Loading state should be visible immediately after navigation
await expect(page.getByTestId('loading-spinner')).toBeVisible();
// Data should appear after the delay
await expect(page.getByTestId('products-grid')).toBeVisible({ timeout: 5000 });
});Q12: Should I commit package-lock.json?
Yes, always. package-lock.json (or yarn.lock) ensures that every CI run and every team member installs the exact same dependency versions. Without it, a minor version update in a transitive dependency can silently break your tests. Always run npm ci in CI (not npm install) — it respects the lockfile exactly.
Conclusion
Your Playwright TypeScript folder structure is the backbone of your entire automation strategy. Every decision — where tests live, how pages are abstracted, how data flows, how environments are managed — either makes the framework stronger or more fragile over time.
The Playwright TypeScript folder structure in this guide follows three unbreakable principles:
Principle 1 — Separation of Concerns
Tests do one thing: they describe expected behavior and make assertions. Page objects do one thing: they encapsulate selectors and interactions. Fixtures do one thing: they set up and tear down context. Nothing bleeds into anything else. When you violate this — when you put assertions in page objects, or raw selectors in test files — you create coupling that eventually becomes impossible to untangle.
Principle 2 — Convention Over Configuration
When a new QA engineer joins your team, they should not need to ask where to put a new test file, a new page object, or a new utility function. The Playwright TypeScript folder structure answers these questions automatically. A well-structured project is a self-documenting project.
Write an ARCHITECTURE.md file. Keep it updated when conventions change. Review pull requests with structure in mind, not just code logic. Culture enforces structure just as much as the file system does.
Principle 3 — Built to Scale
The same structure that works for 30 tests works for 3,000 tests. You add files, not architectural rethinks. You add features, not migrations. When you find yourself saying “we need to restructure the whole project,” that is a sign the structure was not thought through from the start.
Start with the simplified version for small projects. Add components/ when UI patterns repeat. Add reporters/ when CI reporting becomes important. Add feature-module organization when the team grows. Scale deliberately, not reactively.
What To Do Next
Build with intention. Structure with purpose. Test with confidence.
Found this useful? Share it with your QA team. Have a question? Drop a comment below.
🔥 Continue Your Learning Journey
Want to go beyond Playwright with Typescript setup and crack interviews faster? Check these hand-picked guides:
👉 🚀 Master TestNG Framework (Enterprise Level)
Build scalable automation frameworks with CI/CD, parallel execution, and real-world architecture
➡️ Read: TestNG Automation Framework – Complete Architect Guide
👉 🧠 Learn Cucumber (BDD from Scratch to Advanced)
Understand Gherkin, step definitions, and real-world BDD framework design
➡️ Read: Cucumber Automation Framework – Beginner to Advanced Guide
👉 🔐 API Authentication Made Simple
Master JWT, OAuth, Bearer Tokens with real API testing examples
➡️ Read: Ultimate API Authentication Guide
👉 ⚡ Crack Playwright Interviews (2026 Ready)
Top real interview questions with answers and scenarios
➡️ Read: Playwright Interview Questions Guide