Skip to content
chatgpt image feb 22, 2026, 07 27 39 pm QATRIBE

QA, Automation & Testing Made Simple

chatgpt image feb 22, 2026, 07 27 39 pm QATRIBE

QA, Automation & Testing Made Simple

  • Home
  • Blogs
  • Git
  • Playwright
  • Selenium
  • API Testing
    • API Authentication
    • REST Assured Interview Questions
    • API Testing Interview Questions
  • Java
    • Java Interview Prepartion
    • Java coding
  • Test Lead/Test Manager
  • Cucumber
  • TestNG
  • Home
  • Blogs
  • Git
  • Playwright
  • Selenium
  • API Testing
    • API Authentication
    • REST Assured Interview Questions
    • API Testing Interview Questions
  • Java
    • Java Interview Prepartion
    • Java coding
  • Test Lead/Test Manager
  • Cucumber
  • TestNG
Close

Search

Subscribe
Playwright TypeScript Folder Structure
Blogs

Playwright TypeScript Folder Structure: The Complete Guide (2026)-QaTribe

By Ajit Marathe
41 Min Read
0

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

  1. Why Folder Structure Matters More Than You Think
  2. Project Initialization
  3. The Complete Folder Tree
  4. The /tests Folder — Organizing Your Spec Files
  5. The /pages Folder — Page Object Model
  6. The /components Folder — Reusable UI Pieces
  7. The /fixtures Folder — Test Setup Done Right
  8. The /utils Folder — Shared Helpers
  9. The /data Folder — Test Data Management
  10. The /config Folder — Environment Management
  11. The /types Folder — TypeScript Interfaces
  12. The /constants Folder — No More Magic Strings
  13. The /hooks Folder — Global Setup and Teardown
  14. playwright.config.ts — Deep Dive
  15. tsconfig.json — TypeScript Configuration
  16. package.json Scripts — Run Everything
  17. Environment Strategy — Dev, Staging, Production
  18. CI/CD Integration
  19. Scaling for Enterprise Projects
  20. Common Mistakes
  21. Migration Guide — Fixing an Existing Project
  22. Final Complete Structure — Copy and Use
  23. 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

ProblemReal-World Impact
No Page ObjectsOne selector change breaks 40 test files
No fixture systemLogin logic is copy-pasted across 30 tests
No config managementCan’t run tests against staging without editing code
No data separationHardcoded emails cause parallel test failures
No type definitionsRefactoring 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 TypePatternExample
Feature testfeature-name.spec.tslogin.spec.ts
API testresource.api.spec.tsuser.api.spec.ts
Smoke testsmoke.spec.tssmoke.spec.ts
Regressionfeature.regression.spec.tscheckout.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:

TypeWhen to UseLocation
Static JSONFixed lookup data, predefined usersdata/users.json
Factory (dynamic)Unique data per test rundata/factory/*.ts
API-seededComplex scenarios, faster than UICreated 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 }}/4

With 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.

  1. Create pages/login.page.ts with proper class structure
  2. Update tests/e2e/auth/login.spec.ts to use the new page object
  3. Delete the old inline selectors from test files
  4. Repeat for each page

Week 5 — Extract Utilities and Fixtures

  1. Find all duplicated page.goto('/login') + fill + click patterns → move to auth fixture
  2. Find all repeated date/string manipulation → move to utils/
  3. Find all hardcoded test users → move to data/factory/
  4. Update all test files to import from new locations

Week 6 — Config, Constants, CI

  1. Move all hardcoded URLs to constants/routes.ts
  2. Move all hardcoded strings to constants/messages.ts
  3. Create config/environments/ files
  4. Update playwright.config.ts to use appConfig
  5. Update CI pipeline
  6. 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 element

Q10: 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

Tags:

API AutomationAPI TestingAPI Testing ChecklistAPI Testing ToolsAPI Testing TutorialAPI Testing with PythonAppiumAppium TutorialAutomation TestingCypressCypress TutorialCypress vs SeleniumEnd to End TestingGraphQL Testinghow to install playwright typescriptinterview-questionsinterview-questions; browser automationPlaywrightplaywright beginners guide indiaplaywright configplaywright page object modelplaywright setup 2025playwright test framework typescriptPlaywright Tutorialplaywright typescript tutorialplaywright vs seleniumPostmanPostman Interview QuestionsQA AutomationREST APIREST API TestingRest AssuredSeleniumselenium alternativeselenium interview questionsSelenium TutorialSelenium WebDriverSelenium with JavaSelenium with PythonTest AutomationTest Automation Framework
Author

Ajit Marathe

Follow Me
Other Articles
Playwright TypeScript Folder Structure
Previous

REST Assured Interview Questions: The Complete Guide (Beginner to Architect)

No Comment! Be the first one.

    Leave a Reply Cancel reply

    Your email address will not be published. Required fields are marked *

    Recent Posts

    • Playwright TypeScript Folder Structure: The Complete Guide (2026)-QaTribe
    • REST Assured Interview Questions: The Complete Guide (Beginner to Architect)
    • Test Lead and Test Manager Interview Questions: The Ultimate Guide (2026)
    • REST Assured Interview Questions: The Complete Guide for 2026(Beginner to Architect Level)
    • Top 25 Playwright Interview Questions (2026) – Framework Design, Architecture & Best Practices

    Categories

    • API Authentication
    • API Testing
    • API Testing Interview Questions
    • Blogs
    • Cucumber
    • Git
    • Java
    • Java coding
    • Java Interview Prepartion
    • Playwright
    • REST Assured Interview Questions
    • Selenium
    • Test Lead/Test Manager
    • TestNG
    • About
    • Privacy Policy
    • Contact
    • Disclaimer
    Copyright © 2026 — QATRIBE. All rights reserved. Learn • Practice • Crack Interviews