Skip to main content

πŸ”— Lesson 9.5: Integration and E2E Testing

Take your testing skills to the next level. Learn to test how multiple components work together, test routing and navigation flows, verify state management across components, and write end-to-end tests that simulate real user journeys through your entire application.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand the difference between unit, integration, and E2E tests
  • Write integration tests for multiple connected components
  • Test React Router navigation and routing logic
  • Test global state management (Context, Redux, Zustand)
  • Set up and write E2E tests with Playwright or Cypress
  • Test complete user workflows from start to finish
  • Implement CI/CD testing strategies
  • Choose the right test type for different scenarios

Estimated Time: 90-120 minutes

Project: Add comprehensive integration and E2E tests to an e-commerce application

πŸ“‘ In This Lesson

πŸ“– Understanding Test Types

You've already learned about unit tests (testing individual functions) and basic component tests. Now it's time to level up and understand the full spectrum of testing approaches available to you.

πŸ“– The Testing Spectrum

Testing exists on a spectrum from small, fast, isolated tests (unit tests) to large, slower, realistic tests (E2E tests). Each type serves a different purpose and provides different value.

Visualizing the Test Types

graph LR A[Unit Tests] --> B[Integration Tests] B --> C[E2E Tests] A --> D[Fast
Many
Isolated] C --> E[Slow
Few
Realistic] B --> F[Balanced
Moderate
Connected] style A fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style B fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style C fill:#f59f00,stroke:#e67700,stroke-width:2px,color:#fff style D fill:#e8f5e9 style E fill:#fff3e0 style F fill:#e3f2fd

Comparing Test Types

Aspect Unit Tests Integration Tests E2E Tests
Scope Single function or component Multiple components working together Entire application from user perspective
Speed Very fast (milliseconds) Fast to moderate (seconds) Slow (seconds to minutes)
Cost to maintain Low Moderate High
Confidence level Low to moderate Moderate to high Very high
What to test Logic, calculations, edge cases Component interactions, data flow User workflows, critical paths
How many? Many (70-80%) Moderate (15-25%) Few (5-10%)

The Testing Pyramid

The testing pyramid is a classic model that helps you balance your testing strategy:


                     /\
                    /  \     E2E Tests (5-10%)
                   /____\    Few, slow, high confidence
                  /      \
                 /        \  Integration Tests (15-25%)
                /__________\ Moderate number, moderate speed
               /            \
              /              \
             /                \
            /                  \ Unit Tests (70-80%)
           /____________________\ Many, fast, focused
                

πŸ’‘ Why This Balance?

The pyramid shape represents:

  • Base (Unit Tests): Quick feedback during development, catch bugs early
  • Middle (Integration Tests): Verify components work together correctly
  • Top (E2E Tests): Ensure critical user paths work end-to-end

This balance gives you fast, reliable feedback while still maintaining confidence that your application works as a whole.

When to Use Each Test Type

βœ… Use Unit Tests For:

  • Pure functions and utility functions
  • Complex calculations and algorithms
  • Business logic
  • Edge cases and error handling
  • Custom hooks

πŸ”— Use Integration Tests For:

  • Parent and child component interactions
  • Form submissions with multiple fields
  • Routing and navigation flows
  • State management across components
  • API integration with multiple requests

🌐 Use E2E Tests For:

  • Critical user workflows (login, checkout, signup)
  • Multi-page user journeys
  • Authentication flows
  • Payment processing
  • Happy path scenarios
πŸ’¬ Testing Philosophy: "Write tests that give you confidence without becoming a burden. The goal isn't 100% coverageβ€”it's having the right tests that catch real bugs and allow you to move fast."

πŸ”— Integration Testing Basics

Integration tests verify that multiple pieces of your application work together correctly. Unlike unit tests that isolate components, integration tests let components interact naturally.

What Makes a Test an Integration Test?

An integration test typically involves:

  • Multiple components: Parent and child components rendering together
  • Real interactions: User events triggering state changes across components
  • Data flow: Props, callbacks, and context passing data between components
  • Side effects: API calls, routing, or state management updates

Simple Integration Test Example

Let's test a parent component that manages state for its children:


// ProductList.tsx
interface Product {
  id: string;
  name: string;
  price: number;
}

function ProductList() {
  const [products, setProducts] = React.useState<Product[]>([
    { id: '1', name: 'Laptop', price: 999 },
    { id: '2', name: 'Mouse', price: 29 }
  ]);
  const [cart, setCart] = React.useState<string[]>([]);
  
  const addToCart = (productId: string) => {
    setCart([...cart, productId]);
  };
  
  return (
    <div>
      <h1>Products</h1>
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={addToCart}
        />
      ))}
      <CartSummary cart={cart} products={products} />
    </div>
  );
}

// ProductCard.tsx
function ProductCard({ product, onAddToCart }: {
  product: Product;
  onAddToCart: (id: string) => void;
}) {
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>
        Add to Cart
      </button>
    </div>
  );
}

// CartSummary.tsx
function CartSummary({ cart, products }: {
  cart: string[];
  products: Product[];
}) {
  const total = cart.reduce((sum, id) => {
    const product = products.find(p => p.id === id);
    return sum + (product?.price || 0);
  }, 0);
  
  return (
    <div>
      <h2>Cart ({cart.length} items)</h2>
      <p>Total: ${total}</p>
    </div>
  );
}
                

Integration Test for the Complete Flow


import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { ProductList } from './ProductList';

describe('ProductList Integration', () => {
  it('adds products to cart and updates total', async () => {
    const user = userEvent.setup();
    
    render(<ProductList />);
    
    // Initial state
    expect(screen.getByText('Cart (0 items)')).toBeInTheDocument();
    expect(screen.getByText('Total: $0')).toBeInTheDocument();
    
    // Find and click "Add to Cart" for Laptop
    const laptopButtons = screen.getAllByText('Add to Cart');
    await user.click(laptopButtons[0]);
    
    // Cart updates
    expect(screen.getByText('Cart (1 items)')).toBeInTheDocument();
    expect(screen.getByText('Total: $999')).toBeInTheDocument();
    
    // Add mouse
    await user.click(laptopButtons[1]);
    
    // Cart updates again
    expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
    expect(screen.getByText('Total: $1028')).toBeInTheDocument();
  });
  
  it('allows adding same product multiple times', async () => {
    const user = userEvent.setup();
    
    render(<ProductList />);
    
    const laptopButton = screen.getAllByText('Add to Cart')[0];
    
    // Add laptop twice
    await user.click(laptopButton);
    await user.click(laptopButton);
    
    expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
    expect(screen.getByText('Total: $1998')).toBeInTheDocument();
  });
});
                

πŸ’‘ What Makes This Integration Testing?

Notice how we're testing:

  1. Multiple components: ProductList, ProductCard, and CartSummary work together
  2. Data flow: Callback from child to parent updates state
  3. State propagation: State changes in parent affect multiple children
  4. Calculations: Cart total calculated from multiple pieces of data
  5. User workflow: Complete "add to cart" flow from button click to total update

We're not testing components in isolationβ€”we're testing how they work together as a system.

Testing Complex Parent-Child Interactions


// SearchableList.tsx
function SearchableList() {
  const [items] = React.useState([
    'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'
  ]);
  const [searchTerm, setSearchTerm] = React.useState('');
  const [selectedItems, setSelectedItems] = React.useState<string[]>([]);
  
  const filteredItems = items.filter(item =>
    item.toLowerCase().includes(searchTerm.toLowerCase())
  );
  
  const toggleItem = (item: string) => {
    setSelectedItems(prev =>
      prev.includes(item)
        ? prev.filter(i => i !== item)
        : [...prev, item]
    );
  };
  
  return (
    <div>
      <SearchInput value={searchTerm} onChange={setSearchTerm} />
      <ItemList
        items={filteredItems}
        selectedItems={selectedItems}
        onToggle={toggleItem}
      />
      <SelectionSummary selected={selectedItems} />
    </div>
  );
}

// Integration test
describe('SearchableList Integration', () => {
  it('filters items and maintains selection', async () => {
    const user = userEvent.setup();
    
    render(<SearchableList />);
    
    // Initially shows all items
    expect(screen.getByText('Apple')).toBeInTheDocument();
    expect(screen.getByText('Banana')).toBeInTheDocument();
    
    // Select Apple
    await user.click(screen.getByText('Apple'));
    expect(screen.getByText('Selected: 1')).toBeInTheDocument();
    
    // Filter for 'a'
    await user.type(screen.getByRole('textbox'), 'a');
    
    // Should show only items with 'a'
    expect(screen.getByText('Apple')).toBeInTheDocument();
    expect(screen.getByText('Banana')).toBeInTheDocument();
    expect(screen.queryByText('Cherry')).not.toBeInTheDocument();
    
    // Apple should still be selected after filtering
    expect(screen.getByText('Selected: 1')).toBeInTheDocument();
    
    // Select Banana
    await user.click(screen.getByText('Banana'));
    expect(screen.getByText('Selected: 2')).toBeInTheDocument();
    
    // Clear filter
    await user.clear(screen.getByRole('textbox'));
    
    // All items visible, both still selected
    expect(screen.getByText('Cherry')).toBeInTheDocument();
    expect(screen.getByText('Selected: 2')).toBeInTheDocument();
  });
});
                

Testing Form Integration


// RegistrationForm.tsx
function RegistrationForm() {
  const [formData, setFormData] = React.useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: ''
  });
  const [errors, setErrors] = React.useState<Record<string, string>>({});
  const [submitted, setSubmitted] = React.useState(false);
  
  const validate = () => {
    const newErrors: Record<string, string> = {};
    
    if (formData.username.length < 3) {
      newErrors.username = 'Username must be at least 3 characters';
    }
    
    if (!formData.email.includes('@')) {
      newErrors.email = 'Invalid email';
    }
    
    if (formData.password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters';
    }
    
    if (formData.password !== formData.confirmPassword) {
      newErrors.confirmPassword = 'Passwords must match';
    }
    
    return newErrors;
  };
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    const newErrors = validate();
    setErrors(newErrors);
    
    if (Object.keys(newErrors).length === 0) {
      setSubmitted(true);
    }
  };
  
  if (submitted) {
    return <div>Registration successful!</div>;
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Username"
        value={formData.username}
        onChange={value => setFormData({...formData, username: value})}
        error={errors.username}
      />
      <InputField
        label="Email"
        type="email"
        value={formData.email}
        onChange={value => setFormData({...formData, email: value})}
        error={errors.email}
      />
      <InputField
        label="Password"
        type="password"
        value={formData.password}
        onChange={value => setFormData({...formData, password: value})}
        error={errors.password}
      />
      <InputField
        label="Confirm Password"
        type="password"
        value={formData.confirmPassword}
        onChange={value => setFormData({...formData, confirmPassword: value})}
        error={errors.confirmPassword}
      />
      <button type="submit">Register</button>
    </form>
  );
}

// Integration test
describe('RegistrationForm Integration', () => {
  it('validates entire form and shows all errors', async () => {
    const user = userEvent.setup();
    
    render(<RegistrationForm />);
    
    // Submit without filling anything
    await user.click(screen.getByText('Register'));
    
    // All validation errors appear
    expect(screen.getByText('Username must be at least 3 characters')).toBeInTheDocument();
    expect(screen.getByText('Invalid email')).toBeInTheDocument();
    expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument();
    expect(screen.getByText('Passwords must match')).toBeInTheDocument();
  });
  
  it('clears errors as user fixes them', async () => {
    const user = userEvent.setup();
    
    render(<RegistrationForm />);
    
    // Submit to trigger errors
    await user.click(screen.getByText('Register'));
    expect(screen.getByText('Username must be at least 3 characters')).toBeInTheDocument();
    
    // Fix username
    await user.type(screen.getByLabelText('Username'), 'john_doe');
    await user.click(screen.getByText('Register'));
    
    // Username error should be gone
    expect(screen.queryByText('Username must be at least 3 characters')).not.toBeInTheDocument();
    // But other errors remain
    expect(screen.getByText('Invalid email')).toBeInTheDocument();
  });
  
  it('successfully submits with valid data', async () => {
    const user = userEvent.setup();
    
    render(<RegistrationForm />);
    
    // Fill form correctly
    await user.type(screen.getByLabelText('Username'), 'john_doe');
    await user.type(screen.getByLabelText('Email'), 'john@example.com');
    await user.type(screen.getByLabelText('Password'), 'SecurePass123');
    await user.type(screen.getByLabelText('Confirm Password'), 'SecurePass123');
    
    // Submit
    await user.click(screen.getByText('Register'));
    
    // Success message appears
    expect(screen.getByText('Registration successful!')).toBeInTheDocument();
  });
});
                

βœ… Integration Testing Best Practices

  • Test user workflows: Follow the steps a real user would take
  • Test state changes: Verify state updates propagate correctly
  • Test error states: Ensure error handling works across components
  • Use realistic data: Don't oversimplify test data
  • Test happy and sad paths: Both success and failure scenarios
  • Don't mock too much: Let components interact naturally

πŸ›£οΈ Testing Routing and Navigation

React Router is a critical part of most React applications. Integration tests should verify that routing works correctly and that navigation flows make sense.

Setting Up Router for Tests


import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen } from '@testing-library/react';

function renderWithRouter(
  ui: React.ReactElement,
  { initialEntries = ['/'] } = {}
) {
  return render(
    <MemoryRouter initialEntries={initialEntries}>
      {ui}
    </MemoryRouter>
  );
}
                

πŸ’‘ Why MemoryRouter?

MemoryRouter keeps routing history in memory instead of in the browser's address bar. This is perfect for tests because:

  • No interaction with actual browser history
  • Can set initial route with initialEntries
  • Tests remain isolated and don't affect each other
  • Faster than using BrowserRouter

Testing Basic Navigation


// App.tsx
function App() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
        <Link to="/contact">Contact</Link>
      </nav>
      
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/about" element={<AboutPage />} />
        <Route path="/contact" element={<ContactPage />} />
      </Routes>
    </div>
  );
}

// Test
describe('App Routing', () => {
  it('navigates between pages', async () => {
    const user = userEvent.setup();
    
    renderWithRouter(<App />);
    
    // Starts on home page
    expect(screen.getByText('Welcome to Home')).toBeInTheDocument();
    
    // Navigate to About
    await user.click(screen.getByText('About'));
    expect(screen.getByText('About Us')).toBeInTheDocument();
    
    // Navigate to Contact
    await user.click(screen.getByText('Contact'));
    expect(screen.getByText('Contact Us')).toBeInTheDocument();
    
    // Navigate back to Home
    await user.click(screen.getByText('Home'));
    expect(screen.getByText('Welcome to Home')).toBeInTheDocument();
  });
  
  it('renders correct page based on initial route', () => {
    // Start directly on About page
    renderWithRouter(<App />, { initialEntries: ['/about'] });
    
    expect(screen.getByText('About Us')).toBeInTheDocument();
  });
});
                

Testing Dynamic Routes


// BlogPost.tsx
function BlogPost() {
  const { id } = useParams<{ id: string }>();
  const [post, setPost] = React.useState<Post | null>(null);
  
  React.useEffect(() => {
    fetch(`/api/posts/${id}`)
      .then(res => res.json())
      .then(setPost);
  }, [id]);
  
  if (!post) return <div>Loading...</div>;
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

// Test
describe('BlogPost Routing', () => {
  it('fetches and displays correct post based on route param', async () => {
    server.use(
      rest.get('/api/posts/:id', (req, res, ctx) => {
        const { id } = req.params;
        return res(
          ctx.json({
            id,
            title: `Post ${id}`,
            content: `Content for post ${id}`
          })
        );
      })
    );
    
    renderWithRouter(
      <Routes>
        <Route path="/posts/:id" element={<BlogPost />} />
      </Routes>,
      { initialEntries: ['/posts/123'] }
    );
    
    expect(await screen.findByText('Post 123')).toBeInTheDocument();
    expect(screen.getByText('Content for post 123')).toBeInTheDocument();
  });
});
                

Testing Programmatic Navigation


// LoginForm.tsx
function LoginForm() {
  const navigate = useNavigate();
  const [credentials, setCredentials] = React.useState({
    username: '',
    password: ''
  });
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    });
    
    if (response.ok) {
      navigate('/dashboard');
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        placeholder="Username"
        value={credentials.username}
        onChange={e => setCredentials({...credentials, username: e.target.value})}
      />
      <input
        type="password"
        placeholder="Password"
        value={credentials.password}
        onChange={e => setCredentials({...credentials, password: e.target.value})}
      />
      <button type="submit">Login</button>
    </form>
  );
}

// Test
describe('LoginForm Navigation', () => {
  it('navigates to dashboard after successful login', async () => {
    const user = userEvent.setup();
    
    server.use(
      rest.post('/api/login', (req, res, ctx) => {
        return res(ctx.status(200));
      })
    );
    
    renderWithRouter(
      <Routes>
        <Route path="/login" element={<LoginForm />} />
        <Route path="/dashboard" element={<div>Dashboard</div>} />
      </Routes>,
      { initialEntries: ['/login'] }
    );
    
    // Fill and submit form
    await user.type(screen.getByPlaceholderText('Username'), 'john');
    await user.type(screen.getByPlaceholderText('Password'), 'password123');
    await user.click(screen.getByText('Login'));
    
    // Should navigate to dashboard
    expect(await screen.findByText('Dashboard')).toBeInTheDocument();
  });
  
  it('stays on login page after failed login', async () => {
    const user = userEvent.setup();
    
    server.use(
      rest.post('/api/login', (req, res, ctx) => {
        return res(ctx.status(401));
      })
    );
    
    renderWithRouter(
      <Routes>
        <Route path="/login" element={<LoginForm />} />
        <Route path="/dashboard" element={<div>Dashboard</div>} />
      </Routes>,
      { initialEntries: ['/login'] }
    );
    
    await user.type(screen.getByPlaceholderText('Username'), 'john');
    await user.type(screen.getByPlaceholderText('Password'), 'wrong');
    await user.click(screen.getByText('Login'));
    
    // Should still be on login page
    await waitFor(() => {
      expect(screen.getByText('Login')).toBeInTheDocument();
      expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
    });
  });
});
                

Testing Protected Routes


// ProtectedRoute.tsx
function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { isAuthenticated } = useAuth();
  
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  
  return <>{children}</>;
}

// Test
describe('ProtectedRoute', () => {
  it('redirects to login when not authenticated', () => {
    renderWithRouter(
      <AuthProvider value={{ isAuthenticated: false }}>
        <Routes>
          <Route path="/login" element={<div>Login Page</div>} />
          <Route
            path="/dashboard"
            element={
              <ProtectedRoute>
                <div>Dashboard</div>
              </ProtectedRoute>
            }
          />
        </Routes>
      </AuthProvider>,
      { initialEntries: ['/dashboard'] }
    );
    
    // Should redirect to login
    expect(screen.getByText('Login Page')).toBeInTheDocument();
    expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
  });
  
  it('renders protected content when authenticated', () => {
    renderWithRouter(
      <AuthProvider value={{ isAuthenticated: true }}>
        <Routes>
          <Route path="/login" element={<div>Login Page</div>} />
          <Route
            path="/dashboard"
            element={
              <ProtectedRoute>
                <div>Dashboard</div>
              </ProtectedRoute>
            }
          />
        </Routes>
      </AuthProvider>,
      { initialEntries: ['/dashboard'] }
    );
    
    // Should show dashboard
    expect(screen.getByText('Dashboard')).toBeInTheDocument();
    expect(screen.queryByText('Login Page')).not.toBeInTheDocument();
  });
});
                

Testing Search Parameters


// ProductSearch.tsx
function ProductSearch() {
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get('q') || '';
  const category = searchParams.get('category') || 'all';
  
  return (
    <div>
      <input
        value={query}
        onChange={e => setSearchParams({ q: e.target.value, category })}
        placeholder="Search..."
      />
      <select
        value={category}
        onChange={e => setSearchParams({ q: query, category: e.target.value })}
      >
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="books">Books</option>
      </select>
      <div>Query: {query}</div>
      <div>Category: {category}</div>
    </div>
  );
}

// Test
describe('ProductSearch URL Parameters', () => {
  it('reads initial search params from URL', () => {
    renderWithRouter(
      <Routes>
        <Route path="/search" element={<ProductSearch />} />
      </Routes>,
      { initialEntries: ['/search?q=laptop&category=electronics'] }
    );
    
    expect(screen.getByDisplayValue('laptop')).toBeInTheDocument();
    expect(screen.getByDisplayValue('electronics')).toBeInTheDocument();
    expect(screen.getByText('Query: laptop')).toBeInTheDocument();
    expect(screen.getByText('Category: electronics')).toBeInTheDocument();
  });
  
  it('updates URL when search changes', async () => {
    const user = userEvent.setup();
    
    renderWithRouter(
      <Routes>
        <Route path="/search" element={<ProductSearch />} />
      </Routes>,
      { initialEntries: ['/search'] }
    );
    
    // Type in search
    await user.type(screen.getByPlaceholderText('Search...'), 'phone');
    
    expect(screen.getByText('Query: phone')).toBeInTheDocument();
    
    // Change category
    await user.selectOptions(screen.getByRole('combobox'), 'books');
    
    expect(screen.getByText('Category: books')).toBeInTheDocument();
  });
});
                

βœ… Routing Testing Tips

  • Use MemoryRouter: Keeps tests isolated and fast
  • Set initialEntries: Start tests on specific routes
  • Test navigation: Verify users can move between pages
  • Test params: Check dynamic routes load correct data
  • Test redirects: Ensure protected routes redirect properly
  • Test search params: Verify URL state management works

πŸ—„οΈ Testing State Management

Global state management is a crucial part of modern React apps. Whether you're using Context, Redux, or Zustand, you need to test that state updates correctly across components.

Testing Context-Based State


// CartContext.tsx
interface CartItem {
  id: string;
  name: string;
  quantity: number;
}

interface CartContextType {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
}

const CartContext = React.createContext<CartContextType | null>(null);

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = React.useState<CartItem[]>([]);
  
  const addItem = (item: CartItem) => {
    setItems(prev => {
      const existing = prev.find(i => i.id === item.id);
      if (existing) {
        return prev.map(i =>
          i.id === item.id
            ? { ...i, quantity: i.quantity + item.quantity }
            : i
        );
      }
      return [...prev, item];
    });
  };
  
  const removeItem = (id: string) => {
    setItems(prev => prev.filter(i => i.id !== id));
  };
  
  const clearCart = () => {
    setItems([]);
  };
  
  return (
    <CartContext.Provider value={{ items, addItem, removeItem, clearCart }}>
      {children}
    </CartContext.Provider>
  );
}

export const useCart = () => {
  const context = React.useContext(CartContext);
  if (!context) throw new Error('useCart must be used within CartProvider');
  return context;
};

// Components using the context
function AddToCartButton({ product }: { product: { id: string; name: string } }) {
  const { addItem } = useCart();
  
  return (
    <button onClick={() => addItem({ ...product, quantity: 1 })}>
      Add {product.name} to Cart
    </button>
  );
}

function CartDisplay() {
  const { items, removeItem, clearCart } = useCart();
  
  return (
    <div>
      <h2>Cart ({items.length} items)</h2>
      {items.map(item => (
        <div key={item.id}>
          {item.name} (x{item.quantity})
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      {items.length > 0 && (
        <button onClick={clearCart}>Clear Cart</button>
      )}
    </div>
  );
}
                

Integration Test for Context State


describe('Cart Context Integration', () => {
  function renderWithCart(ui: React.ReactElement) {
    return render(
      <CartProvider>
        {ui}
      </CartProvider>
    );
  }
  
  it('adds items to cart from multiple components', async () => {
    const user = userEvent.setup();
    
    renderWithCart(
      <>
        <AddToCartButton product={{ id: '1', name: 'Laptop' }} />
        <AddToCartButton product={{ id: '2', name: 'Mouse' }} />
        <CartDisplay />
      </>
    );
    
    // Initially empty
    expect(screen.getByText('Cart (0 items)')).toBeInTheDocument();
    
    // Add laptop
    await user.click(screen.getByText('Add Laptop to Cart'));
    expect(screen.getByText('Cart (1 items)')).toBeInTheDocument();
    expect(screen.getByText('Laptop (x1)')).toBeInTheDocument();
    
    // Add mouse
    await user.click(screen.getByText('Add Mouse to Cart'));
    expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
    expect(screen.getByText('Mouse (x1)')).toBeInTheDocument();
    
    // Add laptop again (should increase quantity)
    await user.click(screen.getByText('Add Laptop to Cart'));
    expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
    expect(screen.getByText('Laptop (x2)')).toBeInTheDocument();
  });
  
  it('removes items from cart', async () => {
    const user = userEvent.setup();
    
    renderWithCart(
      <>
        <AddToCartButton product={{ id: '1', name: 'Laptop' }} />
        <AddToCartButton product={{ id: '2', name: 'Mouse' }} />
        <CartDisplay />
      </>
    );
    
    // Add items
    await user.click(screen.getByText('Add Laptop to Cart'));
    await user.click(screen.getByText('Add Mouse to Cart'));
    
    expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
    
    // Remove laptop
    const removeButtons = screen.getAllByText('Remove');
    await user.click(removeButtons[0]);
    
    expect(screen.getByText('Cart (1 items)')).toBeInTheDocument();
    expect(screen.queryByText('Laptop (x1)')).not.toBeInTheDocument();
    expect(screen.getByText('Mouse (x1)')).toBeInTheDocument();
  });
  
  it('clears entire cart', async () => {
    const user = userEvent.setup();
    
    renderWithCart(
      <>
        <AddToCartButton product={{ id: '1', name: 'Laptop' }} />
        <AddToCartButton product={{ id: '2', name: 'Mouse' }} />
        <CartDisplay />
      </>
    );
    
    // Add items
    await user.click(screen.getByText('Add Laptop to Cart'));
    await user.click(screen.getByText('Add Mouse to Cart'));
    
    expect(screen.getByText('Cart (2 items)')).toBeInTheDocument();
    
    // Clear cart
    await user.click(screen.getByText('Clear Cart'));
    
    expect(screen.getByText('Cart (0 items)')).toBeInTheDocument();
    expect(screen.queryByText('Laptop (x1)')).not.toBeInTheDocument();
    expect(screen.queryByText('Mouse (x1)')).not.toBeInTheDocument();
  });
});
                

Testing Redux/Redux Toolkit


// store.ts
import { configureStore, createSlice } from '@reduxjs/toolkit';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Todo[],
  reducers: {
    addTodo: (state, action) => {
      state.push({
        id: Date.now().toString(),
        text: action.payload,
        completed: false
      });
    },
    toggleTodo: (state, action) => {
      const todo = state.find(t => t.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    deleteTodo: (state, action) => {
      return state.filter(t => t.id !== action.payload);
    }
  }
});

export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions;

export function createTestStore() {
  return configureStore({
    reducer: {
      todos: todosSlice.reducer
    }
  });
}

// Test helper
import { Provider } from 'react-redux';

function renderWithRedux(
  ui: React.ReactElement,
  { store = createTestStore() } = {}
) {
  return {
    ...render(<Provider store={store}>{ui}</Provider>),
    store
  };
}

// TodoList component
function TodoList() {
  const dispatch = useDispatch();
  const todos = useSelector((state: RootState) => state.todos);
  const [input, setInput] = React.useState('');
  
  const handleAdd = () => {
    if (input.trim()) {
      dispatch(addTodo(input));
      setInput('');
    }
  };
  
  return (
    <div>
      <input
        value={input}
        onChange={e => setInput(e.target.value)}
        placeholder="Add todo..."
      />
      <button onClick={handleAdd}>Add</button>
      
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => dispatch(toggleTodo(todo.id))}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => dispatch(deleteTodo(todo.id))}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// Test
describe('Redux TodoList Integration', () => {
  it('adds and manages todos', async () => {
    const user = userEvent.setup();
    
    renderWithRedux(<TodoList />);
    
    // Add first todo
    await user.type(screen.getByPlaceholderText('Add todo...'), 'Buy milk');
    await user.click(screen.getByText('Add'));
    
    expect(screen.getByText('Buy milk')).toBeInTheDocument();
    
    // Add second todo
    await user.type(screen.getByPlaceholderText('Add todo...'), 'Walk dog');
    await user.click(screen.getByText('Add'));
    
    expect(screen.getByText('Walk dog')).toBeInTheDocument();
    
    // Toggle first todo
    const checkboxes = screen.getAllByRole('checkbox');
    await user.click(checkboxes[0]);
    
    const buyMilkText = screen.getByText('Buy milk');
    expect(buyMilkText).toHaveStyle({ textDecoration: 'line-through' });
    
    // Delete second todo
    const deleteButtons = screen.getAllByText('Delete');
    await user.click(deleteButtons[1]);
    
    expect(screen.queryByText('Walk dog')).not.toBeInTheDocument();
    expect(screen.getByText('Buy milk')).toBeInTheDocument();
  });
});
                

Testing Zustand Store


// store.ts
import { create } from 'zustand';

interface CounterStore {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 })
}));

// Counter component
function Counter() {
  const { count, increment, decrement, reset } = useCounterStore();
  
  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

// DisplayCount component (separate component using same store)
function DisplayCount() {
  const count = useCounterStore(state => state.count);
  
  return <div>Display: {count}</div>;
}

// Test
describe('Zustand Counter Integration', () => {
  beforeEach(() => {
    // Reset store before each test
    useCounterStore.setState({ count: 0 });
  });
  
  it('updates count across multiple components', async () => {
    const user = userEvent.setup();
    
    render(
      <>
        <Counter />
        <DisplayCount />
      </>
    );
    
    // Initial state
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
    expect(screen.getByText('Display: 0')).toBeInTheDocument();
    
    // Increment
    await user.click(screen.getByText('+'));
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
    expect(screen.getByText('Display: 1')).toBeInTheDocument();
    
    // Increment again
    await user.click(screen.getByText('+'));
    expect(screen.getByText('Count: 2')).toBeInTheDocument();
    expect(screen.getByText('Display: 2')).toBeInTheDocument();
    
    // Decrement
    await user.click(screen.getByText('-'));
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
    expect(screen.getByText('Display: 1')).toBeInTheDocument();
    
    // Reset
    await user.click(screen.getByText('Reset'));
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
    expect(screen.getByText('Display: 0')).toBeInTheDocument();
  });
});
                

πŸ’‘ State Management Testing Tips

  • Test state updates: Verify state changes propagate to all consumers
  • Test multiple components: Ensure different components see same state
  • Reset state: Clean slate for each test (especially Zustand)
  • Test actions/reducers: Verify state logic works correctly
  • Use test stores: Create fresh stores for Redux tests
  • Test selectors: Ensure derived state computes correctly

🧩 Testing Component Integration

Real applications have complex component hierarchies with data flowing in multiple directions. Integration tests should verify these interactions work correctly.

Testing Parent-Multiple Children Flow


// Dashboard.tsx
function Dashboard() {
  const [user, setUser] = React.useState<User | null>(null);
  const [notifications, setNotifications] = React.useState<Notification[]>([]);
  const [loading, setLoading] = React.useState(true);
  
  React.useEffect(() => {
    Promise.all([
      fetch('/api/user').then(r => r.json()),
      fetch('/api/notifications').then(r => r.json())
    ])
      .then(([userData, notificationData]) => {
        setUser(userData);
        setNotifications(notificationData);
        setLoading(false);
      });
  }, []);
  
  const markAsRead = (id: string) => {
    fetch(`/api/notifications/${id}/read`, { method: 'POST' })
      .then(() => {
        setNotifications(prev =>
          prev.map(n => n.id === id ? { ...n, read: true } : n)
        );
      });
  };
  
  if (loading) return <div>Loading dashboard...</div>;
  
  return (
    <div>
      <UserProfile user={user} />
      <NotificationList
        notifications={notifications}
        onMarkAsRead={markAsRead}
      />
      <ActivityFeed userId={user?.id} />
    </div>
  );
}

// Test
describe('Dashboard Integration', () => {
  it('loads and displays all dashboard components', async () => {
    server.use(
      rest.get('/api/user', (req, res, ctx) => {
        return res(
          ctx.json({
            id: '123',
            name: 'John Doe',
            email: 'john@example.com'
          })
        );
      }),
      rest.get('/api/notifications', (req, res, ctx) => {
        return res(
          ctx.json([
            { id: '1', message: 'New message', read: false },
            { id: '2', message: 'Update available', read: true }
          ])
        );
      }),
      rest.get('/api/users/123/activity', (req, res, ctx) => {
        return res(
          ctx.json([
            { id: '1', action: 'Posted comment' }
          ])
        );
      })
    );
    
    render(<Dashboard />);
    
    // Shows loading
    expect(screen.getByText('Loading dashboard...')).toBeInTheDocument();
    
    // All components load
    expect(await screen.findByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('john@example.com')).toBeInTheDocument();
    expect(screen.getByText('New message')).toBeInTheDocument();
    expect(screen.getByText('Update available')).toBeInTheDocument();
    expect(screen.getByText('Posted comment')).toBeInTheDocument();
  });
  
  it('marks notification as read and updates UI', async () => {
    const user = userEvent.setup();
    
    server.use(
      rest.get('/api/user', (req, res, ctx) => {
        return res(ctx.json({ id: '123', name: 'John' }));
      }),
      rest.get('/api/notifications', (req, res, ctx) => {
        return res(
          ctx.json([
            { id: '1', message: 'New message', read: false }
          ])
        );
      }),
      rest.post('/api/notifications/:id/read', (req, res, ctx) => {
        return res(ctx.status(200));
      })
    );
    
    render(<Dashboard />);
    
    // Wait for notification to appear
    const notification = await screen.findByText('New message');
    expect(notification).toHaveClass('unread'); // Assuming this class exists
    
    // Mark as read
    await user.click(screen.getByText('Mark as Read'));
    
    // UI updates
    await waitFor(() => {
      expect(notification).toHaveClass('read');
    });
  });
});
                

Testing Complex Data Flow


// FilterableProductList.tsx
function FilterableProductList() {
  const [products, setProducts] = React.useState<Product[]>([]);
  const [filters, setFilters] = React.useState({
    category: 'all',
    minPrice: 0,
    maxPrice: 1000,
    inStock: false
  });
  const [sortBy, setSortBy] = React.useState('name');
  
  React.useEffect(() => {
    fetch('/api/products')
      .then(r => r.json())
      .then(setProducts);
  }, []);
  
  const filteredProducts = products
    .filter(p => {
      if (filters.category !== 'all' && p.category !== filters.category) {
        return false;
      }
      if (p.price < filters.minPrice || p.price > filters.maxPrice) {
        return false;
      }
      if (filters.inStock && !p.inStock) {
        return false;
      }
      return true;
    })
    .sort((a, b) => {
      if (sortBy === 'name') return a.name.localeCompare(b.name);
      if (sortBy === 'price') return a.price - b.price;
      return 0;
    });
  
  return (
    <div>
      <FilterPanel filters={filters} onFiltersChange={setFilters} />
      <SortControls sortBy={sortBy} onSortChange={setSortBy} />
      <ProductGrid products={filteredProducts} />
      <ResultCount count={filteredProducts.length} total={products.length} />
    </div>
  );
}

// Test
describe('FilterableProductList Integration', () => {
  it('filters and sorts products correctly', async () => {
    const user = userEvent.setup();
    
    server.use(
      rest.get('/api/products', (req, res, ctx) => {
        return res(
          ctx.json([
            { id: '1', name: 'Laptop', price: 999, category: 'electronics', inStock: true },
            { id: '2', name: 'Book', price: 20, category: 'books', inStock: true },
            { id: '3', name: 'Phone', price: 699, category: 'electronics', inStock: false },
            { id: '4', name: 'Desk', price: 300, category: 'furniture', inStock: true }
          ])
        );
      })
    );
    
    render(<FilterableProductList />);
    
    // Wait for products to load
    await screen.findByText('Laptop');
    expect(screen.getByText('Showing 4 of 4 products')).toBeInTheDocument();
    
    // Filter by category
    await user.selectOptions(screen.getByLabelText('Category'), 'electronics');
    expect(screen.getByText('Showing 2 of 4 products')).toBeInTheDocument();
    expect(screen.getByText('Laptop')).toBeInTheDocument();
    expect(screen.getByText('Phone')).toBeInTheDocument();
    expect(screen.queryByText('Book')).not.toBeInTheDocument();
    
    // Filter by in stock
    await user.click(screen.getByLabelText('In Stock Only'));
    expect(screen.getByText('Showing 1 of 4 products')).toBeInTheDocument();
    expect(screen.getByText('Laptop')).toBeInTheDocument();
    expect(screen.queryByText('Phone')).not.toBeInTheDocument();
    
    // Change sort
    await user.selectOptions(screen.getByLabelText('Sort by'), 'price');
    
    // Products should be sorted (verify order if needed)
    const products = screen.getAllByRole('article');
    expect(products[0]).toHaveTextContent('Laptop');
  });
  
  it('clears filters and shows all products', async () => {
    const user = userEvent.setup();
    
    // ... setup
    
    render(<FilterableProductList />);
    
    await screen.findByText('Laptop');
    
    // Apply filters
    await user.selectOptions(screen.getByLabelText('Category'), 'electronics');
    expect(screen.getByText('Showing 2 of 4 products')).toBeInTheDocument();
    
    // Clear filters
    await user.click(screen.getByText('Clear Filters'));
    
    expect(screen.getByText('Showing 4 of 4 products')).toBeInTheDocument();
  });
});
                

Testing Modal and Dialog Interactions


// UserSettings.tsx
function UserSettings() {
  const [user, setUser] = React.useState({ name: 'John', email: 'john@example.com' });
  const [isEditing, setIsEditing] = React.useState(false);
  const [showDeleteModal, setShowDeleteModal] = React.useState(false);
  
  const handleSave = (newData: typeof user) => {
    fetch('/api/user', {
      method: 'PUT',
      body: JSON.stringify(newData)
    })
      .then(r => r.json())
      .then(data => {
        setUser(data);
        setIsEditing(false);
      });
  };
  
  const handleDelete = () => {
    fetch('/api/user', { method: 'DELETE' })
      .then(() => {
        // Redirect or show success
      });
  };
  
  return (
    <div>
      <h1>User Settings</h1>
      
      {!isEditing ? (
        <div>
          <p>Name: {user.name}</p>
          <p>Email: {user.email}</p>
          <button onClick={() => setIsEditing(true)}>Edit</button>
          <button onClick={() => setShowDeleteModal(true)}>Delete Account</button>
        </div>
      ) : (
        <EditForm
          user={user}
          onSave={handleSave}
          onCancel={() => setIsEditing(false)}
        />
      )}
      
      {showDeleteModal && (
        <ConfirmDialog
          title="Delete Account"
          message="Are you sure? This cannot be undone."
          onConfirm={handleDelete}
          onCancel={() => setShowDeleteModal(false)}
        />
      )}
    </div>
  );
}

// Test
describe('UserSettings Integration', () => {
  it('edits user information', async () => {
    const user = userEvent.setup();
    
    server.use(
      rest.put('/api/user', async (req, res, ctx) => {
        const body = await req.json();
        return res(ctx.json(body));
      })
    );
    
    render(<UserSettings />);
    
    // Initial view
    expect(screen.getByText('Name: John')).toBeInTheDocument();
    
    // Enter edit mode
    await user.click(screen.getByText('Edit'));
    
    // Form appears
    expect(screen.getByLabelText('Name')).toBeInTheDocument();
    
    // Change name
    const nameInput = screen.getByLabelText('Name');
    await user.clear(nameInput);
    await user.type(nameInput, 'Jane Doe');
    
    // Save
    await user.click(screen.getByText('Save'));
    
    // Returns to view mode with updated data
    expect(await screen.findByText('Name: Jane Doe')).toBeInTheDocument();
    expect(screen.queryByLabelText('Name')).not.toBeInTheDocument();
  });
  
  it('shows confirmation before deleting account', async () => {
    const user = userEvent.setup();
    
    server.use(
      rest.delete('/api/user', (req, res, ctx) => {
        return res(ctx.status(200));
      })
    );
    
    render(<UserSettings />);
    
    // Click delete
    await user.click(screen.getByText('Delete Account'));
    
    // Modal appears
    expect(screen.getByText('Are you sure? This cannot be undone.')).toBeInTheDocument();
    
    // Cancel
    await user.click(screen.getByText('Cancel'));
    
    // Modal closes, still on settings
    expect(screen.queryByText('Are you sure?')).not.toBeInTheDocument();
    expect(screen.getByText('Name: John')).toBeInTheDocument();
    
    // Try again and confirm
    await user.click(screen.getByText('Delete Account'));
    await user.click(screen.getByText('Confirm'));
    
    // Account deleted (verify redirect or message)
  });
});
                

βœ… Component Integration Testing Tips

  • Test complete flows: From user action to final UI update
  • Test data propagation: Verify data flows through component tree
  • Test conditional rendering: Different UI states based on data
  • Test error boundaries: Error handling across components
  • Test loading states: Multiple components loading simultaneously
  • Test user workflows: Multi-step processes across components

🌐 Introduction to E2E Testing

End-to-End (E2E) testing is the highest level of testing. Unlike unit and integration tests that run in Node.js, E2E tests run in a real browser and test your entire application from the user's perspective.

πŸ“– What is E2E Testing?

End-to-End testing simulates real user scenarios by automating browser interactions. Tests click buttons, fill forms, navigate pages, and verify the application behaves correctlyβ€”just like a real user would.

Why E2E Tests Matter

E2E tests provide the highest confidence that your application works because they test:

  • Real browser environment: Actual rendering, JavaScript execution, CSS layout
  • Complete workflows: Multi-page journeys from start to finish
  • Network interactions: Real API calls (or realistic mocks)
  • Browser APIs: localStorage, cookies, navigation, history
  • User experience: Visual layout, responsive design, animations

E2E vs Integration Tests

Aspect Integration Tests E2E Tests
Environment jsdom (simulated browser) Real browser (Chrome, Firefox, etc.)
Network Mocked with MSW Real or mocked at network level
Routing MemoryRouter Real browser routing
CSS/Layout Not tested Fully tested
Speed Fast (1-2 seconds) Slower (5-30 seconds)
Use case Feature-level testing Critical user workflows

Popular E2E Testing Tools

graph TB A[E2E Testing Tools] A --> B[Playwright] A --> C[Cypress] A --> D[Selenium] B --> E[Modern, Fast
Multiple Browsers
TypeScript First] C --> F[Developer Friendly
Great DX
Time Travel Debug] D --> G[Industry Standard
Wide Support
Older Tech] style B fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style C fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style D fill:#868e96,stroke:#495057,stroke-width:2px,color:#fff

Choosing Between Playwright and Cypress

Feature Playwright Cypress
Browser Support Chrome, Firefox, Safari, Edge Chrome, Firefox, Edge (Safari experimental)
Speed Very fast, parallel execution Fast, but slower than Playwright
TypeScript Excellent, built-in Good, needs setup
API Testing Native support Via plugins
Learning Curve Moderate Easy
Debugging Good (trace viewer) Excellent (time travel)
Mobile Testing Device emulation Viewport only

πŸ’‘ Which Should You Choose?

Choose Playwright if:

  • You need to test across multiple browsers
  • You want the fastest test execution
  • You need mobile device testing
  • You're starting a new project

Choose Cypress if:

  • You value developer experience and debugging tools
  • You want the easiest learning curve
  • You're already using Cypress
  • Chrome/Firefox support is sufficient

What to Test with E2E

E2E tests are expensive, so focus on:

βœ… Critical User Paths

  • Authentication: Login, signup, logout, password reset
  • Core features: The main value proposition of your app
  • Payment flows: Checkout, subscriptions, billing
  • Data creation: Creating posts, products, profiles
  • Happy paths: The most common user journeys

❌ Don't Test with E2E

  • Edge cases (use unit tests)
  • Error handling (use integration tests)
  • Complex calculations (use unit tests)
  • Every possible path (too expensive)
  • UI variations (use visual regression tests)
πŸ’¬ E2E Testing Philosophy: "Test the critical paths that, if broken, would prevent users from getting value from your application. Everything else should be covered by faster, cheaper tests."

🎭 Playwright Setup and Basics

Playwright is a modern E2E testing framework created by Microsoft. It's fast, reliable, and has excellent TypeScript support out of the box.

Installing Playwright


# Install Playwright
npm init playwright@latest

# Or with existing project
npm install -D @playwright/test

# Install browsers
npx playwright install
                

Project Structure


my-app/
β”œβ”€β”€ e2e/
β”‚   β”œβ”€β”€ auth.spec.ts
β”‚   β”œβ”€β”€ shopping.spec.ts
β”‚   └── profile.spec.ts
β”œβ”€β”€ playwright.config.ts
└── package.json
                

Basic Playwright Configuration


// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  
  // Run tests in parallel
  fullyParallel: true,
  
  // Fail the build on CI if you accidentally left test.only
  forbidOnly: !!process.env.CI,
  
  // Retry on CI only
  retries: process.env.CI ? 2 : 0,
  
  // Reporter
  reporter: 'html',
  
  use: {
    // Base URL for your app
    baseURL: 'http://localhost:5173',
    
    // Collect trace on failure
    trace: 'on-first-retry',
    
    // Screenshot on failure
    screenshot: 'only-on-failure',
  },
  
  // Configure projects for different browsers
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  
  // Start dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});
                

Your First Playwright Test


// e2e/example.spec.ts
import { test, expect } from '@playwright/test';

test('homepage has title and heading', async ({ page }) => {
  // Navigate to the page
  await page.goto('/');
  
  // Check page title
  await expect(page).toHaveTitle(/My App/);
  
  // Check for heading
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
                

Basic Playwright Actions


test('user can interact with the page', async ({ page }) => {
  await page.goto('/');
  
  // Click a button
  await page.getByRole('button', { name: 'Click me' }).click();
  
  // Fill an input
  await page.getByLabel('Email').fill('user@example.com');
  
  // Select from dropdown
  await page.getByLabel('Country').selectOption('US');
  
  // Check a checkbox
  await page.getByLabel('I agree').check();
  
  // Wait for element to appear
  await page.getByText('Success!').waitFor();
  
  // Check element is visible
  await expect(page.getByText('Welcome!')).toBeVisible();
  
  // Check input value
  await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
});
                

Playwright Locators

Playwright has powerful locator strategies that prioritize accessibility:


// By role (BEST - most accessible)
page.getByRole('button', { name: 'Submit' })
page.getByRole('textbox', { name: 'Username' })
page.getByRole('link', { name: 'Contact Us' })

// By label (GOOD - accessible)
page.getByLabel('Email address')

// By placeholder
page.getByPlaceholder('Enter your email')

// By text
page.getByText('Welcome back')

// By test ID (use when others don't work)
page.getByTestId('submit-button')

// By CSS selector (AVOID - brittle)
page.locator('.submit-btn')
                

Testing a Login Flow


// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('user can login successfully', async ({ page }) => {
    // Navigate to login page
    await page.goto('/login');
    
    // Fill in credentials
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('SecurePass123');
    
    // Submit form
    await page.getByRole('button', { name: 'Login' }).click();
    
    // Wait for navigation to dashboard
    await page.waitForURL('/dashboard');
    
    // Verify we're logged in
    await expect(page.getByText('Welcome back, User!')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
  });
  
  test('shows error with invalid credentials', async ({ page }) => {
    await page.goto('/login');
    
    await page.getByLabel('Email').fill('wrong@example.com');
    await page.getByLabel('Password').fill('wrongpass');
    await page.getByRole('button', { name: 'Login' }).click();
    
    // Should stay on login page
    await expect(page).toHaveURL('/login');
    
    // Should show error message
    await expect(page.getByText('Invalid email or password')).toBeVisible();
  });
  
  test('validates required fields', async ({ page }) => {
    await page.goto('/login');
    
    // Try to submit without filling fields
    await page.getByRole('button', { name: 'Login' }).click();
    
    // Should show validation errors
    await expect(page.getByText('Email is required')).toBeVisible();
    await expect(page.getByText('Password is required')).toBeVisible();
  });
});
                

Testing Multi-Step Forms


test('completes multi-step registration', async ({ page }) => {
  await page.goto('/register');
  
  // Step 1: Personal info
  await page.getByLabel('First Name').fill('John');
  await page.getByLabel('Last Name').fill('Doe');
  await page.getByLabel('Email').fill('john@example.com');
  await page.getByRole('button', { name: 'Next' }).click();
  
  // Wait for step 2
  await expect(page.getByText('Step 2: Account Details')).toBeVisible();
  
  // Step 2: Account details
  await page.getByLabel('Username').fill('johndoe');
  await page.getByLabel('Password').fill('SecurePass123');
  await page.getByLabel('Confirm Password').fill('SecurePass123');
  await page.getByRole('button', { name: 'Next' }).click();
  
  // Wait for step 3
  await expect(page.getByText('Step 3: Preferences')).toBeVisible();
  
  // Step 3: Preferences
  await page.getByLabel('Newsletter').check();
  await page.getByLabel('Language').selectOption('en');
  await page.getByRole('button', { name: 'Complete Registration' }).click();
  
  // Success!
  await page.waitForURL('/dashboard');
  await expect(page.getByText('Registration successful!')).toBeVisible();
});
                

Testing Navigation


test('navigates through the app', async ({ page }) => {
  await page.goto('/');
  
  // Check we're on home page
  await expect(page).toHaveURL('/');
  await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
  
  // Navigate to products
  await page.getByRole('link', { name: 'Products' }).click();
  await expect(page).toHaveURL('/products');
  await expect(page.getByRole('heading', { name: 'Our Products' })).toBeVisible();
  
  // Navigate to specific product
  await page.getByRole('link', { name: 'Laptop' }).click();
  await expect(page).toHaveURL(/\/products\/\d+/);
  await expect(page.getByRole('heading', { name: 'Laptop' })).toBeVisible();
  
  // Use browser back button
  await page.goBack();
  await expect(page).toHaveURL('/products');
  
  // Use browser forward button
  await page.goForward();
  await expect(page).toHaveURL(/\/products\/\d+/);
});
                

Working with API Requests


test('mocks API responses', async ({ page }) => {
  // Mock the API call
  await page.route('/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Laptop', price: 999 },
        { id: 2, name: 'Mouse', price: 29 }
      ])
    });
  });
  
  await page.goto('/products');
  
  // Products should be displayed from mock data
  await expect(page.getByText('Laptop')).toBeVisible();
  await expect(page.getByText('$999')).toBeVisible();
});

test('waits for API response', async ({ page }) => {
  await page.goto('/products');
  
  // Wait for API call to complete
  const response = await page.waitForResponse('/api/products');
  expect(response.status()).toBe(200);
  
  // Products should be loaded
  await expect(page.getByRole('article').first()).toBeVisible();
});
                

βœ… Playwright Best Practices

  • Use role-based selectors: More accessible and stable
  • Wait automatically: Playwright auto-waits, don't add manual waits
  • Use page.goto() carefully: Only navigate when needed
  • Mock external APIs: Make tests deterministic
  • Use test.describe(): Group related tests
  • Take screenshots: On failure for debugging

🌲 Cypress Introduction

Cypress is another popular E2E testing framework known for its excellent developer experience and powerful debugging capabilities.

Installing Cypress


# Install Cypress
npm install -D cypress

# Open Cypress for first time
npx cypress open
                

Project Structure


my-app/
β”œβ”€β”€ cypress/
β”‚   β”œβ”€β”€ e2e/
β”‚   β”‚   β”œβ”€β”€ auth.cy.ts
β”‚   β”‚   └── shopping.cy.ts
β”‚   β”œβ”€β”€ fixtures/
β”‚   β”‚   └── users.json
β”‚   β”œβ”€β”€ support/
β”‚   β”‚   β”œβ”€β”€ commands.ts
β”‚   β”‚   └── e2e.ts
β”œβ”€β”€ cypress.config.ts
└── package.json
                

Basic Cypress Configuration


// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:5173',
    viewportWidth: 1280,
    viewportHeight: 720,
    
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
});
                

Your First Cypress Test


// cypress/e2e/example.cy.ts
describe('Homepage', () => {
  it('displays welcome message', () => {
    cy.visit('/');
    cy.contains('h1', 'Welcome').should('be.visible');
  });
});
                

Basic Cypress Commands


describe('User interactions', () => {
  it('can interact with elements', () => {
    cy.visit('/');
    
    // Click a button
    cy.contains('button', 'Click me').click();
    
    // Fill an input
    cy.get('input[name="email"]').type('user@example.com');
    
    // Select from dropdown
    cy.get('select[name="country"]').select('US');
    
    // Check a checkbox
    cy.get('input[type="checkbox"]').check();
    
    // Assert element is visible
    cy.contains('Success!').should('be.visible');
    
    // Assert input value
    cy.get('input[name="email"]').should('have.value', 'user@example.com');
  });
});
                

Cypress Selectors


// By text content
cy.contains('Login')
cy.contains('button', 'Submit')

// By CSS selector
cy.get('.submit-button')
cy.get('#email-input')

// By attribute
cy.get('[data-cy="submit"]')
cy.get('[type="email"]')

// By multiple criteria
cy.get('button').contains('Submit')
                

Testing a Login Flow with Cypress


// cypress/e2e/auth.cy.ts
describe('Authentication', () => {
  beforeEach(() => {
    cy.visit('/login');
  });
  
  it('logs in successfully', () => {
    cy.get('[data-cy="email"]').type('user@example.com');
    cy.get('[data-cy="password"]').type('SecurePass123');
    cy.contains('button', 'Login').click();
    
    // Should redirect to dashboard
    cy.url().should('include', '/dashboard');
    cy.contains('Welcome back').should('be.visible');
  });
  
  it('shows error with invalid credentials', () => {
    cy.get('[data-cy="email"]').type('wrong@example.com');
    cy.get('[data-cy="password"]').type('wrongpass');
    cy.contains('button', 'Login').click();
    
    // Should stay on login
    cy.url().should('include', '/login');
    cy.contains('Invalid credentials').should('be.visible');
  });
});
                

Custom Cypress Commands


// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>
      logout(): Chainable<void>
    }
  }
}

Cypress.Commands.add('login', (email: string, password: string) => {
  cy.visit('/login');
  cy.get('[data-cy="email"]').type(email);
  cy.get('[data-cy="password"]').type(password);
  cy.contains('button', 'Login').click();
  cy.url().should('include', '/dashboard');
});

Cypress.Commands.add('logout', () => {
  cy.contains('button', 'Logout').click();
  cy.url().should('include', '/login');
});

// Use in tests
describe('Protected pages', () => {
  it('requires authentication', () => {
    cy.login('user@example.com', 'SecurePass123');
    
    // Now logged in, can access protected page
    cy.visit('/profile');
    cy.contains('My Profile').should('be.visible');
    
    cy.logout();
  });
});
                

Working with API in Cypress


describe('Products', () => {
  it('loads products from API', () => {
    // Intercept API call
    cy.intercept('GET', '/api/products', {
      statusCode: 200,
      body: [
        { id: 1, name: 'Laptop', price: 999 },
        { id: 2, name: 'Mouse', price: 29 }
      ]
    }).as('getProducts');
    
    cy.visit('/products');
    
    // Wait for API call
    cy.wait('@getProducts');
    
    // Products should be displayed
    cy.contains('Laptop').should('be.visible');
    cy.contains('$999').should('be.visible');
  });
  
  it('adds product to cart', () => {
    cy.intercept('POST', '/api/cart', {
      statusCode: 201,
      body: { success: true }
    }).as('addToCart');
    
    cy.visit('/products/1');
    cy.contains('button', 'Add to Cart').click();
    
    cy.wait('@addToCart');
    cy.contains('Added to cart').should('be.visible');
  });
});
                

πŸ’‘ Playwright vs Cypress: Quick Comparison

Feature Playwright Cypress
Syntax await page.click() cy.get().click()
Auto-wait Built-in Built-in
Debugging Trace viewer Time travel, better DX
Speed Faster Slower

🎯 E2E Testing Patterns

Let's explore common patterns and best practices for writing maintainable E2E tests.

Page Object Model (POM)

The Page Object Model organizes selectors and actions into reusable classes:


// pages/LoginPage.ts
import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}
  
  // Selectors
  get emailInput() {
    return this.page.getByLabel('Email');
  }
  
  get passwordInput() {
    return this.page.getByLabel('Password');
  }
  
  get submitButton() {
    return this.page.getByRole('button', { name: 'Login' });
  }
  
  get errorMessage() {
    return this.page.getByRole('alert');
  }
  
  // Actions
  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 getErrorText() {
    return await this.errorMessage.textContent();
  }
}

// Use in test
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  
  await loginPage.goto();
  await loginPage.login('user@example.com', 'SecurePass123');
  
  await expect(page).toHaveURL('/dashboard');
});
                

Test Fixtures for Setup


// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

type AuthFixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('user@example.com', 'SecurePass123');
    
    await use(page);
  },
});

// Use in test
test('authenticated user can access profile', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/profile');
  await expect(authenticatedPage.getByText('My Profile')).toBeVisible();
});
                

Testing Complete User Journeys


test('complete shopping journey', async ({ page }) => {
  // 1. Browse products
  await page.goto('/products');
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
  
  // 2. Search for product
  await page.getByPlaceholder('Search products').fill('laptop');
  await page.getByRole('button', { name: 'Search' }).click();
  await expect(page.getByText('Gaming Laptop')).toBeVisible();
  
  // 3. View product details
  await page.getByText('Gaming Laptop').click();
  await expect(page).toHaveURL(/\/products\/\d+/);
  await expect(page.getByRole('heading', { name: 'Gaming Laptop' })).toBeVisible();
  
  // 4. Add to cart
  await page.getByRole('button', { name: 'Add to Cart' }).click();
  await expect(page.getByText('Added to cart')).toBeVisible();
  
  // 5. Go to cart
  await page.getByRole('link', { name: 'Cart' }).click();
  await expect(page).toHaveURL('/cart');
  await expect(page.getByText('Gaming Laptop')).toBeVisible();
  
  // 6. Proceed to checkout
  await page.getByRole('button', { name: 'Checkout' }).click();
  await expect(page).toHaveURL('/checkout');
  
  // 7. Fill shipping info
  await page.getByLabel('Full Name').fill('John Doe');
  await page.getByLabel('Address').fill('123 Main St');
  await page.getByLabel('City').fill('New York');
  await page.getByLabel('Zip Code').fill('10001');
  
  // 8. Fill payment info
  await page.getByLabel('Card Number').fill('4111111111111111');
  await page.getByLabel('Expiry').fill('12/25');
  await page.getByLabel('CVV').fill('123');
  
  // 9. Complete order
  await page.getByRole('button', { name: 'Place Order' }).click();
  
  // 10. Verify success
  await expect(page).toHaveURL(/\/orders\/\d+/);
  await expect(page.getByText('Order confirmed!')).toBeVisible();
  await expect(page.getByText('Order #')).toBeVisible();
});
                

Testing Responsive Design


test.describe('Responsive navigation', () => {
  test('shows desktop menu on large screen', async ({ page }) => {
    await page.setViewportSize({ width: 1920, height: 1080 });
    await page.goto('/');
    
    // Desktop menu visible
    await expect(page.getByRole('navigation')).toBeVisible();
    await expect(page.getByRole('link', { name: 'Products' })).toBeVisible();
    
    // Mobile menu not visible
    await expect(page.getByRole('button', { name: 'Menu' })).not.toBeVisible();
  });
  
  test('shows mobile menu on small screen', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');
    
    // Mobile menu button visible
    await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
    
    // Menu items hidden initially
    await expect(page.getByRole('link', { name: 'Products' })).not.toBeVisible();
    
    // Open mobile menu
    await page.getByRole('button', { name: 'Menu' }).click();
    
    // Menu items now visible
    await expect(page.getByRole('link', { name: 'Products' })).toBeVisible();
  });
});
                

Testing File Uploads


test('uploads profile picture', async ({ page }) => {
  await page.goto('/profile');
  
  // Prepare file to upload
  const filePath = path.join(__dirname, 'fixtures', 'avatar.png');
  
  // Upload file
  await page.getByLabel('Profile Picture').setInputFiles(filePath);
  
  // Submit
  await page.getByRole('button', { name: 'Save' }).click();
  
  // Verify upload
  await expect(page.getByText('Profile updated')).toBeVisible();
  
  // Check image is displayed
  const img = page.getByRole('img', { name: 'Profile picture' });
  await expect(img).toBeVisible();
});
                

βœ… E2E Testing Patterns Best Practices

  • Use Page Object Model: Encapsulate page logic
  • Create reusable fixtures: DRY principle for setup
  • Test complete journeys: End-to-end user flows
  • Test responsive behavior: Multiple viewport sizes
  • Use test data factories: Generate realistic test data
  • Clean up after tests: Delete created data

πŸ”„ CI/CD Testing Strategies

Running tests in Continuous Integration ensures your application stays healthy as the codebase grows.

GitHub Actions Example


# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  unit-and-integration:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run unit tests
        run: npm run test:unit
      
      - name: Run integration tests
        run: npm run test:integration
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
  
  e2e:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright
        run: npx playwright install --with-deps
      
      - name: Run E2E tests
        run: npm run test:e2e
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/
                

Test Pyramid in CI/CD

graph TB A[Git Push] --> B[CI Pipeline Starts] B --> C[Unit Tests: 2-3 min] C --> D{Pass?} D -->|No| E[❌ Pipeline Fails] D -->|Yes| F[Integration Tests: 5-10 min] F --> G{Pass?} G -->|No| E G -->|Yes| H[E2E Tests: 10-20 min] H --> I{Pass?} I -->|No| E I -->|Yes| J[βœ… Deploy] style C fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style F fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style H fill:#f59f00,stroke:#e67700,stroke-width:2px,color:#fff style E fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px,color:#fff style J fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff

Parallel Test Execution


// playwright.config.ts
export default defineConfig({
  // Run tests in parallel
  workers: process.env.CI ? 2 : undefined,
  
  // Shard tests across machines in CI
  shard: process.env.CI
    ? { current: 1, total: 4 }
    : undefined,
    
  // Retry failed tests
  retries: process.env.CI ? 2 : 0,
});
                

Test Reporting


// playwright.config.ts
export default defineConfig({
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results.json' }],
    ['junit', { outputFile: 'test-results.xml' }],
    process.env.CI ? ['github'] : ['list']
  ],
});
                

Managing Test Data in CI


// Use environment-specific test data
test.beforeEach(async ({ page }) => {
  // Seed database with test data
  if (process.env.CI) {
    await fetch(`${process.env.API_URL}/test/seed`, {
      method: 'POST',
      headers: { 'X-Test-Token': process.env.TEST_TOKEN }
    });
  }
});

test.afterEach(async () => {
  // Clean up test data
  if (process.env.CI) {
    await fetch(`${process.env.API_URL}/test/cleanup`, {
      method: 'POST',
      headers: { 'X-Test-Token': process.env.TEST_TOKEN }
    });
  }
});
                

πŸ’‘ CI/CD Testing Best Practices

  • Fast feedback: Run unit tests first, E2E last
  • Parallel execution: Speed up test runs
  • Retry flaky tests: Auto-retry up to 2 times
  • Cache dependencies: Faster pipeline runs
  • Record artifacts: Screenshots, videos, traces
  • Monitor test health: Track flaky tests

πŸ‹οΈ Hands-on Exercises

Practice your integration and E2E testing skills with these comprehensive exercises.

Exercise 1: Test a Shopping Cart Feature

Task: Write integration tests for a shopping cart that spans multiple components.

Requirements:

  • Test adding products to cart from product list
  • Test updating quantities in cart
  • Test removing items from cart
  • Test cart total calculation
  • Test cart persistence (Context or Redux)
  • Test empty cart state
πŸ’‘ Hint

Create a test helper that renders your components within the cart provider. Test the complete flow from product list β†’ add to cart β†’ cart summary β†’ remove items. Verify state updates across all components.

βœ… Solution Approach

describe('Shopping Cart Integration', () => {
  function renderWithCart(ui: React.ReactElement) {
    return render(
      <CartProvider>
        {ui}
      </CartProvider>
    );
  }
  
  it('adds products and updates cart across components', async () => {
    const user = userEvent.setup();
    
    renderWithCart(
      <>
        <ProductList />
        <Cart />
      </>
    );
    
    // Add product
    await user.click(screen.getAllByText('Add to Cart')[0]);
    
    // Verify cart updates
    expect(screen.getByText('1 items')).toBeInTheDocument();
    
    // Update quantity
    await user.click(screen.getByLabelText('Increase quantity'));
    expect(screen.getByText('2 items')).toBeInTheDocument();
    
    // Remove item
    await user.click(screen.getByText('Remove'));
    expect(screen.getByText('0 items')).toBeInTheDocument();
    expect(screen.getByText('Your cart is empty')).toBeInTheDocument();
  });
});
                        

Exercise 2: Test Multi-Page User Journey with E2E

Task: Write a Playwright test for a complete user registration and profile setup flow.

User Journey:

  1. User visits homepage
  2. Clicks "Sign Up"
  3. Fills registration form
  4. Confirms email (mock)
  5. Completes profile setup
  6. Uploads profile picture
  7. Views completed profile
πŸ’‘ Hint

Use Page Object Model to organize each page's actions. Mock the email confirmation endpoint. Test each step of the journey, verifying navigation and data persistence between pages.

βœ… Solution Approach

test('complete user registration journey', async ({ page }) => {
  // 1. Homepage
  await page.goto('/');
  await page.getByRole('link', { name: 'Sign Up' }).click();
  
  // 2. Registration
  await expect(page).toHaveURL('/register');
  await page.getByLabel('Email').fill('newuser@example.com');
  await page.getByLabel('Password').fill('SecurePass123');
  await page.getByLabel('Confirm Password').fill('SecurePass123');
  await page.getByRole('button', { name: 'Create Account' }).click();
  
  // 3. Email confirmation (mock)
  await page.route('/api/auth/verify', route => {
    route.fulfill({ status: 200, body: JSON.stringify({ verified: true }) });
  });
  await expect(page).toHaveURL('/verify-email');
  
  // 4. Profile setup
  await expect(page).toHaveURL('/profile/setup');
  await page.getByLabel('Display Name').fill('John Doe');
  await page.getByLabel('Bio').fill('Software developer');
  
  // 5. Upload picture
  await page.getByLabel('Profile Picture').setInputFiles('fixtures/avatar.png');
  await page.getByRole('button', { name: 'Complete Setup' }).click();
  
  // 6. View profile
  await expect(page).toHaveURL('/profile');
  await expect(page.getByText('John Doe')).toBeVisible();
  await expect(page.getByText('Software developer')).toBeVisible();
});
                        

Exercise 3: Test Authentication Flow with Protected Routes

Task: Write integration tests for authentication with route protection.

Requirements:

  • Test login redirects to dashboard
  • Test logout redirects to login
  • Test protected routes redirect unauthenticated users
  • Test authenticated users can access protected routes
  • Test auth state persists across page navigation
  • Test session expiration
πŸ’‘ Hint

Use MemoryRouter with AuthProvider. Test both successful and failed login attempts. Verify that navigation to protected routes triggers appropriate redirects based on auth state.

Exercise 4: Test Real-Time Features

Task: Write tests for a chat application with real-time messages.

Requirements:

  • Test sending messages
  • Test receiving messages (mock WebSocket)
  • Test message list updates
  • Test typing indicators
  • Test online/offline status
πŸ’‘ Hint

Mock WebSocket connection. Use fake timers for typing indicators. Test that messages appear in the correct order and that UI updates reflect connection status.

⭐ Best Practices

Follow these guidelines to write maintainable, reliable integration and E2E tests.

1. Follow the Testing Pyramid

βœ… Do This

  • 70-80% unit tests (fast, focused)
  • 15-25% integration tests (component interactions)
  • 5-10% E2E tests (critical user paths)

2. Test User Behavior, Not Implementation

βœ… Do This


// Test what the user sees and does
test('user can submit form', async () => {
  await user.type(screen.getByLabelText('Email'), 'test@example.com');
  await user.click(screen.getByRole('button', { name: 'Submit' }));
  expect(screen.getByText('Success!')).toBeVisible();
});
                    

❌ Don't Do This


// Don't test implementation details
test('form submission calls handleSubmit', () => {
  const handleSubmit = vi.fn();
  render(<Form onSubmit={handleSubmit} />);
  // Testing internal function calls
});
                    

3. Use Realistic Test Data


// βœ… Good - realistic data
const testUser = {
  name: 'John Doe',
  email: 'john.doe@example.com',
  age: 32,
  address: {
    street: '123 Main St',
    city: 'New York',
    zip: '10001'
  }
};

// ❌ Bad - oversimplified
const testUser = {
  name: 'test',
  email: 'test@test.com'
};
                

4. Keep Tests Independent


// βœ… Good - each test sets up its own state
describe('Todo List', () => {
  test('adds todo', async () => {
    render(<TodoList />);
    // Fresh state, independent test
  });
  
  test('deletes todo', async () => {
    render(<TodoList />);
    // Fresh state, independent test
  });
});

// ❌ Bad - tests depend on each other
describe('Todo List', () => {
  let todoId: string;
  
  test('adds todo', async () => {
    // Sets todoId for next test
  });
  
  test('deletes todo', async () => {
    // Depends on previous test
  });
});
                

5. Use Page Object Model for E2E


// βœ… Good - encapsulated page logic
class LoginPage {
  constructor(private page: Page) {}
  
  async login(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Login' }).click();
  }
}

test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.login('user@example.com', 'pass123');
});

// ❌ Bad - repeated selectors
test('user can login', async ({ page }) => {
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('pass123');
  await page.getByRole('button', { name: 'Login' }).click();
});
                

6. Mock External Dependencies


// βœ… Good - mock external APIs
test('loads user data', async () => {
  server.use(
    rest.get('/api/user', (req, res, ctx) => {
      return res(ctx.json({ name: 'John' }));
    })
  );
  
  render(<UserProfile />);
  expect(await screen.findByText('John')).toBeVisible();
});
                

7. Use Descriptive Test Names


// βœ… Good - clear what's being tested
test('displays validation error when email is invalid', () => {});
test('redirects to dashboard after successful login', () => {});
test('shows loading spinner while fetching products', () => {});

// ❌ Bad - vague test names
test('works', () => {});
test('test login', () => {});
test('should render', () => {});
                

8. Clean Up After Tests


describe('Database tests', () => {
  beforeEach(async () => {
    await seedDatabase();
  });
  
  afterEach(async () => {
    await cleanDatabase();
  });
  
  test('creates user', async () => {
    // Test runs with clean database
  });
});
                

9. Handle Async Operations Properly


// βœ… Good - wait for async operations
test('loads data', async () => {
  render(<DataComponent />);
  expect(await screen.findByText('Loaded')).toBeVisible();
});

// ❌ Bad - no async handling
test('loads data', () => {
  render(<DataComponent />);
  expect(screen.getByText('Loaded')).toBeVisible(); // Will fail!
});
                

10. Test Accessibility


test('form is accessible', async () => {
  render(<ContactForm />);
  
  // Can use labels to find inputs
  expect(screen.getByLabelText('Email')).toBeInTheDocument();
  expect(screen.getByLabelText('Message')).toBeInTheDocument();
  
  // Can use button role
  expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
  
  // Test keyboard navigation
  await user.tab();
  expect(screen.getByLabelText('Email')).toHaveFocus();
});
                

πŸ“‹ Testing Checklist

  • βœ… Tests are independent and can run in any order
  • βœ… Test names clearly describe what's being tested
  • βœ… Using realistic test data
  • βœ… Mocking external dependencies (APIs, time, randomness)
  • βœ… Testing user behavior, not implementation
  • βœ… Proper async handling with await
  • βœ… Cleaning up after each test
  • βœ… Tests run quickly (< 30 seconds for integration, < 5 min for E2E)
  • βœ… Following the testing pyramid
  • βœ… Tests are maintainable and easy to update

πŸ“š Summary

Congratulations! You've mastered integration and end-to-end testing for React applications. You now understand how to test components working together and complete user workflows.

Key Takeaways

🎯 Core Concepts

  1. Test Types Hierarchy
    • Unit tests: Fast, focused, many
    • Integration tests: Moderate speed, component interactions
    • E2E tests: Slow, realistic, critical paths only
  2. Integration Testing
    • Test multiple components working together
    • Test data flow through component trees
    • Test routing and navigation
    • Test state management (Context, Redux, Zustand)
  3. E2E Testing
    • Test in real browsers with Playwright or Cypress
    • Test complete user journeys from start to finish
    • Test critical workflows (auth, checkout, core features)
    • Use Page Object Model for maintainable tests
  4. Tools and Frameworks
    • Playwright: Fast, modern, multi-browser support
    • Cypress: Great DX, time travel debugging
    • MSW: Network mocking for both integration and E2E
    • Testing Library: User-centric testing utilities
  5. CI/CD Integration
    • Run tests in pipeline (unit β†’ integration β†’ E2E)
    • Parallel test execution for speed
    • Retry flaky tests automatically
    • Generate test reports and artifacts

The Testing Pyramid Recap

graph TB A[Testing Strategy] A --> B[Unit Tests
70-80%
Fast, Many] A --> C[Integration Tests
15-25%
Moderate, Some] A --> D[E2E Tests
5-10%
Slow, Few] B --> E[Functions
Hooks
Utils] C --> F[Components
Routes
State] D --> G[User Flows
Critical Paths
Auth] style B fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style C fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style D fill:#f59f00,stroke:#e67700,stroke-width:2px,color:#fff

Integration vs E2E: Quick Reference

When to Use Integration Tests E2E Tests
Component interactions βœ… Perfect ❌ Overkill
Routing flows βœ… Good βœ… Good
State management βœ… Perfect ❌ Too slow
Form validation βœ… Perfect ❌ Too slow
Critical user journeys ❌ Not realistic enough βœ… Perfect
Authentication flows βœ… Good for logic βœ… Good for full flow
Payment processing ❌ Not realistic βœ… Perfect
Responsive design ❌ Can't test βœ… Perfect

Common Pitfalls to Avoid

Pitfall Problem Solution
Too many E2E tests Slow test suite, high maintenance Follow testing pyramid, focus on critical paths
Testing implementation Brittle tests that break on refactoring Test user behavior and outcomes
Dependent tests Tests fail in isolation or random order Make each test independent with own setup
Not mocking external APIs Flaky tests, slow execution Mock with MSW or route handlers
Poor selectors in E2E Tests break with styling changes Use role-based and accessible selectors
No CI/CD integration Tests not run regularly Set up automated testing pipeline

Tools Summary

πŸ› οΈ Your Testing Toolkit

  • Vitest: Fast unit test runner
  • React Testing Library: User-centric component testing
  • MSW: Network request mocking
  • Playwright: Modern E2E testing framework
  • Cypress: Developer-friendly E2E alternative
  • GitHub Actions: CI/CD automation

Next Steps

Now that you've mastered testing, you're ready to:

  • Add comprehensive test coverage to your projects
  • Set up CI/CD pipelines with automated testing
  • Write tests that give you confidence to refactor
  • Choose the right test type for each scenario
  • Build reliable, maintainable applications

πŸŽ‰ Congratulations!

You've completed Lesson 9.5: Integration and E2E Testing! You now have the skills to:

  • βœ… Understand the difference between test types
  • βœ… Write integration tests for component interactions
  • βœ… Test routing and navigation flows
  • βœ… Test state management across components
  • βœ… Write E2E tests with Playwright or Cypress
  • βœ… Test complete user workflows
  • βœ… Set up CI/CD testing pipelines
  • βœ… Choose the right test type for each scenario
πŸ’‘ Remember: "The goal of testing isn't 100% coverageβ€”it's having confidence that your application works correctly. Focus on testing the things that matter most to your users."

πŸš€ Module 9 Complete!

You've finished the Testing React Applications module! You've learned:

  • Testing fundamentals and why testing matters
  • React Testing Library and component testing
  • Testing user interactions and forms
  • Testing asynchronous code and API calls
  • Integration testing and E2E testing

Next up: Module 10 - Advanced Topics and Deployment