by thetestingacademy
Comprehensive end-to-end testing methodologies and best practices covering architecture, test design, data management, flakiness prevention, and cross-browser strategies.
npx @qaskills/cli add e2e-testing-patternsAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA architect specializing in end-to-end testing patterns and methodologies. When the user asks you to design, review, or improve E2E testing strategies, follow these detailed instructions.
/\
/ \ E2E Tests (10-20%)
/____\ - Critical user journeys
/ \ - High-value scenarios
/ \ - Smoke tests
/__________\ Integration Tests (20-30%)
/ \
/ \ Unit Tests (50-70%)
/________________\
E2E tests should focus on:
E2E tests should NOT test:
Structure:
pages/
base.page.ts # Shared base functionality
login.page.ts # Login page actions and selectors
dashboard.page.ts # Dashboard page actions
components/
header.component.ts # Reusable header component
modal.component.ts # Reusable modal component
Implementation:
// base.page.ts
export abstract class BasePage {
constructor(protected page: Page) {}
async navigate(path: string): Promise<void> {
await this.page.goto(path);
}
async waitForLoad(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
async takeScreenshot(name: string): Promise<void> {
await this.page.screenshot({ path: `screenshots/${name}.png` });
}
}
// login.page.ts
export class LoginPage extends BasePage {
private readonly emailInput = this.page.getByLabel('Email');
private readonly passwordInput = this.page.getByLabel('Password');
private readonly submitButton = this.page.getByRole('button', { name: 'Sign in' });
async goto(): Promise<void> {
await this.navigate('/login');
}
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
await this.waitForLoad();
}
async expectError(message: string): Promise<void> {
await expect(this.page.getByRole('alert')).toContainText(message);
}
}
Pros:
Cons:
Structure:
// actors/user.actor.ts
export class User {
constructor(private page: Page) {}
async attemptsTo(...tasks: Task[]): Promise<void> {
for (const task of tasks) {
await task.perform(this.page);
}
}
async shouldSee(...assertions: Assertion[]): Promise<void> {
for (const assertion of assertions) {
await assertion.verify(this.page);
}
}
}
// tasks/login.task.ts
export class Login implements Task {
constructor(
private email: string,
private password: string
) {}
async perform(page: Page): Promise<void> {
await page.getByLabel('Email').fill(this.email);
await page.getByLabel('Password').fill(this.password);
await page.getByRole('button', { name: 'Sign in' }).click();
}
}
// Usage
test('user can login and view dashboard', async ({ page }) => {
const user = new User(page);
await user.attemptsTo(
new NavigateTo('/login'),
new Login('user@example.com', 'password123')
);
await user.shouldSee(
new PageTitle('Dashboard'),
new Element('welcome-message').isVisible()
);
});
Pros:
Cons:
Organize tests by complete user journeys rather than by pages:
describe('Purchase Journey', () => {
test('guest user can complete full purchase flow', async ({ page }) => {
// Journey: Browse → Add to Cart → Checkout → Payment → Confirmation
// Step 1: Browse products
await page.goto('/products');
await page.getByRole('link', { name: 'Laptops' }).click();
// Step 2: Add to cart
const product = page.getByTestId('product-123');
await product.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
// Step 3: Checkout
await page.getByRole('button', { name: 'Checkout' }).click();
await fillCheckoutForm(page, guestUserData);
// Step 4: Payment
await fillPaymentForm(page, testPaymentData);
await page.getByRole('button', { name: 'Place Order' }).click();
// Step 5: Confirmation
await expect(page.getByRole('heading', { name: 'Order Confirmed' })).toBeVisible();
const orderNumber = await page.getByTestId('order-number').textContent();
expect(orderNumber).toMatch(/^ORD-\d{6}$/);
});
});
Pros:
Cons:
// factories/user.factory.ts
export class UserFactory {
private static counter = 0;
static createUser(overrides: Partial<User> = {}): User {
const id = ++this.counter;
return {
id: `user-${id}`,
email: `testuser${id}@example.com`,
name: `Test User ${id}`,
role: 'user',
...overrides,
};
}
static createAdmin(): User {
return this.createUser({ role: 'admin' });
}
}
// Usage in tests
test('admin can delete users', async ({ page }) => {
const admin = UserFactory.createAdmin();
await loginAs(page, admin);
// ... rest of test
});
// fixtures/db-seed.fixture.ts
export async function seedDatabase(): Promise<SeedData> {
const users = await db.users.createMany([
{ email: 'user1@example.com', name: 'User 1' },
{ email: 'user2@example.com', name: 'User 2' },
]);
const products = await db.products.createMany([
{ name: 'Product A', price: 29.99 },
{ name: 'Product B', price: 49.99 },
]);
return { users, products };
}
export async function cleanDatabase(): Promise<void> {
await db.orders.deleteMany();
await db.products.deleteMany();
await db.users.deleteMany();
}
// Use in test setup
test.beforeEach(async () => {
await cleanDatabase();
await seedDatabase();
});
// helpers/test-data.ts
export async function createUserViaAPI(userData: CreateUserDto): Promise<User> {
const response = await request.post('/api/users', {
data: userData,
});
return response.json();
}
test('user can update profile', async ({ page }) => {
// Setup: Create user via API (faster than UI)
const user = await createUserViaAPI({
email: 'test@example.com',
password: 'password123',
});
// Test: Update profile via UI
await page.goto('/profile');
await page.getByLabel('Name').fill('Updated Name');
await page.getByRole('button', { name: 'Save' }).click();
// Assertion
await expect(page.getByText('Updated Name')).toBeVisible();
});
// ❌ BAD: Hardcoded wait
await page.waitForTimeout(5000);
// ✅ GOOD: Wait for specific condition
await page.waitForSelector('[data-testid="results"]');
await page.waitForLoadState('networkidle');
// ✅ BETTER: Use auto-waiting assertions
await expect(page.getByTestId('results')).toBeVisible();
// ✅ Automatically retries until condition is met (or timeout)
await expect(page.getByRole('alert')).toHaveText('Success', { timeout: 10000 });
// ✅ Wait for element count to stabilize
await expect(page.getByRole('listitem')).toHaveCount(5);
// ✅ Wait for element to be in the right state
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
// Wait for specific API call to complete
test('should load user data', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) => response.url().includes('/api/users') && response.status() === 200
);
await page.goto('/users');
await responsePromise;
await expect(page.getByRole('heading')).toContainText('Users');
});
// ❌ BAD: Assumes element exists immediately
await page.click('button');
await page.fill('input', 'text');
// ✅ GOOD: Wait for element before interaction
await page.waitForSelector('button');
await page.click('button');
await page.waitForSelector('input');
await page.fill('input', 'text');
// ✅ BETTER: Use built-in auto-waiting
await page.getByRole('button').click();
await page.getByRole('textbox').fill('text');
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 13'] },
},
],
});
test('should support advanced CSS features', async ({ page, browserName }) => {
test.skip(browserName === 'webkit', 'Safari does not support this CSS feature yet');
await page.goto('/advanced-styles');
// ... test advanced CSS behavior
});
test('homepage renders consistently across browsers', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixels: 100, // Allow minor rendering differences
});
});
tests/
e2e/
auth/
login.spec.ts
signup.spec.ts
password-reset.spec.ts
shopping/
browse-products.spec.ts
cart-operations.spec.ts
checkout.spec.ts
admin/
user-management.spec.ts
analytics.spec.ts
// Tag tests by priority
test('user can login @smoke', async ({ page }) => {
// Critical path
});
test('user can reset password @regression', async ({ page }) => {
// Less critical, run in nightly builds
});
test('admin can export analytics @full', async ({ page }) => {
// Run only in full test suite
});
// Run subsets
// npx playwright test --grep @smoke
// npx playwright test --grep @regression
// Run tests in parallel (default)
test.describe.configure({ mode: 'parallel' });
// Run tests serially when they share state
test.describe.configure({ mode: 'serial' });
test.describe('User onboarding flow', () => {
test.describe.configure({ mode: 'serial' });
test('step 1: create account', async ({ page }) => {
// ...
});
test('step 2: verify email', async ({ page }) => {
// ...
});
test('step 3: complete profile', async ({ page }) => {
// ...
});
});
// auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('admin123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
// fixtures/auth.fixture.ts
export const test = base.extend<{
authenticatedPage: Page;
adminPage: Page;
}>({
authenticatedPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
// Usage
test('admin can access admin panel', async ({ adminPage }) => {
await adminPage.goto('/admin');
await expect(adminPage.getByRole('heading')).toHaveText('Admin Dashboard');
});
test('homepage loads within 3 seconds', async ({ page }) => {
const startTime = Date.now();
await page.goto('/');
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(3000);
});
import { playAudit } from 'playwright-lighthouse';
test('homepage meets performance standards', async ({ page }) => {
await page.goto('/');
await playAudit({
page,
thresholds: {
performance: 90,
accessibility: 95,
'best-practices': 90,
seo: 90,
},
});
});
sleep(5000) is a code smell.// playwright.config.ts
export default defineConfig({
reporter: [
['html', { open: 'never', outputFolder: 'test-results/html' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
],
});
// Enable tracing on failure
export default defineConfig({
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
// View trace:
// npx playwright show-trace trace.zip
test('critical payment flow', async ({ page }) => {
test.info().annotations.push({ type: 'priority', description: 'critical' });
test.info().annotations.push({ type: 'ticket', description: 'JIRA-1234' });
// ... test implementation
});
E2E testing is an investment in confidence. Done well, it catches critical bugs before production. Done poorly, it wastes time and erodes trust in automation.
- name: Install QA Skills
run: npx @qaskills/cli add e2e-testing-patterns10 of 29 agents supported