skills/frontend/e2e-user-journeys/SKILL.md
Use when implementing critical user workflows that span multiple pages/components - tests complete journeys end-to-end using Page Object Model, user-centric selectors, and condition-based waiting; use sparingly (10-15% of tests)
npx skillsauth add bacchus-labs/wrangler e2e-user-journeysInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
End-to-end (E2E) tests verify complete user workflows from start to finish, including multiple pages, API requests, and database state.
When to use this skill:
When NOT to use:
E2E TESTS ONLY FOR CRITICAL USER JOURNEYS
Rule of thumb: If manual QA would test it end-to-end, automate it at E2E level. Otherwise, test at lower level.
Target: E2E tests should be 10-15% of total test suite (not more).
Critical user workflows:
Cross-page workflows:
Third-party integrations:
Business-critical flows:
Decision Tree:
Is this a complete user workflow?
├─ YES → Continue
│ ├─ Is it business-critical?
│ │ ├─ YES → E2E test appropriate
│ │ └─ NO → Could this be component test?
│ └─ NO → Use component or unit test
└─ NO → Use component or unit test
Encapsulate page interactions in reusable classes:
Benefits:
Pattern:
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
// Locators (encapsulated)
private get emailInput() {
return this.page.locator('[name="email"]');
}
private get passwordInput() {
return this.page.locator('[name="password"]');
}
private get submitButton() {
return this.page.locator('button[type="submit"]');
}
// High-level actions (domain language)
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async getErrorMessage(): Promise<string | null> {
const alert = this.page.locator('[role="alert"]');
return await alert.textContent();
}
async isLoggedIn(): Promise<boolean> {
return await this.page.locator('[data-testid="user-menu"]').isVisible();
}
}
// tests/login.spec.ts
test('user can log in with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
expect(await loginPage.isLoggedIn()).toBe(true);
await expect(page).toHaveURL('/dashboard');
});
test('shows error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrongpassword');
const error = await loginPage.getErrorMessage();
expect(error).toContain('Invalid credentials');
});
Benefits visible:
Select elements the way users find them:
// 1. BEST: Accessible selectors (what screen readers see)
page.getByRole('button', { name: 'Submit' });
page.getByRole('heading', { name: 'Welcome' });
page.getByLabel('Email address');
// 2. GOOD: Semantic selectors (visible text)
page.getByText('Click here to continue');
page.getByPlaceholder('Enter your name');
page.getByAltText('Company logo');
// 3. ACCEPTABLE: Test IDs (when no semantic option)
page.locator('[data-testid="checkout-form"]');
// 4. NEVER: Implementation details (brittle)
page.locator('.btn-primary-lg-v2'); // CSS classes
page.locator('div > div > button:nth-child(3)'); // Element hierarchy
page.locator('#component-instance-xyz'); // Internal IDs
Resilience:
Accessibility verification:
Readability:
// ❌ BAD: CSS selectors (brittle)
await page.click('.btn-primary.btn-lg');
// ✅ GOOD: Accessible selector (resilient)
await page.click('button[name="submit"]');
// ❌ BAD: Element hierarchy (brittle)
await page.fill('div.form > div:nth-child(2) > input');
// ✅ GOOD: Semantic selector (resilient)
await page.fill('input[name="email"]');
// OR
await page.getByLabel('Email address').fill('[email protected]');
Always wait for conditions, not arbitrary times:
// ❌ BAD: Guessing at timing
await page.click('button');
await page.waitForTimeout(500); // Hope response in 500ms
const result = await page.textContent('.result');
Problems:
// ✅ GOOD: Wait for condition
await page.click('button');
await page.waitForSelector('.result'); // Wait until appears
const result = await page.textContent('.result');
// ✅ BETTER: Wait for specific state
await page.click('button');
await page.waitForResponse(resp => resp.url().includes('/api/submit'));
await page.waitForSelector('.result:has-text("Success")');
Playwright:
await page.waitForSelector('.element');
await page.waitForResponse(resp => resp.url().includes('/api'));
await page.waitForFunction(() => document.querySelector('.element'));
Selenium:
import { until } from 'selenium-webdriver';
await driver.wait(until.elementLocated(By.css('.element')));
await driver.wait(until.elementIsVisible(element));
Cypress:
cy.get('.element').should('be.visible'); // Built-in retry
cy.intercept('/api/data').as('getData');
cy.wait('@getData');
See: condition-based-waiting skill for comprehensive guidance.
// ✅ GOOD: Reusable test data factory
async function createTestUser(overrides = {}) {
return await db.users.create({
email: `test-${Date.now()}@example.com`,
password: 'password123',
name: 'Test User',
...overrides
});
}
// Each test creates its own data
test('user can update profile', async ({ page }) => {
const user = await createTestUser();
// ... test using user
await loginPage.login(user.email, user.password);
// Cleanup
await db.users.delete(user.id);
});
Each test must be independent:
// ✅ GOOD: Each test sets up and tears down
test('test A', async () => {
const user = await createTestUser();
// ... test
await cleanup(user);
});
test('test B', async () => {
const user = await createTestUser();
// ... test
await cleanup(user);
});
// ❌ BAD: Tests share data (flaky)
const sharedUser = await createTestUser();
test('test A', async () => {
// Uses sharedUser - what if test B modified it?
});
// ✅ GOOD: Unique data per test
email: `test-${Date.now()}-${Math.random()}@example.com`
// ✅ GOOD: Use UUIDs
import { randomUUID } from 'crypto';
email: `test-${randomUUID()}@example.com`
test('user can complete checkout', async ({ page }) => {
// ARRANGE: Set up test data
const user = await createTestUser();
const product = await createTestProduct();
// ACT: Perform user workflow
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(user.email, user.password);
const productPage = new ProductPage(page);
await productPage.goto(product.id);
await productPage.addToCart();
const checkoutPage = new CheckoutPage(page);
await checkoutPage.goto();
await checkoutPage.fillShippingInfo({
address: '123 Main St',
city: 'Seattle',
zip: '98101'
});
await checkoutPage.fillPaymentInfo({
cardNumber: '4242424242424242',
expiry: '12/25',
cvc: '123'
});
await checkoutPage.submitOrder();
// ASSERT: Verify outcome
await expect(page.locator('[data-testid="order-confirmation"]'))
.toContainText('Order placed successfully');
// Verify database state
const order = await db.orders.findByUserId(user.id);
expect(order.status).toBe('confirmed');
expect(order.total).toBe(product.price);
// CLEANUP: Remove test data
await db.orders.delete(order.id);
await db.users.delete(user.id);
await db.products.delete(product.id);
});
Mock external services, not your own API:
// ✅ GOOD: Mock external payment provider
test('handles payment failure', async ({ page }) => {
await page.route('https://api.stripe.com/v1/charges', route => {
route.fulfill({
status: 400,
body: JSON.stringify({
error: { message: 'Card declined' }
})
});
});
// ... attempt checkout
await expect(page.locator('[role="alert"]'))
.toContainText('Payment failed: Card declined');
});
// ❌ BAD: Mocking your own API (defeats purpose)
test('shows user profile', async ({ page }) => {
await page.route('/api/users/123', route => {
route.fulfill({ body: JSON.stringify({ name: 'Alice' })});
});
// Not testing real API integration!
});
import { test, expect } from '@playwright/test';
test('user signup flow', async ({ page }) => {
await page.goto('/signup');
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', 'password123');
await page.fill('[name="confirmPassword"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/verify-email');
await expect(page.locator('h1')).toContainText('Check your email');
});
import { Builder, By, until } from 'selenium-webdriver';
test('user signup flow', async () => {
## References
For detailed information, see:
- `references/detailed-guide.md` - Complete workflow details, examples, and troubleshooting
tools
Use when creating technical specifications for features, systems, or architectural designs. Creates comprehensive specification documents using the Wrangler MCP issue management system with proper structure and completeness checks.
testing
Creates and refines agent skills using TDD methodology with pressure testing and rationalization detection. Use when creating new skills, editing existing skills, testing skills with pressure scenarios, or verifying skills work before deployment.
tools
Use when design is complete and you need detailed implementation tasks - creates tracked MCP issues with exact file paths, complete code examples, and verification steps. Optional reference plan file for architecture overview.
development
Validates governance file completeness, format compliance, and metric accuracy. Use when auditing governance health, after bulk changes, or ensuring documentation integrity.