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) in the KeyControl test automation framework.
## 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/, performer/, approver/, ess/).
* 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/
kc/
Admin_Group_Management_Module.feature
Performer_Task_Management_KC1.feature
step-definitions/
kc/
KeyControlAdminSteps.js
KeyControlPerformerSteps.js
page-objects/
kc/
basepage/
KeyControlAdminPage.js
KeyControlPerformerPage.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/kc/keycontrol/KeyControlAdminPage.js export class KeyControlAdminPage { constructor(page) { this.page = page;
// Pre-define locators for reuse
this.groupNameInput = page.locator('[data-ga="group-name"]');
this.saveButton = page.locator('[data-ga="save-button"]');
this.successBanner = page.locator('[data-ga="success-banner"]');
}
async navigateToGroupManagement() {
await this.page.click('[data-ga="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-ga / data-test attributes | [data-ga="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) | .kc-modal-title | | 5 | XPath | //div[@class="header"] | DO
* Use data-ga or data-test 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-ga="confirm"]'). // Semantic locator await page.getByRole('button', { name: 'Submit' }).click();
// data-ga attribute
await page.locator('[data-ga="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-ga="success-banner"]')).toBeVisible(); await expect(page.locator('[data-ga="user-count"]')).toHaveText('5'); await expect(page.locator('[data-ga="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-ga="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 },
performer: { user: process.env.PERFORMER_USER, pass: process.env.PERFORMER_PASS },
approver: { user: process.env.APPROVER_USER, pass: process.env.APPROVER_PASS },
ess: { user: process.env.ESS_USER, pass: process.env.ESS_PASS }
};
await page.goto(process.env.KEYCONTROL_URL);
await page.fill('[data-ga="username"]', creds[role].user);
await page.fill('[data-ga="password"]', creds[role].pass);
await page.click('[data-ga="login-button"]');
await expect(page.locator('[data-ga="dashboard"]')).toBeVisible();
}
Microsoft SSO
* See MICROSOFT_SSO_TESTING.md for handling Azure AD / Microsoft 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 @keycontrol @admin @group-management Feature: Admin Group Management Module
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-ga / 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.KEYCONTROL_URL || 'https://qa-keycontrol.axaxl-cloud.com',
browser: process.env.BROWSER || 'edge',
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:
npx run generate:report # Generate Allure report
npx 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 GitHub Actions 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 (Azure Key Vault, GitHub 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 — the project already uses "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
* The framework targets Microsoft Edge (Chromium) as the primary browser.
* Add firefox and webkit project entries in playwright.config.js for critical smoke tests when cross-browser coverage is required:
projects: [
{ name: 'edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
{ 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-ga 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
* MICROSOFT_SSO_TESTING.md
* CI_HARNESS.md