Skip to the content.

Go to Main Page »

Best Practices for playwright-elements

Write maintainable, scalable, and type-safe end-to-end tests

This guide covers recommended patterns, anti-patterns, and best practices for using playwright-elements effectively in real-world projects.


Table of Contents


Project Structure

A well-organized project structure is the foundation of maintainable tests.

project/
├── src/                          # Application code
│   └── app/
│
├── test/                         # All test-related code
│   ├── fixtures/                  # Custom test fixtures
│   │   ├── index.ts              # Main fixtures file (re-export everything)
│   │   ├── auth.fixture.ts       # Auth-related fixtures
│   │   └── api.fixture.ts        # API-related fixtures
│   │
│   ├── pages/                    # Page objects
│   │   ├── base/                 # Base page classes
│   │   │   ├── BasePage.ts        # Common page functionality
│   │   │   └── BaseAuthPage.ts    # Auth page base class
│   │   │
│   │   ├── login.page.ts         # Login page
│   │   ├── dashboard.page.ts      # Dashboard page
│   │   ├── settings.page.ts       # Settings page
│   │   └── index.ts              # Auto-generated or manual index
│   │
│   ├── components/               # Reusable UI components
│   │   ├── header.component.ts   # Header component
│   │   ├── form.component.ts     # Form component
│   │   ├── table.component.ts    # Table component
│   │   └── index.ts
│   │
│   ├── testIds/                  # Test ID definitions
│   │   ├── index.ts              # Central test ID registry
│   │   ├── auth.ids.ts           # Auth-related IDs
│   │   └── common.ids.ts         # Common/Shared IDs
│   │
│   ├── utils/                    # Test utilities
│   │   ├── helpers.ts            # Helper functions
│   │   ├── waiters.ts            # Custom wait conditions
│   │   └── validators.ts         # Validation utilities
│   │
│   ├── data/                    # Test data
│   │   ├── factories/            # Data factories
│   │   ├── fixtures/             # Test fixtures
│   │   └── mocks/                # Mock data
│   │
│   └── specs/                   # Test files
│       ├── smoke/                # Smoke tests
│       ├── regression/           # Regression tests
│       ├── integration/          # Integration tests
│       ├── e2e/                  # End-to-end tests
│       └── api/                  # API tests
│
├── playwright.config.ts         # Playwright configuration
├── jest.config.js              # Jest config (if used)
├── tsconfig.json               # TypeScript config
└── package.json

Alternative: Feature-Based Structure

For larger projects, organize by feature/domain:

test/
├── auth/                       # Authentication feature
│   ├── pages/                  # Auth-related pages
│   ├── components/             # Auth-related components
│   ├── testIds/                # Auth test IDs
│   └── specs/                  # Auth tests
│
├── dashboard/                  # Dashboard feature
│   ├── pages/
│   ├── components/
│   ├── testIds/
│   └── specs/
│
└── shared/                     # Shared resources
    ├── fixtures/
    ├── utils/
    └── testIds/

Central Fixtures File

Create a central fixtures file that re-exports everything:

// test/fixtures/index.ts
import { test as baseTest, buildPageObject, PageObject } from 'playwright-elements';
import * as pageObjectModule from '../pages';
import * as componentModule from '../components';

// Configure page objects
type PageObjectFixtures = {
  pageObject: PageObject<typeof pageObjectModule>;
  components: typeof componentModule;
};

export const test = baseTest.extend<PageObjectFixtures>({
  pageObject: [
    async ({}, use) => {
      await use(buildPageObject(pageObjectModule));
    },
    { scope: 'test' }
  ],
  components: [
    async ({}, use) => {
      await use(componentModule);
    },
    { scope: 'test' }
  ],
});

// Re-export everything for convenience
export * from 'playwright-elements';
export * from '../pages';
export * from '../components';
export * from '../testIds';
export * from '../utils';

Naming Conventions

Page Objects

Type Convention Example
Class name PascalCase + Page suffix LoginPage, DashboardPage
File name kebab-case + .page.ts login.page.ts, dashboard.page.ts
Properties camelCase usernameField, submitButton
Methods camelCase + verb login(), getErrorMessage()

Components

Type Convention Example
Class/const name PascalCase Header, FormField
File name kebab-case + .component.ts header.component.ts
Properties camelCase logo, menuButton
Methods camelCase + verb expand(), submit()

Test IDs

Type Convention Example
Static IDs kebab-case submit-button, login-form
Factory prefixes kebab-case + - btn-, nav-, form-
Type parameters dot notation 'auth.loginButton', 'form.input'

Tests

Type Convention Example
Test file kebab-case + .spec.ts login.spec.ts, user-registration.spec.ts
Test describe Title Case Login Page, User Registration
Test title should + action should login successfully, should show error

Component Organization

The with() Method: When to Use

Use .with() for:

Avoid .with() for:

Component Granularity

Too Fine-Grained (Avoid):

// Every single element is a separate component
const button = $('.button').with({});
const icon = $('.icon').with({});

Too Coarse (Avoid):

// Entire page is one huge component
const page = $('.app').with({
  // 50+ properties
  header: $('.header'),
  footer: $('.footer'),
  form: $('.form'),
  // ...
});

Just Right (Recommended):

// Logical groupings
const header = $('.header').with({
  logo: $('.logo'),
  navigation: $('.nav').with({
    items: $('.nav-item'),
    async openMenu(this: WebElement) {
      await this.click();
    }
  })
});

const loginForm = $('.login-form').with({
  username: $('input[name="username"]'),
  password: $('input[name="password"]'),
  submit: $('button[type="submit"]'),
  async login(this: WebElement, username: string, password: string) {
    await this.username.fill(username);
    await this.password.fill(password);
    await this.submit.click();
  }
});

Base Components

Create base components for common patterns:

// components/base/form.component.ts
import { WebElement } from 'playwright-elements';

export abstract class BaseForm extends WebElement {
  abstract submit: WebElement;
  
  async submitForm() {
    await this.submit.click();
  }
  
  async isValid() {
    const errorMessages = await this.$('.error-message').getAll();
    return errorMessages.length === 0;
  }
}

// components/login.form.ts
import { $ } from 'playwright-elements';
import { BaseForm } from './base/form.component';

export class LoginForm extends BaseForm {
  readonly username = $('input[name="username"]');
  readonly password = $('input[name="password"]');
  readonly submit = $('button[type="submit"]');
  
  async login(username: string, password: string) {
    await this.username.fill(username);
    await this.password.fill(password);
    await this.submitForm();
  }
}

Test ID Strategies

Centralized Test ID Registry

Create a central file for all test IDs:

// testIds/index.ts
import { factory, sid, ns } from 'playwright-elements';

// Namespaced helpers
const authId = ns<'auth'>();
const formId = ns<'form'>();
const navId = ns<'nav'>();

export const ids = {
  // Authentication
  auth: {
    login: {
      form: sid<'auth.login.form'>('login-form'),
      username: sid<'auth.login.username'>('login-username'),
      password: sid<'auth.login.password'>('login-password'),
      submit: sid<'auth.login.submit'>('login-submit'),
      error: sid<'auth.login.error'>('login-error'),
    },
    logout: {
      button: sid<'auth.logout.button'>('logout-button'),
    },
    register: {
      link: sid<'auth.register.link'>('register-link'),
    }
  },
  
  // Navigation
  nav: {
    logo: sid<'nav.logo'>('nav-logo'),
    menu: {
      button: sid<'nav.menu.button'>('nav-menu-button'),
      item: factory<'nav.menu.item'>('nav-menu-item'),
    },
    breadcrumb: factory<'nav.breadcrumb'>('breadcrumb-'),
  },
  
  // Forms
  form: {
    input: factory<'form.input'>('form-input'),
    button: factory<'form.button'>('form-button'),
    error: factory<'form.error'>('form-error'),
    success: sid<'form.success'>('form-success-message'),
  },
  
  // Common elements
  common: {
    spinner: sid<'common.spinner'>('spinner'),
    toast: factory<'common.toast'>('toast'),
    modal: sid<'common.modal'>('modal'),
    overlay: sid<'common.overlay'>('overlay'),
  }
} as const;

// Validate no prefix collisions
import { assertNoPrefixCollisions } from 'playwright-elements';
assertNoPrefixCollisions(ids);

Feature-Based Test ID Files

For larger projects, split test IDs by feature:

// testIds/auth.ids.ts
export const authIds = {
  login: {
    form: sid<'auth.login.form'>('login-form'),
    username: sid<'auth.login.username'>('login-username'),
    // ...
  }
} as const;

// testIds/nav.ids.ts
export const navIds = {
  menu: {
    button: sid<'nav.menu.button'>('nav-menu-button'),
    // ...
  }
} as const;

// testIds/index.ts
export * from './auth.ids';
export * from './nav.ids';
// ...

Test ID Naming Conventions

Good:

// Descriptive and consistent
const button = sid<'auth.login.submit'>('login-submit-button');
const input = factory<'form.field'>('form-field-input');

Avoid:

// Too generic
const btn = sid('button');
const inp = sid('input');

// Inconsistent
const LoginButton = sid('login-btn');
const password_field = sid('pw-field');

Page Object Patterns

Classic Page Object

// pages/login.page.ts
import { $ } from 'playwright-elements';
import { ids } from '../testIds';
import { $byTestId } from 'playwright-elements';

export class LoginPage {
  // Using CSS selectors
  readonly usernameField = $('input[name="username"]');
  readonly passwordField = $('input[name="password"]');
  
  // Using Test IDs
  readonly submitButton = $byTestId(ids.auth.login.submit);
  readonly errorMessage = $byTestId(ids.auth.login.error);
  
  // Components
  readonly header = new HeaderComponent();
  readonly footer = new FooterComponent();
  
  async navigate() {
    await goto('/login');
  }
  
  async login(username: string, password: string) {
    await this.usernameField.fill(username);
    await this.passwordField.fill(password);
    await this.submitButton.click();
  }
  
  async getErrorMessage() {
    return await this.errorMessage.textContent();
  }
  
  async isErrorVisible() {
    return await this.errorMessage.isVisible();
  }
}

Component-Driven Page Object

// pages/dashboard.page.ts
import { $ } from 'playwright-elements';
import { HeaderComponent } from '../components/header.component';
import { TableComponent } from '../components/table.component';
import { SidebarComponent } from '../components/sidebar.component';

export class DashboardPage {
  readonly header = new HeaderComponent();
  readonly sidebar = new SidebarComponent();
  readonly dataTable = new TableComponent();
  
  async navigate() {
    await goto('/dashboard');
  }
  
  async openSettings() {
    await this.sidebar.settingsLink.click();
  }
  
  async searchForItem(query: string) {
    await this.header.searchInput.fill(query);
    await this.header.searchButton.click();
  }
}

Factory Pattern for Pages

// pages/index.ts
export * from './login.page';
export * from './dashboard.page';
export * from './settings.page';

// factories/page.factory.ts
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';
import { SettingsPage } from '../pages/settings.page';

export class PageFactory {
  static getPage(pageName: string) {
    switch (pageName) {
      case 'login':
        return new LoginPage();
      case 'dashboard':
        return new DashboardPage();
      case 'settings':
        return new SettingsPage();
      default:
        throw new Error(`Unknown page: ${pageName}`);
    }
  }
}

Selector Strategies

Selector Priority (Best to Worst)

  1. Test IDs - Most stable, most readable
  2. ARIA attributes - Semantic, stable
  3. Name attributes - Usually stable
  4. CSS classes - Can change, but often semantic
  5. CSS selectors - Generic, can be fragile
  6. Text content - Language-dependent, can change
  7. XPath - Fragile, hard to read

Test ID Usage

Always prefer Test IDs:

// Best
const button = $byTestId(ids.auth.login.submit);

// Good (if you control the HTML)
const button = $getByRole('button', { name: 'Submit' });

// Acceptable
const button = $('button[type="submit"]');

// Avoid (fragile)
const button = $('div > button:first-child');

ARIA Attributes

// Good
const button = $getByRole('button', { name: 'Submit' });
const dialog = $getByRole('dialog');
const alert = $getByRole('alert');

// With ARIA labels
const closeButton = $getByLabel('Close');
const menuButton = $getByLabel('Open menu');

Form Fields

// Best
const username = $getByLabel('Username');
const email = $getByLabel('Email');

// Good
const username = $('input[name="username"]');
const email = $('input[type="email"]');

// Acceptable
const username = $('input#username');
const username2 = $('.username-input');

// Avoid
const username = $('input:nth-child(3)');

Dynamic Content

For lists, tables, and dynamic content:

// Factory for dynamic IDs
const row = factory<'table.row'>('table-row');
const cell = factory<'table.cell'>('table-cell');

// In component
<tr {...testIdProps(row(user.id))}>
  <td {...testIdProps(cell(`${user.id}-name`))}>{user.name}</td>
</tr>

// In test
const userRow = $byTestId(row(userId));
await userRow.expect().toContainText('John Doe');

// Or by prefix
const allRows = $byTestIdPrefix(row);
const johnRow = allRows.filter({ hasText: 'John Doe' });

Avoid Over-Qualified Selectors

Too specific (Avoid):

const button = $('body > div.container > div.header > nav > ul > li > button');

Better:

const button = $byTestId(ids.nav.menu.button);
// or
const button = $getByRole('button', { name: 'Menu' });
// or
const button = $('.menu-button');

Assertion Strategies

Assertion Hierarchy

  1. Existence - Is the element there?
  2. Visibility - Can the user see it?
  3. State - Is it in the correct state?
  4. Content - Does it have the correct content?
// 1. Existence
await element.expect().toBeAttached();

// 2. Visibility
await element.expect().toBeVisible();

// 3. State
await element.expect().toBeEnabled();
await element.expect().toBeChecked();

// 4. Content
await element.expect().toHaveText('Expected text');
await element.expect().toHaveValue('expected value');

Soft vs Hard Assertions

Use Soft Assertions When Use Hard Assertions When
Checking multiple independent conditions Checking critical path
Collecting all failures for debugging Failing fast on critical errors
Non-critical validations Core functionality
// Soft assertions for form validation
test('form validation', async ({ pageObject }) => {
  await pageObject.login.username.fill('');
  await pageObject.login.password.fill('');
  await pageObject.login.submit.click();
  
  // All validations run, all failures reported
  await pageObject.login.usernameError.softExpect().toBeVisible();
  await pageObject.login.passwordError.softExpect().toBeVisible();
  await pageObject.login.formError.softExpect().toContainText('required');
});

// Hard assertion for critical path
test('login redirects to dashboard', async ({ pageObject }) => {
  await pageObject.login.login('admin', 'password');
  // Fail immediately if redirect doesn't happen
  await pageObject.dashboard.header.expect().toBeVisible();
});

Custom Assertion Messages

Always provide meaningful assertion messages:

// Good
await element.expect('Login button should be visible after form submission').toBeVisible();

// Bad (no context)
await element.expect().toBeVisible();

// Acceptable (implicit from selector)
await $('.login-button').expect().toBeVisible();

Assertion Order

Order assertions from most general to most specific:

// Good
await element.expect().toBeVisible();
await element.expect().toBeEnabled();
await element.expect().toHaveText('Submit');

// Also good (logical order)
await element.expect().toBeAttached();
await element.expect().toBeVisible();
await element.expect().toBeEnabled();

Testing Strategies

Test Pyramid

Follow the test pyramid principle:

        ┌─────────────┐
        │   E2E (10%)  │  ← Few, comprehensive
        ├─────────────┤
        │ Integration   │
        │   (20%)      │
        ├─────────────┤
        │   Unit       │  ← Many, fast
        │  (70%)       │
        └─────────────┘

In playwright-elements context:

Test Granularity

One Assertion per Test:

// Good - clear what's being tested
test('login button is visible', async ({ pageObject }) => {
  await pageObject.login.navigate();
  await pageObject.login.submitButton.expect().toBeVisible();
});

test('login button is enabled when form is valid', async ({ pageObject }) => {
  await pageObject.login.navigate();
  await pageObject.login.fillValidForm();
  await pageObject.login.submitButton.expect().toBeEnabled();
});

// Bad - testing multiple things
test('login button works', async ({ pageObject }) => {
  await pageObject.login.navigate();
  await pageObject.login.submitButton.expect().toBeVisible();
  await pageObject.login.submitButton.expect().toBeEnabled();
  await pageObject.login.submitButton.expect().toHaveText('Login');
});

Test Data Management

Use factories for test data:

// test/data/factories/user.factory.ts
export class UserFactory {
  static createUser(overrides?: Partial<User>) {
    return {
      id: `user-${Date.now()}`,
      username: `user${Date.now()}`,
      email: `user${Date.now()}@example.com`,
      password: 'Password123!',
      firstName: 'Test',
      lastName: 'User',
      ...overrides
    };
  }
  
  static createAdmin() {
    return this.createUser({ role: 'admin' });
  }
  
  static createStandardUser() {
    return this.createUser({ role: 'user' });
  }
}

// Usage in tests
test('user registration', async ({ pageObject }) => {
  const user = UserFactory.createUser();
  await pageObject.registration.register(user);
  await pageObject.dashboard.welcomeMessage.expect().toContainText(user.username);
});

Test Isolation

Each test should be isolated:

// Good - isolated
test('user can login', async ({ pageObject }) => {
  const user = UserFactory.createUser();
  await pageObject.registration.register(user);
  await pageObject.login.login(user.username, 'password');
});

test('user can logout', async ({ pageObject }) => {
  const user = UserFactory.createUser();
  await pageObject.login.login(user.username, 'password');
  await pageObject.dashboard.logout();
});

// Bad - tests depend on each other
test('register and login', async ({ pageObject }) => {
  const user = UserFactory.createUser();
  await pageObject.registration.register(user);
});

test('logout after registration', async ({ pageObject }) => {
  // This test depends on the previous test's state
  await pageObject.dashboard.logout();
});

Performance Optimization

Lazy Loading

WebElement locators are lazy by default - they’re only evaluated when used:

// This doesn't query the DOM yet
const button = $('button');

// This queries the DOM
await button.click();

Reuse Elements

Define elements at the page/component level, not in tests:

// Good - defined once in page object
class LoginPage {
  readonly submitButton = $('button[type="submit"]');
}

// Usage in multiple tests
test('test 1', async ({ pageObject }) => {
  await pageObject.login.submitButton.click();
});

test('test 2', async ({ pageObject }) => {
  await pageObject.login.submitButton.expect().toBeEnabled();
});

// Bad - redefining in each test
test('test 1', async ({ page }) => {
  const button = $('button[type="submit"]');
  await button.click();
});

test('test 2', async ({ page }) => {
  const button = $('button[type="submit"]');
  await button.expect().toBeEnabled();
});

Avoid Unnecessary Waits

// Good - only wait when necessary
await element.click(); // Implicit wait

// Bad - unnecessary explicit waits
await page.waitForTimeout(1000);
await element.click();

// Better - use proper wait conditions
await element.waitFor();
await element.click();

Parallel Test Execution

Run tests in parallel when possible:

// playwright.config.ts
{
  workers: 4, // Use 4 parallel workers
  // or for CI
  workers: Math.max(1, Math.floor(os.cpus().length / 2)),
}

Use asyncForEach for Data Collection

// Good - parallel execution for data collection
const texts = [];
await elements.asyncForEach(async (e) => {
  texts.push(await e.textContent());
});

// Bad - sequential execution (slower)
const texts = [];
for (const e of await elements.getAll()) {
  texts.push(await e.textContent());
}

Type Safety Tips

Always Use Explicit Types

// Good - explicit type
const button: WebElement = $('button');

// Better - with nested types
type Form = WebElement & {
  username: WebElement;
  password: WebElement;
  submit: WebElement;
};

const form: Form = $('.form').with({
  username: $('input[name="username"]'),
  password: $('input[name="password"]'),
  submit: $('button[type="submit"]'),
});

Use typeof this for Custom Methods

// Good - type-safe this
type Table = WebElement & {
  rows: WebElement;
};

const table: Table = $('.table').with({
  rows: $('.row'),
  
  async filterRows(this: Table, text: string) {
    return this.rows.filter({ hasText: text });
  }
});

// Bad - loses type safety
const table2 = $('.table').with({
  rows: $('.row'),
  
  async filterRows(text: string) {
    // this is any, no type safety
    return this.rows.filter({ hasText: text });
  }
});

Parent Type Annotations

// Good - type-safe parent access
const header = $('.header').with({
  logo: $('.logo'),
  login: $('#log-in').with({
    async hoverAndClick(this: WebElement) {
      // Type-safe access to parent
      await this.parent<typeof header>().logo.hover();
      await this.click();
    }
  })
});

Use as const for Test ID Registries

// Good - maintains literal types
export const ids = {
  login: {
    username: sid<'login.username'>('username-input'),
    password: sid<'login.password'>('password-input'),
  }
} as const;

// Bad - loses literal types
export const ids = {
  login: {
    username: sid('username-input'), // Type: TestId<string>
    password: sid('password-input'), // Type: TestId<string>
  }
}; // No type parameter

Maintenance & Refactoring

Version Control

Keep playwright-elements updated:

# Check for updates
npm outdated playwright-elements

# Update
npm install playwright-elements@latest

# Check breaking changes in migration guide

Deprecation Strategy

When refactoring:

  1. Update the implementation
  2. Add deprecation warnings if needed
  3. Update all usages
  4. Remove old code in next major version

Documentation

Document your page objects and components:

/**
 * LoginPage - Represents the login page of the application
 * 
 * @example
 * ```typescript
 * const loginPage = new LoginPage();
 * await loginPage.login('admin', 'password');
 * ```
 */
export class LoginPage {
  /**
   * Username input field
   */
  readonly usernameField = $('input[name="username"]');
  
  /**
   * Logs in with the specified credentials
   * @param username - The username to login with
   * @param password - The password to login with
   */
  async login(username: string, password: string) {
    await this.usernameField.fill(username);
    await this.passwordField.fill(password);
    await this.submitButton.click();
  }
}

Team Collaboration

Coding Standards

Establish team coding standards:

  1. Consistent Naming: Follow the naming conventions in this guide
  2. Code Reviews: Review each other’s test code
  3. Pair Testing: Write tests together for complex features
  4. Shared Utilities: Create shared utilities in a central location

Knowledge Sharing

  1. Document patterns: Document common patterns your team uses
  2. Brown bag sessions: Share testing tips and tricks
  3. Retrospectives: Discuss testing challenges and solutions
  4. Mentoring: Help new team members learn playwright-elements

Code Ownership

  1. Collective ownership: The whole team owns the tests
  2. Rotation: Rotate test maintenance responsibilities
  3. Reviews: Everyone reviews test code, not just production code

CI/CD Integration

GitHub Actions Example

# .github/workflows/tests.yml
name: Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '20'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Install Playwright browsers
      run: npx playwright install --with-deps
    
    - name: Run unit tests
      run: npm run test:unit
    
    - name: Run integration tests
      run: npm run test:integration
    
    - name: Run e2e tests
      run: npm run test:e2e
      env:
        BASE_URL: $
        API_KEY: $
    
    - name: Upload test results
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: test-results
        path: test-results/

Parallel Test Execution

# Run tests in parallel across multiple jobs
jobs:
  test-unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:unit

  test-integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:integration

  test-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:e2e

Test Reporting

Use reporting tools:

# Generate HTML report
npx playwright show-report

# Or use a custom reporter
npx playwright test --reporter=line,json

Configure in playwright.config.ts:

{
  reporter: [
    ['list'],
    ['json', { outputFile: 'test-results/results.json' }],
    ['html', { outputFolder: 'test-results/html' }],
    ['junit', { outputFile: 'test-results/results.xml' }],
  ],
}

Checklist

Before Committing Tests

Before Merging PR


Additional Resources


Go to Main Page »