DEV Community

Shell QA
Shell QA

Posted on

Playwright JavaScript Framework Best Practices

Playwright JavaScript Framework — Best Practices
A comprehensive guide for writing reliable, maintainable, and scalable end-to-end tests using Playwright with JavaScript and Cucumber BDD.
Table of Contents

  • Project Structure & Organization
  • Page Object Model (POM)
  • Selectors & Locators
  • Assertions
  • Waiting Strategies
  • Test Isolation & State Management
  • Authentication & Login
  • Test Data Management
  • BDD / Cucumber Integration
  • Error Handling & Debugging
  • Retries & Flakiness
  • Parallelism & Performance
  • Configuration Management
  • Reporting & Observability
  • CI/CD Integration
  • Security & Secrets
  • Code Quality & Maintainability
  • Accessibility & Cross-Browser Testing
    1. Project Structure & Organization DO
  • Keep a flat, predictable folder structure that mirrors the application's domain (e.g., admin/, user/, billing/, reports/).
  • Co-locate feature files, step definitions, and page objects by module/domain so related code is easy to find.
  • Use index.js barrel exports to avoid long relative import paths.
  • Store all environment-specific configuration in a single config.js at the root; never hardcode URLs or credentials inside test files. DON'T
  • Don't scatter page objects and step definitions randomly across the project.
  • Don't mix UI concerns with business logic in the same file. Recommended Layout features/ modules/ Admin_Group_Management.feature User_Task_Management.feature

step-definitions/
modules/
AdminSteps.js
UserSteps.js

page-objects/
modules/
basepage/
AdminPage.js
UserPage.js

utils/
logger.js
ExcelHelper.js

setup/
hooks.js
assertions.js
config.js

  1. Page Object Model (POM)
    DO

    • Encapsulate all page interactions (clicks, fills, navigations) inside dedicated Page Object classes.
    • Keep page objects thin — they should only expose methods, not assertions.
    • Compose complex pages from smaller component objects (e.g., TableComponent, ModalComponent).
    • Accept the Playwright page instance via the constructor; never create a new browser context inside a POM. // Good — page-objects/modules/AdminPage.js export class AdminPage { constructor(page) { this.page = page;

    // Pre-define locators for reuse
    this.groupNameInput = page.locator('[data-test="group-name"]');
    this.saveButton = page.locator('[data-test="save-button"]');
    this.successBanner = page.locator('[data-test="success-banner"]');
    }

async navigateToGroupManagement() {
await this.page.click('[data-test="group-management"]');
}

async createGroup(groupData) {
await this.groupNameInput.fill(groupData.name);
await this.saveButton.click();
}
}

DON'T

  • Don't put expect() assertions inside page objects — keep them in step definitions or test files.
  • Don't duplicate selectors across multiple files; define them once in the page object.
    1. Selectors & Locators Priority Order (most preferred → least preferred) | Priority | Strategy | Example | |---|---|---| | 1 | data-test / custom test data attributes | [data-test="save-button"] | | 2 | ARIA roles & labels | page.getByRole('button', { name: 'Save' }) | | 3 | Playwright built-in locators | page.getByLabel('Username') | | 4 | CSS class (stable, non-generated) | .modal-title | | 5 | XPath | //div[@class="header"] | DO
  • Use data-test or data-qa attributes — they are immune to styling and structural changes.
  • Use Playwright's semantic locators (getByRole, getByLabel, getByText, getByPlaceholder) for readability and resilience.
  • Define locators as class properties in page objects to avoid string duplication.
  • Use chaining to scope locators: page.locator('.modal').locator('[data-test="confirm"]'). // Semantic locator await page.getByRole('button', { name: 'Submit' }).click();

// custom data-test attribute
await page.locator('[data-test="group-name"]').fill('Automation Group');

DON'T

  • Don't use auto-generated class names (div.sc-abc123) or positional XPaths (/div[3]/span[1]).
  • Don't use page.$() (legacy Playwright API) — always use page.locator().
    1. Assertions DO
  • Always use Playwright's built-in expect — it has automatic retry, built-in timeouts, and clear error messages.
  • Prefer web-first assertions that wait for the UI state to match: // Web-first assertions (auto-retry) await expect(page.locator('[data-test="success-banner"]')).toBeVisible(); await expect(page.locator('[data-test="user-count"]')).toHaveText('5'); await expect(page.locator('[data-test="save-button"]')).toBeEnabled();

// Use soft assertions when you want to collect multiple failures in one test run:
const softExpect = expect.configure({ soft: true });
await softExpect(heading).toHaveText('Dashboard');
await softExpect(logo).toBeVisible();
// All soft assertion failures are reported at the end

DON'T

  • Don't use page.isVisible() in if statements as a substitute for assertions.
  • Don't hard-code waitForTimeout before an assertion — let expect do the waiting.
    1. Waiting Strategies DO
  • Rely on Playwright's auto-waiting — most click, fill, and expect operations auto-wait for elements to be actionable.
  • Use waitForSelector or waitForResponse only for specific async operations not covered by auto-waiting.
  • Wait for network responses when actions trigger API calls: // Wait for API response after action const [response] = await Promise.all([ page.waitForResponse(resp => resp.url().includes('/api/groups') && resp.status() === 200), page.locator('[data-test="save-button"]').click() ]);

// Use page.waitForLoadState('networkidle') only for pages with complex background requests.

DON'T

  • Never use arbitrary page.waitForTimeout(3000) — this is a top cause of slow, flaky tests.
  • Don't poll visibility in a loop; use expect(...).toBeVisible({ timeout: 10000 }) instead.
    1. Test Isolation & State Management DO
  • Each Cucumber scenario must be fully independent — it should not rely on state left by a previous scenario.
  • Use Before / After hooks in setup/hooks.js to:
    • Create a fresh browser context per scenario.
    • Navigate to a known starting page.
    • Clean up created test data after each scenario. // setup/hooks.js Before(async function () { this.context = await browser.newContext(); this.page = await this.context.newPage(); });

After(async function (scenario) {
if (scenario.result.status === 'FAILED') {
await this.page.screenshot({ path: reports/screenshots/${scenario.pickle.name}.png });
}
await this.context.close();
});

// Use tagged hooks to apply setup only to relevant scenarios:
Before({ tags: '@admin' }, async function () {
await loginAsAdmin(this.page);
});

DON'T

  • Don't share page instances or logged-in sessions across unrelated scenarios.
  • Don't depend on execution order — scenarios should be runnable in any order.
    1. Authentication & Login DO
  • Reuse authenticated state using Playwright's storageState to avoid repeating login for every scenario: // Save auth state once await page.context().storageState({ path: 'setup/auth-state.json' });

// Reuse in playwright.config.js
use: {
storageState: 'setup/auth-state.json'
}

// Store credentials only in environment variables — never in code or feature files.
// Use a dedicated loginAsRole utility function to support multiple user roles cleanly.

// utils/auth.js
export async function loginAs(page, role) {
const creds = {
admin: { user: process.env.ADMIN_USER, pass: process.env.ADMIN_PASS },
user: { user: process.env.STANDARD_USER, pass: process.env.STANDARD_PASS },
approver: { user: process.env.APPROVER_USER, pass: process.env.APPROVER_PASS }
};

await page.goto(process.env.APP_BASE_URL);
await page.fill('[data-test="username"]', creds[role].user);
await page.fill('[data-test="password"]', creds[role].pass);
await page.click('[data-test="login-button"]');
await expect(page.locator('[data-test="dashboard"]')).toBeVisible();
}

Enterprise Single Sign-On (SSO)

  • Maintain a dedicated document (e.g., SSO_TESTING.md) for handling Identity Provider (IdP) login flows.
  • Mock or bypass SSO in lower environments whenever possible to speed up test execution.
    1. Test Data Management DO
  • Keep test data separate from test logic — store in test-data/json/ or test-data/excel/.
  • Use unique data per run (e.g., timestamps, UUIDs) to prevent collisions when tests run in parallel. const groupName = AutoGroup_${Date.now()};

// Use factory functions to generate test data objects:
// utils/dataFactory.js
export function createGroupPayload(overrides = {}) {
return {
name: AutoGroup_${Date.now()},
description: 'Generated by automation',
type: 'standard',
...overrides
};
}

// Clean up all data created during a test in the After hook.

DON'T

  • Don't hardcode test data (names, IDs, dates) inside step definitions.
  • Don't leave orphaned test data in shared environments — it causes noise for manual testers.
    1. BDD / Cucumber Integration Feature File Best Practices
  • Write scenarios from the user's perspective using Given / When / Then.
  • One scenario = one behavior. Don't write "super scenarios" that test 10 things at once.
  • Use Background for common pre-conditions, not complex setup logic.
  • Use tags consistently to allow selective execution: @smoke @regression @admin @group-management Feature: Admin Group Management

Background:
Given I am logged in as an admin

@create-group
Scenario: Admin creates a new group
When I navigate to Group Management
And I create a group with name "AutoGroup"
Then the group "AutoGroup" should appear in the list

Step Definition Best Practices

  • Keep steps atomic and reusable across scenarios.
  • Use World object (this) to share state between steps within a scenario — never use module-level globals.
  • Avoid logic-heavy step definitions; delegate to page objects.
  • Thin step, rich page object:
    When('I create a group with name {string}', async function (name) {
    await this.adminPage.createGroup(name);
    });

  • Use Cucumber Data Tables and Doc Strings for structured input data.

    1. Error Handling & Debugging DO
  • Enable screenshots on failure in After hooks (see Section 6).

  • Enable video recording for CI runs to replay failures:
    // playwright.config.js
    use: {
    video: 'retain-on-failure',
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure'
    }

  • Use the Playwright Trace Viewer (npx playwright show-trace trace.zip) to inspect failing steps.

  • Use the built-in logger (utils/logger.js) instead of console.log for structured output.

  • Add meaningful step names so traces and reports are human-readable.
    Debugging Locally

    Run in headed mode with Playwright Inspector

    PWDEBUG=1 npx playwright test

Run a single scenario by tag

npx test --tags="@create-group"

Slow down execution for visual debugging

npx playwright test --headed --slowmo=500

  1. Retries & Flakiness Retry Policy
    • Set retries: 1 (max 2) in playwright.config.js for CI — enough to handle transient network issues, not enough to hide real bugs.
    • Never increase retries as a fix for a broken test — find and fix the root cause. // playwright.config.js retries: process.env.CI ? 1 : 0,

Flakiness Thresholds
| Level | Threshold | Action |
|---|---|---|
| Acceptable | < 1% over 7 days | Monitor |
| Warning | 1% – 5% | Investigate & RCA |
| Critical | > 5% | Block release, escalate |
Common Flakiness Causes & Fixes
| Cause | Fix |
|---|---|
| waitForTimeout | Replace with expect or waitForResponse |
| Fragile selectors | Switch to data-test / ARIA locators |
| Shared state | Isolate each scenario (see Section 6) |
| Race conditions | Use Promise.all + network wait |
| Dynamic content | Use toBeVisible({ timeout }) instead of hard-wait |

  1. Parallelism & Performance DO
    • Start with workers: 2 in CI and increase only after flakiness stabilizes below 1%.
    • Use Playwright sharding to distribute tests across CI runners: npx playwright test --shard=1/3 npx playwright test --shard=2/3 npx playwright test --shard=3/3
  • Group slow scenarios (e.g., approval workflows) with @slow tags and run them separately.
  • Use storageState to avoid redundant logins (see Section 7). DON'T
  • Don't share a single browser context between parallel workers.
  • Don't run all scenarios in parallel before verifying they are properly isolated.
    1. Configuration Management DO
  • Use a single config.js as the source of truth for all configuration values.
  • Override config values via environment variables — never change the config file between environments.
  • Use .env files for local development; inject secrets via CI environment variables in pipelines.
    // config.js
    export const config = {
    baseUrl: process.env.APP_BASE_URL || 'https://qa-environment.example.com',
    browser: process.env.BROWSER || 'chromium',
    headless: process.env.HEADLESS !== 'false',
    defaultTimeout: Number(process.env.DEFAULT_TIMEOUT) || 120000,
    actionTimeout: Number(process.env.ACTION_TIMEOUT) || 30000,
    };

  • See ENV_SETUP.md for full environment variable documentation.
    DON'T

  • Don't commit .env files with real credentials to source control.

  • Don't hardcode environment URLs in test files — always reference config.js.

    1. Reporting & Observability DO
  • Use Allure as the primary report for rich test history, attachments, and trend analysis.

  • Attach screenshots, videos, and traces to Allure on failure automatically via hooks.

  • Add Allure labels to enrich reports with metadata:
    // in step definitions
    this.allure.attachment('Response Body', JSON.stringify(responseBody, null, 2), 'application/json');

  • Generate reports as part of every CI pipeline run:
    npm run generate:report # Generate Allure report
    npm run open:report # Open in browser locally

  • Maintain a weekly flakiness dashboard from Allure history data and share with the team.
    Report Locations
    | Type | Location |
    |---|---|
    | Allure HTML | allure-report/ |
    | Allure Results | allure-results/ |
    | JSON Report | reports/cucumber_report.json |
    | Screenshots | reports/screenshots/ |
    | Videos | test-results/ |

    1. CI/CD Integration DO
  • Run tests in headless mode in CI:
    npm run test:ci

  • Fail the pipeline on any test failure — don't silently ignore failed tests.

  • Archive Allure results and test-results as CI artifacts for post-run analysis.

  • Run linting (eslint) and type checks before executing tests in the pipeline.

  • Use branch-specific tags to run only smoke tests on PRs and full regression on main:

    Example CI step

    • name: Run Smoke Tests (PR) run: npm test -- --tags "@smoke" if: github.event_name == 'pull_request'
    • name: Run Full Suite (Main) run: npm test if: github.ref == 'refs/heads/main'

DON'T

  • Don't run tests against production environments from CI without explicit approval gates.
  • Don't skip report generation in CI — reports are essential for debugging failures.
    1. Security & Secrets DO
  • Store all credentials in environment variables or a secrets manager (e.g., Vault, CI Secrets).
  • Reference secrets via process.env.VAR_NAME — never hardcode them.
  • Use .gitignore to exclude .env, auth-state.json, and any file that may contain tokens.
  • Rotate test user passwords regularly and update secrets in the CI store.
  • See SECRETS.md for detailed secrets management guidelines. DON'T
  • Don't log credentials or tokens to the console or report output.
  • Don't commit auth-state.json (contains session tokens) to source control.
  • Don't use personal accounts as test users — use dedicated service accounts.
    1. Code Quality & Maintainability DO
  • Enable ESLint with a consistent style guide (e.g., Airbnb or StandardJS).
  • Use ES Modules (import/export) consistently — configure "type": "module" in package.json.
  • Use async/await everywhere — avoid mixing Promises and callbacks.
  • Keep functions small and single-purpose (\le 20 lines is a good target).
  • Write JSDoc comments for all page object methods and utilities.
  • Review test code in pull requests — test code is production code. /**
  • Creates a new group with the given data.
  • @param {Object} groupData
  • @param {string} groupData.name
  • @param {string} [groupData.description] */ async createGroup(groupData) { await this.groupNameInput.fill(groupData.name); await this.saveButton.click(); await expect(this.successBanner).toBeVisible(); }

DON'T

  • Don't leave commented-out code or console.log statements in committed code.
  • Don't duplicate step definitions — extract shared steps into a common-steps.js.
  • Don't use var — always use const or let.
    1. Accessibility & Cross-Browser Testing Accessibility
  • Prefer ARIA roles and labels as primary selectors — this naturally validates accessibility.
  • Run @axe-core/playwright checks on key pages as part of accessibility regression: import { checkA11y } from 'axe-playwright';

await checkA11y(page, undefined, {
runOnly: ['wcag2a', 'wcag2aa']
});

Cross-Browser

  • Configure modern browsers (chromium, firefox, webkit) in playwright.config.js:
    projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } }
    ]

  • Run cross-browser tests on a schedule (e.g., nightly), not on every PR, to keep PR pipelines fast.
    Quick Reference Checklist
    Use this checklist before merging new tests:

  • Tests are isolated — no shared state between scenarios

  • data-test or ARIA locators used — no fragile XPath/CSS

  • No waitForTimeout calls

  • Assertions use Playwright's expect (web-first)

  • Credentials are in environment variables, not hardcoded

  • Test data uses unique identifiers (timestamp/UUID)

  • Screenshots/video capture is configured on failure

  • Feature file has correct tags for selective execution

  • Page objects contain no assertions

  • Code reviewed and ESLint passes
    References

  • Playwright Official Documentation

  • Playwright Best Practices

  • Cucumber.js Documentation

  • Allure Playwright Integration

  • ENV_SETUP.md

  • SELECTORS.md

  • FLAKINESS.md

  • SECRETS.md

  • SSO_TESTING.md

  • CI_HARNESS.md

Top comments (0)