Skip to content
HN
Back to blog
PlaywrightArchitecture

Building a Playwright Framework From Scratch

Walking through the folder structure, page object design and configuration decisions behind a maintainable Playwright framework.

July 30, 202617 min read

Building a Playwright Framework From Scratch

Introduction

Starting a Playwright project is easy.

You can install Playwright, create a test file, and run your first browser test in only a few minutes.

npm init playwright@latest

However, building a Playwright framework is different from writing a few Playwright tests.

A real automation framework must support:

  • Multiple environments
  • Reusable Page Objects
  • Test data management
  • API testing
  • Fixtures
  • Logging
  • Reports
  • Screenshots
  • Parallel execution
  • CI/CD integration
  • Long-term maintenance

Without a clear structure, a small test project can quickly become difficult to understand and expensive to maintain.

In this article, we will build a scalable Playwright framework from scratch using TypeScript.


What We Are Building

The final framework will include:

playwright-framework/

├── config/
├── data/
├── fixtures/
├── pages/
├── components/
├── api/
├── tests/
├── utils/
├── playwright.config.ts
├── package.json
└── tsconfig.json

The framework will support:

  • Page Object Model
  • Reusable components
  • Custom fixtures
  • Environment configuration
  • Authentication state
  • API-based setup
  • Test data separation
  • Screenshots, videos, and traces
  • Smoke and regression tags
  • Parallel execution
  • CI execution

Prerequisites

Before starting, make sure you have:

  • Node.js installed
  • npm installed
  • Basic TypeScript knowledge
  • Basic Playwright knowledge
  • A code editor such as Visual Studio Code

Verify the installation:

node --version
npm --version

Step 1: Create the Project

Create a new project folder.

mkdir playwright-framework
cd playwright-framework

Initialize Node.js:

npm init -y

Install Playwright:

npm install -D @playwright/test

Install the supported browsers:

npx playwright install

For CI environments that require operating-system dependencies:

npx playwright install --with-deps

Step 2: Create the TypeScript Configuration

Create a file named:

tsconfig.json

Add:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "Node",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "@pages/*": ["pages/*"],
      "@components/*": ["components/*"],
      "@fixtures/*": ["fixtures/*"],
      "@data/*": ["data/*"],
      "@utils/*": ["utils/*"],
      "@api/*": ["api/*"],
      "@config/*": ["config/*"]
    }
  },
  "include": [
    "**/*.ts"
  ]
}

The path aliases keep imports readable.

Instead of:

import { LoginPage } from '../../../pages/LoginPage';

You can use:

import { LoginPage } from '@pages/LoginPage';

Step 3: Create the Initial Folder Structure

Create the following folders:

config/
data/
fixtures/
pages/
components/
api/
tests/
utils/

The structure becomes:

playwright-framework/

├── api/
├── components/
├── config/
├── data/
├── fixtures/
├── pages/
├── tests/
├── utils/
├── package.json
└── tsconfig.json

Each folder should have a clear responsibility.


Step 4: Create the Playwright Configuration

Create:

playwright.config.ts

Add:

import {
  defineConfig,
  devices,
} from '@playwright/test';

export default defineConfig({
  testDir: './tests',

  timeout: 60_000,

  expect: {
    timeout: 10_000,
  },

  fullyParallel: true,

  forbidOnly: Boolean(
    process.env.CI,
  ),

  retries:
    process.env.CI ? 2 : 0,

  workers:
    process.env.CI ? 2 : undefined,

  reporter: [
    ['list'],
    [
      'html',
      {
        outputFolder:
          'playwright-report',
        open: 'never',
      },
    ],
  ],

  use: {
    baseURL:
      process.env.BASE_URL ??
      'https://example.com',

    trace:
      'retain-on-failure',

    screenshot:
      'only-on-failure',

    video:
      'retain-on-failure',

    actionTimeout: 15_000,

    navigationTimeout: 30_000,
  },

  projects: [
    {
      name: 'chromium',
      use: {
        ...devices[
          'Desktop Chrome'
        ],
      },
    },

    {
      name: 'firefox',
      use: {
        ...devices[
          'Desktop Firefox'
        ],
      },
    },

    {
      name: 'webkit',
      use: {
        ...devices[
          'Desktop Safari'
        ],
      },
    },
  ],
});

This configuration enables:

  • Parallel execution
  • CI retries
  • Multiple browsers
  • HTML reports
  • Screenshots on failure
  • Video on failure
  • Trace collection
  • Configurable base URL

Step 5: Add Environment Configuration

Hardcoding environment URLs directly in the framework makes maintenance difficult.

Create:

config/environment.ts

Add:

export type EnvironmentName =
  | 'dev'
  | 'qa'
  | 'staging';

type EnvironmentConfig = {
  baseURL: string;
  apiURL: string;
};

const environments:
  Record<
    EnvironmentName,
    EnvironmentConfig
  > = {
    dev: {
      baseURL:
        'https://dev.example.com',
      apiURL:
        'https://api-dev.example.com',
    },

    qa: {
      baseURL:
        'https://qa.example.com',
      apiURL:
        'https://api-qa.example.com',
    },

    staging: {
      baseURL:
        'https://staging.example.com',
      apiURL:
        'https://api-staging.example.com',
    },
  };

const environmentName =
  (
    process.env.TEST_ENV ??
    'qa'
  ) as EnvironmentName;

const environment =
  environments[environmentName];

if (!environment) {
  throw new Error(
    `Unsupported environment: ${environmentName}`,
  );
}

export {
  environment,
  environmentName,
};

Update playwright.config.ts:

import {
  environment,
} from './config/environment';

Then:

use: {
  baseURL: environment.baseURL,
}

Run against a specific environment:

TEST_ENV=staging npx playwright test

On Windows Command Prompt:

set TEST_ENV=staging&& npx playwright test

On PowerShell:

$env:TEST_ENV="staging"
npx playwright test

Step 6: Add Environment Variables

Install dotenv:

npm install dotenv

Create:

.env

Example:

TEST_ENV=qa
TEST_USERNAME=tester@example.com
TEST_PASSWORD=Password123

Load it inside playwright.config.ts:

import 'dotenv/config';

Add .env to .gitignore:

.env

Never commit real credentials to source control.


Step 7: Create a Base Page

Create:

pages/BasePage.ts

Add:

import {
  Page,
} from '@playwright/test';

export class BasePage {
  constructor(
    protected readonly page: Page,
  ) {}

  async open(
    path = '/',
  ): Promise<void> {
    await this.page.goto(path);
  }

  async reload(): Promise<void> {
    await this.page.reload();
  }

  async waitForPageReady():
    Promise<void> {
    await this.page.waitForLoadState(
      'domcontentloaded',
    );
  }
}

Keep the Base Page small.

It should contain only genuinely shared page behaviour.

Avoid turning it into a massive utility class with unrelated methods.


Step 8: Create the First Page Object

Create:

pages/LoginPage.ts

Add:

import {
  Locator,
  Page,
} from '@playwright/test';

import {
  BasePage,
} from './BasePage';

export class LoginPage
  extends BasePage {
  readonly emailInput:
    Locator;

  readonly passwordInput:
    Locator;

  readonly loginButton:
    Locator;

  readonly errorMessage:
    Locator;

  constructor(page: Page) {
    super(page);

    this.emailInput =
      page.getByLabel(
        'Email',
      );

    this.passwordInput =
      page.getByLabel(
        'Password',
      );

    this.loginButton =
      page.getByRole(
        'button',
        {
          name: 'Login',
        },
      );

    this.errorMessage =
      page.getByRole(
        'alert',
      );
  }

  async openLoginPage():
    Promise<void> {
    await this.open('/login');
  }

  async login(
    email: string,
    password: string,
  ): Promise<void> {
    await this.emailInput.fill(
      email,
    );

    await this.passwordInput.fill(
      password,
    );

    await this.loginButton.click();
  }
}

The page object contains:

  • Locators
  • Page interactions
  • Navigation methods

It does not contain test data or test assertions.


Step 9: Create Another Page Object

Create:

pages/DashboardPage.ts

Add:

import {
  Locator,
  Page,
} from '@playwright/test';

import {
  BasePage,
} from './BasePage';

export class DashboardPage
  extends BasePage {
  readonly heading:
    Locator;

  readonly welcomeMessage:
    Locator;

  constructor(page: Page) {
    super(page);

    this.heading =
      page.getByRole(
        'heading',
        {
          name: 'Dashboard',
        },
      );

    this.welcomeMessage =
      page.getByTestId(
        'welcome-message',
      );
  }
}

Tests can now verify the result of a successful login through the dashboard page.


Step 10: Create Reusable Components

Many application elements appear on multiple pages.

Examples include:

  • Headers
  • Navigation menus
  • Sidebars
  • Modals
  • Toast notifications
  • Pagination

Create:

components/HeaderComponent.ts

Add:

import {
  Locator,
  Page,
} from '@playwright/test';

export class HeaderComponent {
  readonly profileMenu:
    Locator;

  readonly logoutButton:
    Locator;

  constructor(
    private readonly page: Page,
  ) {
    this.profileMenu =
      page.getByTestId(
        'profile-menu',
      );

    this.logoutButton =
      page.getByRole(
        'button',
        {
          name: 'Logout',
        },
      );
  }

  async logout():
    Promise<void> {
    await this.profileMenu.click();
    await this.logoutButton.click();
  }
}

Use it inside a page object:

import {
  HeaderComponent,
} from '@components/HeaderComponent';

export class DashboardPage
  extends BasePage {
  readonly header:
    HeaderComponent;

  constructor(page: Page) {
    super(page);

    this.header =
      new HeaderComponent(page);
  }
}

This approach uses composition instead of duplicating header logic across pages.


Step 11: Create Test Data

Create:

data/users.ts

Add:

export type TestUser = {
  email: string;
  password: string;
};

export const validUser:
  TestUser = {
    email:
      process.env.TEST_USERNAME ??
      '',

    password:
      process.env.TEST_PASSWORD ??
      '',
  };

export const invalidUser:
  TestUser = {
    email:
      'invalid@example.com',

    password:
      'WrongPassword',
  };

Validate required variables:

if (
  !validUser.email ||
  !validUser.password
) {
  throw new Error(
    'TEST_USERNAME and TEST_PASSWORD are required',
  );
}

This keeps test data separate from test logic.


Step 12: Create Custom Fixtures

Custom fixtures allow Page Objects to be injected into tests automatically.

Create:

fixtures/app.fixture.ts

Add:

import {
  test as base,
  expect,
} from '@playwright/test';

import {
  LoginPage,
} from '@pages/LoginPage';

import {
  DashboardPage,
} from '@pages/DashboardPage';

type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage:
    DashboardPage;
};

const test =
  base.extend<AppFixtures>({
    loginPage: async (
      { page },
      use,
    ) => {
      await use(
        new LoginPage(page),
      );
    },

    dashboardPage: async (
      { page },
      use,
    ) => {
      await use(
        new DashboardPage(page),
      );
    },
  });

export {
  test,
  expect,
};

Tests no longer need to create page objects manually.


Step 13: Write the First Test

Create:

tests/authentication/login.spec.ts

Add:

import {
  test,
  expect,
} from '@fixtures/app.fixture';

import {
  validUser,
} from '@data/users';

test.describe(
  'Login',
  () => {
    test(
      'user can login successfully @smoke',
      async ({
        loginPage,
        dashboardPage,
      }) => {
        await loginPage
          .openLoginPage();

        await loginPage.login(
          validUser.email,
          validUser.password,
        );

        await expect(
          dashboardPage.heading,
        ).toBeVisible();

        await expect(
          dashboardPage
            .welcomeMessage,
        ).toContainText(
          validUser.email,
        );
      },
    );
  },
);

Run it:

npx playwright test

Run only Chromium:

npx playwright test --project=chromium

Run in headed mode:

npx playwright test --headed

Step 14: Add a Negative Test

Add:

import {
  invalidUser,
} from '@data/users';

test(
  'user cannot login with invalid credentials @regression',
  async ({
    loginPage,
  }) => {
    await loginPage
      .openLoginPage();

    await loginPage.login(
      invalidUser.email,
      invalidUser.password,
    );

    await expect(
      loginPage.errorMessage,
    ).toContainText(
      'Invalid credentials',
    );
  },
);

This test remains independent from the successful login test.


Step 15: Add API Support

API calls are useful for:

  • Creating test data
  • Deleting test data
  • Authentication
  • Backend validation
  • Faster setup

Create:

api/UserApi.ts

Add:

import {
  APIRequestContext,
  expect,
} from '@playwright/test';

import {
  environment,
} from '@config/environment';

export type CreatedUser = {
  id: string;
  email: string;
  password: string;
};

export class UserApi {
  constructor(
    private readonly request:
      APIRequestContext,
  ) {}

  async createUser():
    Promise<CreatedUser> {
    const uniqueId =
      `${Date.now()}-${Math.random()}`;

    const user = {
      email:
        `tester-${uniqueId}@example.com`,

      password:
        'Password123',
    };

    const response =
      await this.request.post(
        `${environment.apiURL}/users`,
        {
          data: user,
        },
      );

    expect(
      response.ok(),
    ).toBeTruthy();

    const body =
      await response.json();

    return {
      id: body.id,
      ...user,
    };
  }

  async deleteUser(
    userId: string,
  ): Promise<void> {
    const response =
      await this.request.delete(
        `${environment.apiURL}/users/${userId}`,
      );

    expect(
      response.ok(),
    ).toBeTruthy();
  }
}

Step 16: Add the API Client to Fixtures

Update:

fixtures/app.fixture.ts

Add:

import {
  UserApi,
} from '@api/UserApi';

Update the fixture type:

type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage:
    DashboardPage;
  userApi: UserApi;
};

Add the fixture:

userApi: async (
  { request },
  use,
) => {
  await use(
    new UserApi(request),
  );
},

Now tests can use API setup directly.


Step 17: Create Data Through the API

Example:

test(
  'new user can login @regression',
  async ({
    loginPage,
    dashboardPage,
    userApi,
  }) => {
    const user =
      await userApi.createUser();

    try {
      await loginPage
        .openLoginPage();

      await loginPage.login(
        user.email,
        user.password,
      );

      await expect(
        dashboardPage.heading,
      ).toBeVisible();
    } finally {
      await userApi.deleteUser(
        user.id,
      );
    }
  },
);

The finally block ensures cleanup happens even if the test fails.


Step 18: Create an Authentication Setup Project

Logging in through the UI before every test is slow.

Create:

tests/setup/auth.setup.ts

Add:

import {
  test as setup,
  expect,
} from '@playwright/test';

import {
  validUser,
} from '@data/users';

const authFile =
  'playwright/.auth/user.json';

setup(
  'authenticate',
  async ({ page }) => {
    await page.goto('/login');

    await page
      .getByLabel('Email')
      .fill(validUser.email);

    await page
      .getByLabel('Password')
      .fill(validUser.password);

    await page
      .getByRole(
        'button',
        {
          name: 'Login',
        },
      )
      .click();

    await expect(page)
      .toHaveURL(/dashboard/);

    await page
      .context()
      .storageState({
        path: authFile,
      });
  },
);

Create the directory:

playwright/.auth/

Add it to .gitignore:

playwright/.auth/

Step 19: Configure Authentication Dependencies

Update playwright.config.ts.

Add a setup project:

projects: [
  {
    name: 'setup',
    testMatch:
      /.*\.setup\.ts/,
  },

  {
    name: 'chromium',
    use: {
      ...devices[
        'Desktop Chrome'
      ],

      storageState:
        'playwright/.auth/user.json',
    },

    dependencies: [
      'setup',
    ],
  },
],

Authenticated tests now reuse the saved browser state.

Keep dedicated login tests separate so they still validate the login flow itself.


Step 20: Add Meaningful Test Steps

For longer tests, use test.step().

test(
  'customer can update profile',
  async ({
    dashboardPage,
  }) => {
    await test.step(
      'Open profile page',
      async () => {
        await dashboardPage
          .header
          .profileMenu
          .click();
      },
    );

    await test.step(
      'Update customer details',
      async () => {
        // Test actions
      },
    );

    await test.step(
      'Verify update success',
      async () => {
        // Assertions
      },
    );
  },
);

Steps improve:

  • HTML reports
  • Trace readability
  • Failure investigation
  • Business-level documentation

Step 21: Add Utility Functions

Avoid creating one giant Utils.ts.

Create focused utilities instead.

Example:

utils/string.util.ts
export function createUniqueEmail():
  string {
  return [
    'tester',
    Date.now(),
    Math.random()
      .toString(36)
      .slice(2),
  ].join('-') +
    '@example.com';
}

Example:

utils/date.util.ts
export function formatDate(
  date: Date,
): string {
  return date
    .toISOString()
    .split('T')[0];
}

Utilities should be:

  • Small
  • Focused
  • Reusable
  • Free from unrelated business logic

Step 22: Add Automatic Failure Attachments

Playwright automatically captures artifacts based on the configuration.

You can also add custom attachments.

Create:

fixtures/diagnostic.fixture.ts

Example:

import {
  test as base,
} from '@playwright/test';

export const test =
  base.extend({
    page: async (
      { page },
      use,
      testInfo,
    ) => {
      await use(page);

      if (
        testInfo.status !==
        testInfo.expectedStatus
      ) {
        const screenshot =
          await page.screenshot({
            fullPage: true,
          });

        await testInfo.attach(
          'failure-screenshot',
          {
            body: screenshot,
            contentType:
              'image/png',
          },
        );
      }
    },
  });

Use this only when default screenshots are not sufficient.

Avoid duplicating artifacts unnecessarily.


Step 23: Add Test Tags

Tags allow different test groups to run in different pipelines.

Example:

test(
  'user can login @smoke',
  async () => {
    // Test
  },
);

Run smoke tests:

npx playwright test --grep @smoke

Run regression tests:

npx playwright test --grep @regression

Exclude slow tests:

npx playwright test --grep-invert @slow

Common tags include:

@smoke
@regression
@critical
@api
@mobile
@slow

Step 24: Add npm Scripts

Update package.json.

{
  "scripts": {
    "test": "playwright test",
    "test:headed": "playwright test --headed",
    "test:debug": "playwright test --debug",
    "test:smoke": "playwright test --grep @smoke",
    "test:regression": "playwright test --grep @regression",
    "test:chromium": "playwright test --project=chromium",
    "report": "playwright show-report"
  }
}

Now you can run:

npm test

Or:

npm run test:smoke

Step 25: Add Linting and Formatting

Install ESLint and Prettier:

npm install -D eslint prettier typescript-eslint

Create:

eslint.config.js

Example:

const tseslint =
  require('typescript-eslint');

module.exports =
  tseslint.config(
    ...tseslint.configs
      .recommended,
    {
      files: [
        '**/*.ts',
      ],
      rules: {
        '@typescript-eslint/no-explicit-any':
          'error',
      },
    },
  );

Create:

.prettierrc
{
  "singleQuote": true,
  "semi": true,
  "trailingComma": "all"
}

Add scripts:

{
  "scripts": {
    "lint": "eslint .",
    "format": "prettier --write ."
  }
}

Consistent formatting improves collaboration and code review quality.


Step 26: Prepare for Parallel Execution

Playwright can run tests across multiple workers.

To make tests safe for parallel execution:

  • Avoid shared global variables
  • Avoid shared accounts
  • Avoid shared files
  • Use unique test data
  • Isolate browser state
  • Keep tests independent
  • Use unique output paths

Example unique test data:

const uniqueEmail =
  `tester-${test.info().workerIndex}-${Date.now()}@example.com`;

For output files:

const filePath =
  test.info().outputPath(
    'export.xlsx',
  );

outputPath() automatically isolates artifacts by test.


Step 27: Add Mobile Testing

Add a mobile project:

{
  name: 'mobile-chrome',

  use: {
    ...devices[
      'Pixel 7'
    ],
  },
},

Run:

npx playwright test --project=mobile-chrome

You can also mark mobile-only tests:

test(
  'mobile menu opens @mobile',
  async ({ page }) => {
    // Test
  },
);

Step 28: Add CI/CD Integration

Example GitHub Actions workflow:

.github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches:
      - main

  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Install browsers
        run: npx playwright install --with-deps

      - name: Run tests
        run: npm run test:smoke
        env:
          TEST_ENV: qa
          TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
          TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}

      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

This pipeline:

  • Checks out the project
  • Installs Node.js
  • Installs dependencies
  • Installs browsers
  • Runs smoke tests
  • Uploads the HTML report

Step 29: Final Project Structure

The framework may now look like this:

playwright-framework/

├── .github/
│   └── workflows/
│       └── playwright.yml
│
├── api/
│   └── UserApi.ts
│
├── components/
│   └── HeaderComponent.ts
│
├── config/
│   └── environment.ts
│
├── data/
│   └── users.ts
│
├── fixtures/
│   ├── app.fixture.ts
│   └── diagnostic.fixture.ts
│
├── pages/
│   ├── BasePage.ts
│   ├── DashboardPage.ts
│   └── LoginPage.ts
│
├── playwright/
│   └── .auth/
│
├── tests/
│   ├── authentication/
│   │   └── login.spec.ts
│   │
│   └── setup/
│       └── auth.setup.ts
│
├── utils/
│   ├── date.util.ts
│   └── string.util.ts
│
├── .env
├── .gitignore
├── .prettierrc
├── eslint.config.js
├── package-lock.json
├── package.json
├── playwright.config.ts
└── tsconfig.json

Important Design Principles

Keep Tests Business-Focused

Tests should describe what the user is doing.

Good:

await checkoutPage
  .placeOrder();

Less useful:

await page
  .locator('.btn-primary')
  .click();

Keep Page Objects Focused

A page object should represent one page or responsibility.

Do not build classes with hundreds of unrelated methods.


Use Components for Shared UI

Reusable UI sections should become component objects.

Examples:

  • Header
  • Sidebar
  • Modal
  • Pagination
  • Toast

Use APIs for Test Setup

Create test data through APIs whenever possible.

This keeps UI tests focused and reduces execution time.


Avoid Hardcoded Waits

Do not use:

await page.waitForTimeout(
  5000,
);

Wait for meaningful conditions:

await expect(
  successMessage,
).toBeVisible();

Keep Tests Independent

Every test should create and clean up its own data.

Tests should not depend on execution order.


Avoid Unnecessary Wrappers

Do not wrap every Playwright method without adding meaningful behaviour.

This usually adds complexity rather than value.


Design for Debugging

Enable:

  • Traces
  • Screenshots
  • Videos
  • Logs
  • HTML reports

A failed test should provide enough evidence to investigate the issue.


Common Mistakes When Starting a Framework

Avoid these common problems:

  • Creating a giant BasePage
  • Creating one large Utils.ts
  • Hardcoding credentials
  • Hardcoding environment URLs
  • Putting assertions inside every Page Object method
  • Using XPath for every locator
  • Adding waitForTimeout() everywhere
  • Sharing the same user across parallel tests
  • Making tests depend on previous tests
  • Running the full regression suite for every small code change
  • Hiding flaky tests with retries

A framework should reduce complexity, not create more of it.


Framework Readiness Checklist

Before calling the framework production-ready, check:

  • Can tests run independently?
  • Can tests run safely in parallel?
  • Are environments configurable?
  • Are secrets stored securely?
  • Are Page Objects reusable and focused?
  • Is test data separated from test logic?
  • Can APIs create and clean up test data?
  • Are screenshots, videos, and traces available?
  • Can smoke and regression tests run separately?
  • Does the framework run in CI?
  • Can a new team member understand the project structure?
  • Can a failed test be investigated without immediately rerunning it?

If the answer to several questions is no, the framework still needs improvement.


Conclusion

Building a Playwright framework from scratch is not only about installing Playwright and creating Page Object classes.

A maintainable framework requires clear boundaries between:

  • Test scenarios
  • Page Objects
  • Reusable components
  • API clients
  • Fixtures
  • Test data
  • Environment configuration
  • Utility functions
  • Reporting and diagnostics

Start with a simple structure, but design it so the framework can grow.

Use semantic locators, web-first assertions, API-based setup, isolated test data, reusable fixtures, and reliable CI execution. Avoid unnecessary abstraction, shared mutable state, hardcoded waits, and giant classes.

The goal is not to build the most complicated framework.

The goal is to build a framework that your team can understand, trust, and maintain as the application continues to evolve.