Deeper treatment of the patterns introduced in E2E Testing. Start there for the dual-architecture overview, the data-testid naming convention, and the auth-setup flow; this doc focuses on the finer-grained rules that make specs reliable and easy to maintain.
Always choose the most specific, least brittle selector. In priority order:
Survives copy, layout, styling, and library changes. Every LFX One component exposes its own data-testid surface, so feature specs should never reach below it with CSS selectors.
// profile-identities-verify.spec.ts
await expect(page.getByTestId('identity-row-idf-2')).toContainText('jdoe@company.org');getByRole, getByLabel, and getByPlaceholder read well and exercise accessibility metadata at the same time. Use them for form labels, buttons with meaningful ARIA names, and headings.
await expect(page.getByRole('button', { name: 'Open filter options' })).toBeVisible();
await expect(page.getByLabel('Project Name')).toBeFocused();Acceptable for copy that is itself part of the assertion (empty states, status tags). Don't use it as a primary locator for interactive elements — translations and copy changes break tests that don't need to.
await expect(page.getByText('No badges yet')).toBeVisible(); // ✓ asserting empty-state copyOnly reach for raw CSS when querying a set of data-testids sharing a prefix:
// badges-dashboard.spec.ts
const firstCard = page.locator('[data-testid^="badge-card-"]').first();Avoid these outright:
// ❌ Brittle: Tailwind classes change frequently
await expect(page.locator('.bg-blue-500.text-white')).toBeVisible();
// ❌ Fragile: DOM structure changes with refactors
await expect(page.locator('div > div:nth-child(2) > span')).toBeVisible();
// ❌ Non-specific: will match too much
await expect(page.locator('button')).toBeVisible();expect(locator).toBeVisible() and locator.click() already wait for the element to be actionable. Don't wrap them in waitForSelector or manual timers.
// ✓ auto-waits
await expect(page.getByTestId('badges-grid')).toBeVisible({ timeout: 30_000 });
// ✗ redundant
await page.waitForSelector('[data-testid="badges-grid"]');
await expect(page.getByTestId('badges-grid')).toBeVisible();When a page can legitimately render in one of two valid shapes, don't branch — assert the union:
// badges-dashboard.spec.ts
const grid = page.getByTestId('badges-grid');
const emptyState = page.getByTestId('badges-empty-state-card');
await expect(grid.or(emptyState)).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });page.waitForLoadState('networkidle') is useful for pages with heavy initial fetches, but it hangs on endpoints with persistent connections (SSE, WebSockets, long-poll). Prefer asserting on a specific "ready" data-testid instead:
// ✓ deterministic
await expect(page.getByTestId('profile-identities')).toBeAttached();
// ⚠ hangs if the dashboard opens an SSE stream
await page.waitForLoadState('networkidle');// ❌ brittle and slow
await page.waitForTimeout(3000);
// ✓ wait for a real signal
await expect(page.getByTestId('badges-grid')).toBeVisible();Declare per-spec constants for anything longer than Playwright's defaults and reuse them across the file. This matches badges-dashboard.spec.ts:
const BADGES_URL = '/badges';
const DATA_LOAD_TIMEOUT = 30_000;
test.setTimeout(60_000);
test('shows badge grid or empty state after loading', async ({ page }) => {
await expect(page.getByTestId('badges-grid').or(page.getByTestId('badges-empty-state-card'))).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
});Tuning a single constant at the top of the file is easier than chasing literals, and a short Playwright default (5000ms) is almost always wrong for data-fetching pages.
Follow [section]-[component]-[element] from the root down:
profile-identities # root section
unverified-identities-section # subsection
identity-row-idf-2 # row (dynamic id)
verify-btn-idf-2 # action (dynamic id)
Don't bury an element's data-testid inside an ancestor-specific prefix if the element itself is reused elsewhere — prefer the element-level data-testid and chain locators if you need scoping:
// ✓ chain locators
const section = page.getByTestId('unverified-identities-section');
await expect(section.getByTestId('identity-row-idf-2')).toBeVisible();
// ✗ proliferates test IDs
await expect(page.getByTestId('unverified-identities-section-identity-row-idf-2')).toBeVisible();When a list needs per-item assertions, encode the stable identifier in the data-testid rather than relying on nth-child:
<!-- template pattern -->
<div [attr.data-testid]="'identity-row-' + identity.id">
<button [attr.data-testid]="'verify-btn-' + identity.id">Verify</button>
</div>This is what makes profile-identities-verify-robust.spec.ts able to assert presence across five rows without hardcoding position:
const ids = ['idf-1', 'idf-2', 'idf-3', 'idf-5', 'idf-6'];
for (const id of ids) {
await expect(page.getByTestId(`identity-row-${id}`)).toBeAttached();
}If you need a "group name" for a set of elements (e.g. all status tags in a table), encode it in an additional data-* attribute, not a CSS class. Tests can match on the attribute without coupling to the stylesheet.
<span [attr.data-testid]="'status-tag-' + row.id" [attr.data-status]="row.status">{{ row.statusLabel }}</span>Use page.route() to stub API responses so you can assert error paths without needing the backend to fail. This is the canonical pattern from badges-dashboard.spec.ts:
test.describe('Badges Dashboard error state', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/badges', (route) => route.fulfill({ status: 500, body: 'Internal Server Error' }));
await page.goto('/badges', { waitUntil: 'domcontentloaded' });
});
test('error state renders when API fails', async ({ page }) => {
await expect(page.getByTestId('badges-error-state-card')).toBeVisible();
});
});Scope the stub to the specific endpoint (glob) that the page calls. Stubbing broader patterns (**/api/**) can mask bugs in unrelated fetches.
Put everything that a spec needs to know the page is ready in a single beforeEach:
// profile-identities-verify-robust.spec.ts
test.beforeEach(async ({ page }) => {
await page.goto('/profile/identities', { waitUntil: 'domcontentloaded' });
await expect(page).not.toHaveURL(/auth0\.com/);
await expect(page.getByTestId('unverified-identities-section').or(page.getByTestId('verified-identities-section'))).toBeVisible({ timeout: 10000 });
});Three things happen in order: navigation, auth sanity check, ready-state wait. Don't repeat these in every test.
Group by screen first, then by concern. The profile-identities-verify-robust file models this well:
test.describe('Identities Verify Flow - Robust Tests', () => {
test.describe('Data-testid presence', () => {
test('should have root container with grid class', ...);
test('should have unverified-identities-section', ...);
});
test.describe('Section structure', () => {
test('should have 2 rows in unverified section', ...);
test('should have 3 rows in verified section', ...);
});
test.describe('Row actions', () => {
test('should have verify-btn `data-testid`s only on unverified rows', ...);
});
});Each inner describe maps to one user-facing concern, which keeps failures easy to attribute.
- Asserting before navigation settles.
page.goto()returns when the HTTP response lands, not when Angular has rendered. Always chain anexpect(...).toBeVisible()on a "ready"data-testidbefore other assertions. - Relying on test order. Tests run in parallel by default. Don't write "test 2 depends on test 1 having created a record." Each test should set up its own state.
- Hardcoding response bodies in assertions.
expect(row).toContainText('jdoe@company.org')is fine for fixture-backed specs; for real-API specs, assert on a shape or pattern instead. - Over-mocking. The purpose of E2E is to exercise integration. Mock only the endpoint you're trying to failover — leave everything else live against the dev server.
- Forgetting the Auth0 check. If a spec renders with "Sign in" visible, global-setup didn't pin auth state and the entire test is validating the login page. Every
beforeEachshould haveawait expect(page).not.toHaveURL(/auth0\.com/);as its second line.
- E2E Testing — the starting point: dual architecture,
data-testidconventions, auth setup, current specs in the tree. - Playwright Locators — the built-in selector API.
- Playwright Assertions — the auto-waiting
expectmatchers.