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)
Creates end-to-end tests for critical user workflows spanning multiple pages using Page Object Model, user-centric selectors, and condition-based waiting. Use when testing complete journeys like checkout, signup, or authentication flows.
/plugin marketplace add samjhecht/wrangler/plugin install wrangler@samjhecht-pluginsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
example.tsMANDATORY: When using this skill, announce it at the start with:
š§ Using Skill: frontend-e2e-user-journeys | [brief purpose based on context]
Example:
š§ Using Skill: frontend-e2e-user-journeys | [Provide context-specific example of what you're doing]
This creates an audit trail showing which skills were applied during the session.
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('test@example.com', '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('test@example.com', '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('test@example.com');
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"]', 'test@example.com');
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 () => {
const driver = await new Builder().forBrowser('chrome').build();
try {
await driver.get('http://localhost:3000/signup');
await driver.findElement(By.name('email')).sendKeys('test@example.com');
await driver.findElement(By.name('password')).sendKeys('password123');
await driver.findElement(By.css('button[type="submit"]')).click();
await driver.wait(until.urlContains('/verify-email'));
const heading = await driver.findElement(By.css('h1')).getText();
expect(heading).toContain('Check your email');
} finally {
await driver.quit();
}
});
describe('User signup flow', () => {
it('allows user to sign up', () => {
cy.visit('/signup');
cy.get('[name="email"]').type('test@example.com');
cy.get('[name="password"]').type('password123');
cy.get('[name="confirmPassword"]').type('password123');
cy.get('button[type="submit"]').click();
cy.url().should('include', '/verify-email');
cy.get('h1').should('contain', 'Check your email');
});
});
BEFORE claiming E2E test complete:
If ANY checkbox unchecked: E2E test is incomplete or incorrect.
If you catch yourself:
waitForTimeout() with arbitrary valuesTHEN:
E2E tests CAN follow TDD, but require incremental approach:
Build E2E test one page at a time following TDD:
Iteration 1: Login Page
RED:
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard'); // FAILS - login doesn't work
});
GREEN: Implement login page and authentication
REFACTOR: Improve login page code
Iteration 2: Add to Cart
RED:
test('user can add product to cart', async ({ page }) => {
// Reuse login from iteration 1
await loginPage.goto();
await loginPage.login('test@example.com', 'password123');
// New functionality (RED - doesn't exist yet)
await page.goto('/product/123');
await page.click('button[name="add-to-cart"]');
await expect(page.locator('.cart-badge')).toHaveText('1'); // FAILS
});
GREEN: Implement add to cart functionality
REFACTOR: Improve cart code
Iteration 3-5: Continue incrementally
Write skeleton E2E test with all steps, expect ALL to fail:
RED:
test('complete checkout flow', async ({ page }) => {
// Step 1: Login (exists)
await loginPage.login('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
// Step 2: Add to cart (doesn't exist - will fail here)
await page.goto('/product/123');
await page.click('button[name="add-to-cart"]');
// Step 3: Checkout (doesn't exist)
await page.goto('/checkout');
await checkoutPage.fillShippingInfo({...});
// Step 4: Payment (doesn't exist)
await checkoutPage.fillPaymentInfo({...});
await checkoutPage.submitOrder();
// Step 5: Confirmation (doesn't exist)
await expect(page.locator('.order-confirmation')).toBeVisible();
});
Run test: FAILS at step 2 (add to cart doesn't exist)
GREEN: Implement add to cart Run test: FAILS at step 3 (checkout doesn't exist)
GREEN: Implement checkout Run test: FAILS at step 4 (payment doesn't exist)
... continue until all steps implemented
When to use each approach:
Both approaches follow TDD: Write test, watch fail, implement, watch pass.
Cross-reference: See test-driven-development skill for core RED-GREEN-REFACTOR principles.
Combines with:
Modern frontend testing distribution:
/\
/E2E\ 10-15% - Critical journeys only
/------\
/Integration\ 40-50% - Highest ROI
/------------\
/ Component \ 30-40% - User behavior
/--------------\
/ Unit \ 5-10% - Pure logic
/----------------\
E2E tests should be the SMALLEST portion of your test suite.
| Rationalization | Counter |
|---|---|
| "I need E2E test for every feature" | No. Use component tests. E2E for critical journeys only. |
| "E2E tests are more thorough" | They're slower and more brittle. Higher ROI at component level. |
| "I don't have time for Page Objects" | You'll spend more time maintaining brittle tests. |
| "CSS selectors are easier" | They break on every UI change. Use accessible selectors. |
| "I'll add timeouts to make it stable" | That's covering up race conditions. Wait for conditions. |
Agent: "I'm implementing user checkout flow."
[Uses frontend-e2e-user-journeys skill]
1. Determine if E2E appropriate:
- Complete workflow? YES (add to cart ā checkout ā payment)
- Business critical? YES (checkout is revenue-critical)
- Could be component test? NO (spans multiple pages)
ā E2E test appropriate
2. Create Page Objects:
- ProductPage (for adding to cart)
- CheckoutPage (for checkout form)
- ConfirmationPage (for order confirmation)
3. Write E2E test with TDD:
- RED: Write test expecting checkout flow works
- GREEN: Implement checkout flow
- REFACTOR: Improve code, E2E test catches regressions
4. Use user-centric selectors:
- button[name="add-to-cart"]
- input[name="cardNumber"]
- [role="alert"] (for error messages)
5. Wait for conditions:
- waitForResponse('/api/checkout')
- waitForSelector('[data-testid="confirmation"]')
6. Test isolation:
- Create unique test user
- Create unique test product
- Clean up after test
7. Verify test:
- Runs in <30s
- Passes 10 times consecutively
- Fails when expected (invalid card)
"Checkout E2E test complete. Critical user journey verified."
Remember: E2E TESTS ONLY FOR CRITICAL USER JOURNEYS. Use component tests for most UI testing.
Optimize Bazel builds for large-scale monorepos. Use when configuring Bazel, implementing remote execution, or optimizing build performance for enterprise codebases.