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 - 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. - 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(). - 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. - 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. - 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 }, 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. - 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 @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. - Error Handling & Debugging DO Enable screenshots on failure in After hooks (see Section 6). Enable video recording for CI runs to replay failu
Comments
No comments yet. Start the discussion.