Loading...
Loading...
Playwright E2E testing best practices. Use when writing browser tests, visual regression, or accessibility tests in any project. Enforces user-facing locators, auto-waiting, web-first assertions, and Page Object Model.
npx skill4agent add lgtm-hq/ai-skills test-uitest-ui-qsf// BEST: Semantic locators
page.getByRole("button", { name: "Submit" });
page.getByRole("tab", { name: "Dashboard" });
page.getByLabel("Email");
page.getByPlaceholder("Search...");
page.getByText("Welcome");
page.getByTitle("Document title");
// GOOD: Test IDs (attribute set by testIdAttribute in playwright.config.ts)
page.getByTestId("delete-row-btn");
// ACCEPTABLE: CSS locators for structural queries
page.locator('input[type="file"][multiple]');
// AVOID: Fragile CSS selectors
page.locator("#submit-btn");
page.locator("div > button.primary");waitForTimeout()// WRONG: Manual timeouts
await page.waitForTimeout(1000);
await button.click();
// CORRECT: Auto-waiting assertions
await expect(button).toBeVisible();
await button.click();
// CORRECT: Poll for async state
await expect
.poll(async () => page.evaluate(() => localStorage.getItem("theme")))
.toBe("dark");
// CORRECT: Wait for specific conditions
await page.waitForLoadState("networkidle");
await page.waitForURL(/\/dashboard/);
// ACCEPTABLE: toPass() for polling complex async operations
await expect(async () => {
await dashboardPage.open();
await dashboardPage.assertPresent();
}).toPass({ timeout: 30_000 });// CORRECT: Web-first (auto-retries)
await expect(page.getByRole("heading")).toHaveText("Dashboard");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(button).toBeEnabled();
// CORRECT: Page object assertion methods
await dashboardPage.assertSelectedCheckboxes(["Odd", "Free"]);
// AVOID: Manual checks (no retry)
const text = await heading.textContent();
expect(text).toBe("Dashboard");BasePage → SpecializedPage// pageObjects/BasePage.ts — all pages extend this
export default class BasePage {
public navigation = new Navigation(this.page);
constructor(readonly page: Page) {}
async openTab(tabName: string): Promise<void> {
await this.page.getByRole("tab", { name: tabName }).click();
}
async assertUrl(path: string | RegExp): Promise<void> {
await expect(this.page).toHaveURL(path);
}
}
// pageObjects/DetailPage.ts — specialized page
export default class DetailPage extends BasePage {
public fileUploadComponent = new FileUpload(this.page);
readonly errorLabel: Locator = this.page.getByRole("alert");
async assertError(message?: string): Promise<void> {
await expect(this.errorLabel).toBeVisible();
if (message) await expect(this.errorLabel).toHaveText(message);
}
}readonlyassertPromise<void>this.navigationthis.fileUploadComponentforEachbeforeEachconsole.logtest.step()// BAD: shared array mutated across iterations — creates order dependency
const passedTests: string[] = [];
for (const tc of testcases) {
test(`test ${tc}`, async () => {
passedTests.push(tc);
});
}
// GOOD: each test is self-contained
testcases.forEach((tc) => {
test(`test ${tc}`, async ({ page }) => {
// no shared mutable state
});
});// BAD: only checks the element exists — passes even if broken
await detailPage.assertBadgeVisible("Theme");
// GOOD: verify content and interaction
await detailPage.assertBadgeText("Theme", "Default");
await detailPage.selectBadge("Theme", "Dark");
await detailPage.assertBadgeText("Theme", "Dark");// Mock API responses
await page.route("**/api/user", (route) => {
route.fulfill({ json: { name: "Test User" } });
});
// Simulate failures
await page.route("**/*.css", (route) => route.abort("failed"));
// Cleanup after test — unroute every mock you registered
await page.unroute("**/api/user");
await page.unroute("**/*.css");getByRolegetByTestIdgetByLabelgetByTitlewaitForTimeout()expect()readonlyassert*forEach