Defines architectural standards, design patterns, and best practices for building scalable, maintainable, and reliable end-to-end tests using Playwright for the e-micro-commerce frontend (Next.js 16+ / App Router / TypeScript / Vanilla CSS). Use whenever implementing or modifying Playwright tests, Page Objects, Component Objects, fixtures, or supporting test infrastructure inside apps/frontend. Do NOT use for unit tests or backend integration tests.
Instalação
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Defines architectural standards, design patterns, and best practices for building scalable, maintainable, and reliable end-to-end tests using Playwright for the e-micro-commerce frontend (Next.js 16+ / App Router / TypeScript / Vanilla CSS). Use whenever implementing or modifying Playwright tests, Page Objects, Component Objects, fixtures, or supporting test infrastructure inside apps/frontend. Do NOT use for unit tests or backend integration tests.
Playwright E2E Testing Skill — e-micro-commerce
Project Context
Stack: Next.js 16+ · App Router · TypeScript · Vanilla CSS
Backend: NestJS REST API at https://api.dominio.com/v1
Identity: Clerk (custom UI — no Clerk SDK components allowed in frontend)
Monorepo path: apps/frontend/
apps/frontend/src/
app/ ← Next.js App Router pages
components/ ← shared UI components
features/ ← feature-specific components
services/ ← API communication layer (only place that calls backend)
hooks/
types/
E2E tests validate user-facing workflows from the browser perspective. All business rules live in the backend — E2E tests must never try to test or replicate business logic; they validate what the user sees and can do.
When E2E Tests Are Required
E2E tests are mandatory when any of the following is true:
A new page, route, or user-facing workflow was added or modified
An authentication or authorization flow was changed (custom login/logout forms — no Clerk components)
A multi-step business workflow was affected (checkout, order creation, catalog browsing)
A frontend ↔ backend integration path changed
Core Principles
Reliability — deterministic tests that never flake
Readability — tests read like user stories
Maintainability — Page Objects isolate all UI change
Test isolation — no shared mutable state between tests
Parallel execution — no ordering dependencies
Fast execution — seed via API, not through UI flows
Prefer Playwright-native features. Never use Selenium-inspired patterns (waitForSelector, waitForTimeout, polling).
Always import test and expect from baseTest.ts — never directly from @playwright/test.
E2E tests are expensive to write and run. Reserve them for core business actions
(login, checkout, order lifecycle, admin product management) — as a rule of thumb,
core-flow E2E coverage should sit around 30% of the total test suite, with the
remainder handled by unit and component tests. Push purely visual/aesthetic checks
(spacing, font sizes, color contrast) to unit or visual-regression tooling, not Playwright
E2E specs.
Composing Component Objects for reusable fragments
A Page Object is not responsible for:
Assertions or test validation
Test data creation or API seeding
Business logic or conditional test logic
Raw DOM queries via page.evaluate()
Locator Priority
Use user-facing selectors in this order:
getByRole
getByLabel
getByPlaceholder
getByTestId
getByText
CSS selectors (last resort only)
getByRole() is the most important locator in this list — it tracks the accessibility
tree rather than markup, so it survives DOM/CSS churn and doubles as an implicit
accessibility check. If deleting an element's class attribute wouldn't break the
user experience, it shouldn't break the locator either.
Prefer chaining and .filter() to narrow a search instead of writing a single complex
selector — see "Chaining .filter()" below for the row-container rule.
Strict Mode Violations and .first() / .nth()
Never use .first() or .nth() to bypass Playwright's strict mode checks. If a locator matches multiple elements, it means the locator is not specific enough and the test is brittle. You MUST refine the locator by scoping it to a specific parent component (e.g., a card, table row, or container) or by using text filtering (.filter({ hasText: '...' })).
// ✔ Good — scoped to the specific product card or parent containerreadonly price = this.page.getByTestId('product-card-shoes').getByTestId('price');
// ✘ Bad — silences strict mode violation but makes the test unpredictablereadonly price = this.page.getByTestId('price').first();
Assertions Stay in Tests
Never use expect() inside a Page Object.
// ✔ Good — assertion in the testawait loginPage.login(user.email, user.password);
awaitexpect(catalogPage.heading).toBeVisible();
// ✘ Bad — assertion inside Page Objectasynclogin() {
awaitthis.submitButton.click();
awaitexpect(this.page).toHaveURL('/catalog'); // never do this
}
ARIA Snapshots for Whole-View Assertions
When a test needs to validate the overall structure of a view (e.g. a full order
summary panel, a dashboard sidebar) rather than one specific field, prefer
toMatchAriaSnapshot() over asserting individual DOM nodes one by one. It captures
the accessibility tree, so it catches meaningful semantic regressions (a button
becoming a link, a missing heading) while ignoring purely cosmetic CSS changes that
don't matter to a screen reader or to real users.
// ✔ Good — guards the whole region's structure resilientlyawaitexpect(orderPage.summaryPanel).toMatchAriaSnapshot();
Use this for structural/regression coverage of a region; still use targeted
toHaveText() / toBeVisible() assertions (or expect.soft(), see below) when the
test cares about a specific value.
Navigation Returns the Destination Page Object
login() is a single-responsibility action — it fills and submits. Never add navigation waits inside it. Tests that submit invalid credentials must be able to assert the page stays on /login.
// ✔ Good — single-responsibility action methodasynclogin(email: string, password: string): Promise<void> {
awaitthis.emailInput.fill(email);
awaitthis.passwordInput.fill(password);
awaitthis.submitButton.click();
// no waitForURL here — callers decide what to assert next
}
Never call page.goto() directly in a test — encapsulate it in the Page Object.
// ✔ Good — goTo() waits for meaningful content, not just HTML parseasyncgoTo(): Promise<this> {
awaitthis.page.goto('/login');
awaitexpect(this.heading).toBeVisible(); // web-first; retries until data loadsreturnthis;
}
// ✘ Bad — domcontentloaded fires before async API data loadsasyncgoTo(): Promise<void> {
awaitthis.page.goto('/login');
awaitthis.page.waitForLoadState('domcontentloaded'); // ← race condition
}
// ✘ Bad — leaks page internals into the testtest('login', async ({ page }) => {
await page.goto('/login');
});
The anchor element for goTo() must be one that only renders after the page's primary data fetch completes (e.g. a table, a heading that includes loaded data, or an input that is only mounted post-fetch).
Dialog Guard Pattern
Every page object method that opens a modal MUST await the dialog being visible before interacting with its inputs. Modal animations and React state updates are asynchronous.
// ✔ Good — guard before fillingasynccreateCategory(name: string): Promise<this> {
awaitthis.newCategoryButton.click();
awaitexpect(this.dialog).toBeVisible(); // ← guardawaitthis.nameInput.fill(name);
awaitthis.saveButton.click();
returnthis;
}
// ✘ Bad — fill() may target an element not yet in the DOMasynccreateCategory(name: string): Promise<this> {
awaitthis.newCategoryButton.click();
awaitthis.nameInput.fill(name); // race condition
}
Row-Visibility Guard Before Table-Row Actions
Every method that locates a row and clicks an action inside it MUST first assert the row is visible. After goTo() the table may still be loading its data from the API.
After any method that triggers an async API call (status transition, cancellation, delete), wait for the UI to confirm the round-trip completed — typically by waiting for the action button to disappear.
// ✔ Good — confirms API round-trip before returningasynctransitionStatus(actionName: string): Promise<this> {
const button = this.page.getByRole('button', { name: actionName });
this.page.once('dialog', dialog => dialog.accept());
await button.click();
awaitexpect(button).toBeHidden(); // ← confirms server responded and UI re-renderedreturnthis;
}
// ✔ Good — cancel confirmationasynccancelOrder(): Promise<this> {
const cancelButton = this.page.getByTestId('cancel-order-button');
await cancelButton.click();
awaitexpect(cancelButton).toBeHidden(); // ← don't return until cancellation confirmedreturnthis;
}
Confirmation Buttons — Use .click() Not press('Enter')
press('Enter') dispatches a keyboard event and requires the element to be focused. Focus can be lost to dialog overlays or animations. Always use .click() on confirmation buttons, preceded by a visibility guard.
// ✔ Goodawaitexpect(this.confirmDeleteButton).toBeVisible();
awaitthis.confirmDeleteButton.click();
// ✘ Bad — depends on focus stateawaitthis.confirmDeleteButton.press('Enter');
Authentication
Custom Forms — No Clerk SDK Components
The frontend implements its own login/logout forms. No Clerk <SignIn>, <SignUp>, <UserButton>, or equivalent components are used. E2E tests interact with those custom forms.
Cache Sessions with storageState
Avoid logging in on every test. Generate one session per role.
Use scope: 'worker' for fixtures that consume cached auth state. Never use worker scope for fixtures that mutate state.
Clearing Auth State — Always Reload After
When a test clears cookies and localStorage to simulate logout, always follow with page.reload(). Without it, Next.js's client-side router retains authenticated state in memory and subsequent navigations are served from the in-memory cache, bypassing the auth check.
// ✔ Good — forces Next.js router to re-evaluate auth from cookiesawait page.context().clearCookies();
await storefrontPage.goTo();
await page.evaluate(() =>localStorage.clear());
await page.reload(); // ← clears in-memory router state// ✘ Bad — Next.js router still thinks the user is logged inawait page.context().clearCookies();
await page.evaluate(() =>localStorage.clear());
// missing reload; redirect assertions will fail non-deterministically
Fixtures and Dependency Injection
Use test.extend — Never Instantiate Page Objects Manually
If a fixture or beforeEach setup step fails (e.g. a required seed API call returns an
unexpected status, or an environment variable is missing), call test.abort("reason")
rather than letting the test fail naturally. This marks the run as an environment/setup
problem in the report instead of a product regression, which keeps triage signal clean.
test.beforeEach(async ({ request }) => {
const health = await request.get('/v1/health');
if (!health.ok()) {
test.abort('Backend health check failed — environment is not ready for E2E run');
}
});
Test Data
Seed via API — Never via UI
Never navigate through the UI just to create prerequisite data. Call the backend API directly using Playwright's request fixture.
Every interface returned by API helpers must include all fields the test will reference. A missing field silently infers any and returns undefined at runtime — causing assertion failures that appear as locator mismatches.
// ✔ Good — all fields that tests reference are presentexportinterfaceSeededOrder {
id: string;
number: string; // e.g. "E2E-1234-5678" — displayed in UI; must be typedcustomerId: string;
totalAmount: number;
status: string;
}
// ✘ Bad — missing number field; order.number is undefined at runtimeexportinterfaceSeededOrder {
id: string;
customerId: string;
totalAmount: number;
status: string;
}
Scope Data-Dependent Actions to the Seeded Resource
Never use .first() or positional selectors on lists that may contain multiple items. Navigate directly to the seeded resource's detail or use its ID to scope the interaction.
// ✔ Good — navigates to the specific seeded product's detail pageasyncaddToCartForProduct(productId: string): Promise<this> {
awaitthis.page.goto(`/products/${productId}`);
awaitexpect(this.addToCartButton).toBeVisible();
awaitthis.addToCartButton.click();
returnthis;
}
// ✘ Bad — adds whichever product renders first; non-deterministicasyncaddToCart(): Promise<this> {
awaitthis.addToCartButton.first().click();
returnthis;
}
Unique Values
Use @faker-js/faker for all generated values. Never hardcode values that could collide across parallel workers.
Test data lifecycle should be self-contained per test (create → use → teardown via
fixture teardown, see above). For any data that can't be cleaned up per-test — e.g.
shared seed/reference data — rely on database snapshots or scheduled teardown jobs so
runs do not inherit dirty state from a previous suite execution.
Test Structure
Arrange–Act–Assert
test('customer can place an order', async ({ request, catalogPage, orderPage }) => {
// Arrangeconst product = awaitcreateProductViaApi(request, { stock: 5 });
// Actawait catalogPage.goTo();
const checkout = await test.step('add to cart and checkout', async () => {
await catalogPage.addToCart(product.name);
return catalogPage.goToCheckout();
});
// Assertawaitexpect(checkout.orderConfirmation).toBeVisible();
});
test.step() for Readability
Use test.step() whenever a test has more than two logical phases. Steps appear in the HTML report and Playwright trace viewer. Name tests and steps for the specific failure they'd surface — write them so the trace viewer reads like a sentence describing the behavior under test (e.g. "checkout charges the card and shows the order number"), not a vague phase label.
test.setTimeout — Declare at describe Scope, Never Inside the Test Body
test.setTimeout() inside the test body resets the timer from that point — not from test start — which can silently allow the test to run longer than intended.
// ✔ Good — timeout applies from the start of every test in this describe
test.describe('Order Lifecycle', () => {
test.setTimeout(60_000);
test('Admin can manage order lifecycle...', async ({ ... }) => {
// no test.setTimeout here
});
});
// ✘ Bad — timer resets mid-test; first N seconds are unguardedtest('Admin can manage order lifecycle...', async ({ ... }) => {
test.setTimeout(60_000); // too late; doesn't cover setup time
});
Use test.slow() as an alternative when you don't need a precise value — it triples the global timeout automatically.
Inline { timeout } Overrides — Remove Them
Never pass { timeout } to individual expect() calls. The global expect.timeout in playwright.config.ts already covers normal assertions. Inline overrides create inconsistency and silently shadow the global config.
// ✔ Good — trusts global configawaitexpect(dialog).toBeHidden();
// ✘ Bad — duplicates or overrides global; becomes stale when config changesawaitexpect(dialog).toBeHidden({ timeout: 5000 });
The only legitimate per-assertion timeout override is in tests that genuinely require extra time (e.g. an order lifecycle test with test.setTimeout set at the describe level).
Chaining .filter() — Always Filter the Row Container, Not a Text Node
.getByText() returns a leaf text node. Calling .filter({ hasText }) on a leaf finds nothing because the sibling text is not a descendant. Always chain .filter() calls on the container element that holds both values as children.
// ✔ Good — filter on the row containerawaitexpect(
orderPage.auditLog.getByRole('row').filter({ hasText: 'Pago' }).filter({ hasText: 'Preparação' })
).toBeVisible();
// ✘ Bad — getByText returns a leaf node; filter on a leaf never matches sibling textawaitexpect(
orderPage.auditLog.getByText('Pago').filter({ hasText: 'Preparação' }).first()
).toBeVisible();
If the list renders <div> blocks instead of <tr> rows, use getByTestId('audit-entry') or getByRole('listitem') as the container selector.
Soft Assertions
Use expect.soft() when validating multiple independent properties of a single state, so the test reports every failing field in one run instead of stopping at the first.
Use page.route() to stub flaky or slow third-party integrations (payment gateways,
shipping/weather lookups, etc.). E2E tests should validate our UI behavior, not the
reliability of a third-party service — mocking lets you assert how the frontend reacts
to specific responses (success, timeout, 5xx) deterministically, without depending on
the real provider being up. This does not replace backend integration tests — use it to
simulate edge cases in the UI only.
A missing await on an async Playwright call is one of the most common causes of
flaky tests — the test moves on before the action or assertion has actually settled.
Enforce this at the repo level rather than relying on review: add the
@typescript-eslint/no-floating-promises ESLint rule to the frontend's lint config so
floating promises fail CI before a flaky test ever lands.