Apply professional E2E testing judgment: choose the right scenarios for E2E vs integration tests, keep suites lean and fast, write tests that survive application evolution, and establish team E2E conventions.
E2E tests are the most expensive tests to write, maintain, and run. A team that writes E2E tests for every feature ends up with a suite that takes 45 minutes, fails randomly, and takes 3 hours to debug a single failure. A team that writes E2E tests for nothing ships bugs that embarrass users in production.
The skill is judgment: knowing which scenarios justify E2E coverage and which are better served by integration or unit tests. This lesson builds that judgment systematically. By the end, you will have a clear decision framework, a process for auditing an existing suite, and team conventions that prevent quality regression over time.
Write an E2E test when ALL of the following are true:
1CRITICAL USER JOURNEYS → Always E2E tests:
2 ✅ Registration → email verification → first login
3 ✅ Browse courses → add to cart → checkout → confirmation
4 ✅ Start a lesson → mark complete → see progress update
5 ✅ Password reset → enter new password → login with new password
6 ✅ Admin publishes course → student sees it
7
8CAN BE INTEGRATION TESTS (skip E2E):
9 ✅ API returns correct pagination metadata
10 ✅ Authentication middleware blocks unauthenticated requests
11 ✅ Database constraint prevents duplicate enrollment
12 ✅ Webhook handler updates enrollment status
13 ✅ Email service is called with correct payload
14
15MUST BE UNIT TESTS (never E2E):
16 ✅ Discount calculation logic (percentage, fixed, expiry)
17 ✅ Password strength validation rules
18 ✅ Date formatting utilities
19 ✅ Cart subtotal calculation
20 ✅ Input sanitisation functions1// WRONG: E2E test for pure logic
2test('applies 10% discount for SAVE10 coupon', async ({ page }) => {
3 await page.goto('/checkout');
4 await page.getByLabel('Coupon code').fill('SAVE10');
5 await page.getByRole('button', { name: 'Apply' }).click();
6 await expect(page.getByTestId('discount-amount')).toHaveText('-$10.00');
7 // This is 50x slower than a unit test and adds no confidence
8});
9
10// RIGHT: E2E test for the purchase flow
11test('user completes a discounted checkout', async ({ page }) => {
12 await page.goto('/courses/typescript-fundamentals');
13 await page.getByRole('button', { name: 'Enrol' }).click();
14 await page.getByLabel('Coupon code').fill('SAVE10');
15 await page.getByRole('button', { name: 'Apply' }).click();
16 await page.getByRole('button', { name: 'Pay' }).click();
17 await expect(page).toHaveURL('/checkout/success');
18 // Unit tests verify the math; E2E verifies the flow completes
19});A healthy E2E suite has tens of tests (critical flows), not hundreds. Periodically audit for redundancy.
1// Audit signals — a test may be wrong level if:
2
3// Signal 1: The test is slow but only asserts on one thing
4test('displays course price', async ({ page }) => { // 5 seconds for one assertion
5 await page.goto('/courses/1');
6 await expect(page.getByTestId('price')).toHaveText('$49.99');
7 // Move to unit test: formatPrice(49.99) === '$49.99'
8});
9
10// Signal 2: The test duplicates an integration test exactly
11test('returns 404 for unknown course', async ({ page }) => {
12 await page.goto('/courses/nonexistent');
13 await expect(page).toHaveURL('/404');
14 // If you already test GET /courses/:id → 404 in integration tests,
15 // this E2E test adds maintenance cost but no new confidence
16});
17
18// Signal 3: The test has more than 15 assertions
19test('checkout validation', async ({ page }) => {
20 // Tests: empty name, invalid card, invalid expiry, invalid CVC,
21 // missing address, invalid zip, expired card, declined card...
22 // Split into: one E2E happy-path test + unit tests for each validation rule
23});
24
25// Signal 4: Tests that always pass but take long
26// Run: npx playwright test --reporter=list 2>&1 | grep -E '^ .* [0-9]+ms'
27// Sort by duration — tests taking > 30s without clear reason are candidates for removal
28
29// Healthy E2E coverage for a learning platform (~30-50 tests total):
30// - Public pages (5): home, courses list, course detail, blog, about
31// - Auth (6): register, verify email, login, logout, reset password, OAuth
32// - Student flows (8): enrol, pay, start lesson, complete lesson, download cert, progress
33// - Instructor flows (6): create course, publish, view analytics, manage students
34// - Admin flows (5): user management, content moderation, feature flags
35// - Accessibility (5): keyboard nav, screen reader headings, skip linksTests that break on every UI redesign are a liability. Tests that survive 2 years of evolution are an asset.
1// FRAGILE: coupled to implementation details
2test('checkout shows correct total', async ({ page }) => {
3 await page.goto('/checkout');
4 // These break on any visual redesign or component refactor:
5 await expect(page.locator('.checkout-summary__total')).toHaveText('$49.99');
6 await expect(page.locator('section.payment-form div.card-inputs input:nth-child(1)')).toBeVisible();
7 await expect(page.locator('.MuiButton-containedPrimary')).toBeEnabled();
8});
9
10// RESILIENT: coupled to user-observable outcomes
11test('checkout shows total and accepts payment', async ({ page }) => {
12 await page.goto('/checkout');
13 // These survive: CSS class changes, component library migration, DOM restructuring
14 await expect(page.getByRole('heading', { name: /total/i })).toBeVisible();
15 await expect(page.getByLabel('Card number')).toBeVisible();
16 await expect(page.getByRole('button', { name: /pay/i })).toBeEnabled();
17});
18
19// FRAGILE: asserts on URL structure that may change
20test('enrolment redirects correctly', async ({ page }) => {
21 await page.getByRole('button', { name: 'Enrol' }).click();
22 await expect(page).toHaveURL('/student/dashboard/courses/enrolled/react-fundamentals');
23 // URL structure changes constantly in real apps
24});
25
26// RESILIENT: asserts on page content after navigation
27test('enrolment shows confirmation message', async ({ page }) => {
28 await page.getByRole('button', { name: 'Enrol' }).click();
29 // The URL may change; the user-visible confirmation will not:
30 await expect(page.getByRole('heading', { name: /enrolled/i })).toBeVisible();
31 await expect(page.getByRole('link', { name: 'Start learning' })).toBeVisible();
32});1// Strategy 1: dedicated E2E test users (simplest)
2// Create fixed users in seeds script:
3// [email protected] — never changes, always enrolled in nothing
4// [email protected] — never changes, has 1 published course
5// [email protected] — never changes
6// Tests use these users with storageState — no per-test auth
7
8// Strategy 2: API setup/teardown for mutable data
9test('creates and publishes a course', async ({ page, request }) => {
10 // Create isolated data for this test
11 const token = process.env['TEST_INSTRUCTOR_TOKEN']!;
12 const res = await request.post('/api/courses', {
13 headers: { Authorization: `Bearer ${token}` },
14 data: { title: `E2E Course ${Date.now()}`, price: 0, level: 'BEGINNER' },
15 });
16 const { id } = await res.json();
17
18 try {
19 // Test the publish flow in the browser
20 await page.goto(`/instructor/courses/${id}`);
21 await page.getByRole('button', { name: 'Publish' }).click();
22 await expect(page.getByRole('status')).toContainText('Published');
23
24 // Verify it appears publicly
25 await page.goto('/courses');
26 await expect(page.getByText(`E2E Course`)).toBeVisible();
27 } finally {
28 await request.delete(`/api/courses/${id}`, {
29 headers: { Authorization: `Bearer ${token}` },
30 });
31 }
32});
33
34// Strategy 3: database reset between test runs (for full isolation)
35// Add a script that resets E2E-specific data:
36// POST /test-helpers/reset-e2e-data (only available in NODE_ENV=test)
37// Run this in global setup or beforeAll
38
39// NEVER: share mutable data between test files
40// NEVER: rely on data created by a previous CI run
41// ALWAYS: each test file either uses fixed read-only data or creates+cleans its own1// File naming:
2// e2e/auth/registration.spec.ts — user story scoped
3// e2e/courses/enrolment.spec.ts
4// e2e/checkout/payment.spec.ts
5// NOT: e2e/test1.spec.ts, e2e/misc.spec.ts
6
7// Test naming — must read as a user story:
8// GOOD: 'student can enrol in a free course without payment'
9// GOOD: 'unverified user cannot access premium content'
10// BAD: 'test enrolment'
11// BAD: 'check that the button works'
12
13// Size limit: max 10 tests per spec file
14// If a file grows beyond 10 tests, split by sub-flow
15
16// Assertion minimum:
17// Every test must assert on at least one URL, heading, or status message
18// A test that only asserts clicks did not throw is not a test
19
20// Review checklist for E2E PRs:
21const reviewChecklist = [
22 'Is this the right level? (could this be an integration or unit test?)',
23 'Does the test name describe a user story?',
24 'Are locators semantic (getByRole, getByLabel)?',
25 'Is all test data created and cleaned up within the test?',
26 'Are there any page.waitForTimeout() calls? (must be removed)',
27 'Does the test mock external services (Stripe, SendGrid)?',
28 'Is the test < 30 lines of actual test code (not counting setup)?',
29];
30
31// Monitoring:
32// Track test duration over time — sudden increases signal new timing issues
33// Track flaky test rate — any test failing > 5% of runs needs immediate attention
34// npx playwright test --reporter=json | jq '[.suites[].specs[] | {title, duration, status}]'An audit of a bloated 80-test E2E suite reduced to 35 focused critical-flow tests, with each removed test moved to the right level.
1// BEFORE AUDIT: 80 E2E tests — slow, fragile, expensive
2// Examples of tests that should NOT be E2E:
3
4// Should be UNIT TEST — pure calculation logic:
5test('applies percentage discount correctly', ...);
6test('calculates cart subtotal with multiple items', ...);
7test('formats price with currency symbol', ...);
8test('validates email format', ...);
9test('validates password strength', ...);
10// → Move to: src/utils/cart.test.ts, src/utils/validation.test.ts
11
12// Should be INTEGRATION TEST — API contract, no browser needed:
13test('GET /courses returns paginated results', ...);
14test('POST /enrollments requires authentication', ...);
15test('webhook handler updates enrollment status', ...);
16test('forgot password email is sent', ...);
17test('duplicate email returns 409', ...);
18// → Move to: src/courses/courses.integration.test.ts
19
20// Duplicate coverage — integration test already covers this:
21test('course detail page returns 404 for unknown id', ...);
22test('API rate limiting returns 429', ...);
23
24// AFTER AUDIT: 35 E2E tests — fast, focused, high-confidence
25// Critical user flows only:
26
27// Auth flows (6 tests):
28test('new user registers, receives verification email link, and logs in', ...);
29test('unverified user cannot access premium content', ...);
30test('login with correct credentials reaches dashboard', ...);
31test('login with wrong credentials shows error without revealing user existence', ...);
32test('password reset flow allows login with new password', ...);
33test('GitHub OAuth creates account and redirects to dashboard', ...);
34
35// Course discovery (4 tests):
36test('anonymous user can browse and filter courses by level', ...);
37test('search returns matching courses and allows enrolment', ...);
38test('course detail page shows curriculum and instructor info', ...);
39test('free course enrolment completes without payment', ...);
40
41// Paid enrolment (4 tests):
42test('paid course checkout completes with valid card', ...);
43test('declined card shows error and allows retry', ...);
44test('coupon code reduces the checkout total', ...);
45test('enrolled course appears in student dashboard', ...);
46
47// Learning (4 tests):
48test('student watches lesson and marks it complete', ...);
49test('progress bar updates after each completed lesson', ...);
50test('final lesson completion triggers certificate generation', ...);
51test('lesson content is locked for unenrolled users', ...);
52
53// Instructor (4 tests):
54test('instructor creates and publishes a course', ...);
55test('published course is visible to students immediately', ...);
56test('instructor views enrollment analytics', ...);
57test('instructor adds a new lesson to existing course', ...);
58
59// Admin (4 tests):
60test('admin bans user — banned user cannot log in', ...);
61test('admin unpublishes course — course disappears from listing', ...);
62test('admin views platform analytics dashboard', ...);
63test('admin manages feature flags', ...);
64
65// Accessibility (4 tests):
66test('all core flows are keyboard navigable', ...);
67test('skip-to-content link appears on tab', ...);
68test('error messages have role=alert for screen readers', ...);
69test('images have descriptive alt text', ...);
70
71// Remaining: 9 tests for edge cases not covered elsewhere
72// Total: 35 tests × ~8 seconds average = ~5 minutes
73// Previously: 80 tests × ~15 seconds average = ~20 minutesThe audit reduced 80 tests to 35 by moving 45 tests to lower levels: 15 to unit tests (pure logic), 20 to integration tests (API contracts), and removing 10 duplicates. The remaining 35 tests cover all critical user-facing flows that, if broken, a user would immediately notice. Run time dropped from 20 minutes to 5 minutes — making the suite practical to run on every PR. Each test now has a clear reason to exist: it verifies a complete user journey that no lower-level test can cover.
Congratulations on completing the End-to-End Testing module. The next module covers Test-Driven Development: the Red-Green-Refactor cycle, writing failing tests before implementation, and applying TDD to real-world React components and API endpoints.
Your progress is saved in this browser, on this device only.