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
- 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
-
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.
- 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().
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 LocallyRun 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
- 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 |
- 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.
- 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'TDon't commit .env files with real credentials to source control.
-
Don't hardcode environment URLs in test files — always reference config.js.
- 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/ |- CI/CD Integration DO
Run tests in headless mode in CI:
npm run test:ciFail 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.
- 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.
- 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.
- 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
ReferencesPlaywright 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)