Skip to main content

πŸ§ͺ Module 9 Project: Testing E-commerce Application

Welcome to your capstone project for Module 9! You're about to add comprehensive test coverage to a real-world e-commerce application. This project brings together everything you've learned about testing: unit tests for utilities and hooks, component tests with React Testing Library, integration tests for complex interactions, and E2E tests for critical user workflows. Think of this as building a safety net that gives you confidence to refactor and add features without fear. By the end, you'll have professional-grade tests that catch bugs early and document how your application works. Let's make this codebase bulletproof! πŸ›‘οΈ

🎯 Project Overview

What You'll Build: Comprehensive test coverage for the e-commerce product catalog from Module 5

Testing Coverage:

  • βœ… Unit tests for utility functions and calculations
  • βœ… Component tests for product cards, cart items, filters
  • βœ… Integration tests for cart functionality and state management
  • βœ… Hook tests for custom hooks (useCart, useFilters)
  • βœ… Reducer tests for cart state logic
  • βœ… E2E tests for complete shopping workflows
  • βœ… API mocking with MSW
  • βœ… Accessibility testing

Estimated Time: 4-5 hours

Difficulty: Advanced

πŸŽ“ Learning Objectives

By completing this project, you will:

  • βœ… Write unit tests for business logic and utilities
  • βœ… Test React components with Testing Library
  • βœ… Write integration tests for component interactions
  • βœ… Test Context providers and reducers
  • βœ… Mock API calls with MSW
  • βœ… Write E2E tests with Playwright
  • βœ… Set up test infrastructure and CI/CD
  • βœ… Achieve meaningful test coverage
  • βœ… Follow testing best practices

πŸ“‹ Prerequisites

Before starting this project, you should have:

  • Completed Module 5 e-commerce project (or have similar codebase)
  • Completed all Module 9 lessons (9.1 - 9.5)
  • Understanding of Vitest and React Testing Library
  • Basic knowledge of Playwright or Cypress
  • Node.js and npm installed

πŸ“‘ Project Guide

βš™οΈ Test Setup and Configuration

Let's start by setting up our testing infrastructure. We'll install all necessary testing libraries and configure them properly.

Installing Testing Dependencies


# Core testing libraries
npm install -D vitest @vitest/ui
npm install -D @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event
npm install -D jsdom

# MSW for API mocking
npm install -D msw

# Playwright for E2E tests
npm install -D @playwright/test
npx playwright install

# Additional utilities
npm install -D @vitest/coverage-v8
                

Vitest Configuration

Create or update your vite.config.ts file:


// vite.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    css: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.spec.ts',
        '**/*.test.ts',
      ],
    },
  },
});
                

Test Setup File

Create src/test/setup.ts for global test configuration:


// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server';

// Start MSW server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

// Reset handlers after each test
afterEach(() => {
  server.resetHandlers();
  cleanup();
});

// Clean up after all tests
afterAll(() => server.close());
                

MSW Setup

Create MSW handlers for API mocking:


// src/test/mocks/handlers.ts
import { rest } from 'msw';
import { products } from '../../data/products';

export const handlers = [
  // Get all products
  rest.get('/api/products', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json(products)
    );
  }),
  
  // Get single product
  rest.get('/api/products/:id', (req, res, ctx) => {
    const { id } = req.params;
    const product = products.find(p => p.id === id);
    
    if (!product) {
      return res(ctx.status(404), ctx.json({ message: 'Product not found' }));
    }
    
    return res(ctx.status(200), ctx.json(product));
  }),
  
  // Search products
  rest.get('/api/products/search', (req, res, ctx) => {
    const query = req.url.searchParams.get('q');
    const filtered = products.filter(p =>
      p.name.toLowerCase().includes(query?.toLowerCase() || '')
    );
    
    return res(ctx.status(200), ctx.json(filtered));
  }),
];

// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
                

Test Utilities

Create reusable test helpers:


// src/test/utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { CartProvider } from '../context/CartContext';

interface AllTheProvidersProps {
  children: React.ReactNode;
}

function AllTheProviders({ children }: AllTheProvidersProps) {
  return (
    <CartProvider>
      {children}
    </CartProvider>
  );
}

function renderWithProviders(
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) {
  return render(ui, { wrapper: AllTheProviders, ...options });
}

// Re-export everything
export * from '@testing-library/react';
export { renderWithProviders as render };
                

Playwright Configuration

Create playwright.config.ts:


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

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});
                

Package.json Scripts

Add testing scripts to your package.json:


{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}
                

Project Structure for Tests


src/
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ Cart/
β”‚   β”‚   β”œβ”€β”€ Cart.tsx
β”‚   β”‚   β”œβ”€β”€ Cart.test.tsx           ← Component test
β”‚   β”‚   β”œβ”€β”€ CartItem.tsx
β”‚   β”‚   └── CartItem.test.tsx
β”‚   └── Product/
β”‚       β”œβ”€β”€ ProductCard.tsx
β”‚       └── ProductCard.test.tsx
β”œβ”€β”€ context/
β”‚   β”œβ”€β”€ CartContext.tsx
β”‚   β”œβ”€β”€ CartContext.test.tsx        ← Context test
β”‚   β”œβ”€β”€ CartReducer.ts
β”‚   └── CartReducer.test.ts         ← Reducer test
β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ useCart.ts
β”‚   └── useCart.test.ts             ← Hook test
β”œβ”€β”€ utils/
β”‚   β”œβ”€β”€ calculations.ts
β”‚   └── calculations.test.ts        ← Unit test
β”œβ”€β”€ test/
β”‚   β”œβ”€β”€ setup.ts
β”‚   β”œβ”€β”€ utils.tsx
β”‚   └── mocks/
β”‚       β”œβ”€β”€ handlers.ts
β”‚       └── server.ts
└── e2e/
    β”œβ”€β”€ shopping-flow.spec.ts       ← E2E test
    └── checkout.spec.ts
                

πŸ’‘ Testing Infrastructure Checklist

  • βœ… Vitest installed and configured
  • βœ… React Testing Library set up
  • βœ… MSW configured for API mocking
  • βœ… Playwright installed for E2E tests
  • βœ… Test utilities and helpers created
  • βœ… Package scripts added
  • βœ… Test folder structure organized

βœ… Verify Setup

Run these commands to verify everything is working:


# Test Vitest is working
npm test

# Open Vitest UI
npm run test:ui

# Test Playwright is working
npm run test:e2e
                    

πŸ”¬ Unit Tests: Utilities and Calculations

Let's start with unit tests for our utility functions. These are the foundation of our test suiteβ€”they're fast, focused, and test pure logic.

Testing Price Calculations

First, let's create the utility functions we'll test:


// src/utils/calculations.ts
export function calculateSubtotal(items: CartItem[]): number {
  return items.reduce((total, item) => {
    return total + (item.price * item.quantity);
  }, 0);
}

export function calculateTax(subtotal: number, taxRate: number = 0.08): number {
  return subtotal * taxRate;
}

export function calculateShipping(subtotal: number): number {
  if (subtotal >= 100) return 0;
  if (subtotal >= 50) return 5.99;
  return 9.99;
}

export function calculateTotal(items: CartItem[], taxRate?: number): number {
  const subtotal = calculateSubtotal(items);
  const tax = calculateTax(subtotal, taxRate);
  const shipping = calculateShipping(subtotal);
  
  return subtotal + tax + shipping;
}

export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(amount);
}

export function calculateDiscount(
  price: number,
  discountPercent: number
): number {
  return price * (discountPercent / 100);
}

export function applyDiscount(
  price: number,
  discountPercent: number
): number {
  return price - calculateDiscount(price, discountPercent);
}
                

Unit Tests for Calculations


// src/utils/calculations.test.ts
import { describe, it, expect } from 'vitest';
import {
  calculateSubtotal,
  calculateTax,
  calculateShipping,
  calculateTotal,
  formatCurrency,
  calculateDiscount,
  applyDiscount,
} from './calculations';

describe('calculateSubtotal', () => {
  it('calculates subtotal for single item', () => {
    const items = [{ id: '1', name: 'Product', price: 10, quantity: 2 }];
    expect(calculateSubtotal(items)).toBe(20);
  });
  
  it('calculates subtotal for multiple items', () => {
    const items = [
      { id: '1', name: 'Product 1', price: 10, quantity: 2 },
      { id: '2', name: 'Product 2', price: 15, quantity: 1 },
    ];
    expect(calculateSubtotal(items)).toBe(35);
  });
  
  it('returns 0 for empty cart', () => {
    expect(calculateSubtotal([])).toBe(0);
  });
  
  it('handles decimal prices correctly', () => {
    const items = [{ id: '1', name: 'Product', price: 9.99, quantity: 3 }];
    expect(calculateSubtotal(items)).toBeCloseTo(29.97);
  });
});

describe('calculateTax', () => {
  it('calculates tax with default rate (8%)', () => {
    expect(calculateTax(100)).toBe(8);
  });
  
  it('calculates tax with custom rate', () => {
    expect(calculateTax(100, 0.10)).toBe(10);
  });
  
  it('returns 0 for zero subtotal', () => {
    expect(calculateTax(0)).toBe(0);
  });
  
  it('handles decimal amounts correctly', () => {
    expect(calculateTax(49.99, 0.08)).toBeCloseTo(3.9992);
  });
});

describe('calculateShipping', () => {
  it('returns free shipping for orders $100 or more', () => {
    expect(calculateShipping(100)).toBe(0);
    expect(calculateShipping(150)).toBe(0);
  });
  
  it('returns $5.99 for orders between $50 and $99.99', () => {
    expect(calculateShipping(50)).toBe(5.99);
    expect(calculateShipping(75)).toBe(5.99);
    expect(calculateShipping(99.99)).toBe(5.99);
  });
  
  it('returns $9.99 for orders under $50', () => {
    expect(calculateShipping(0)).toBe(9.99);
    expect(calculateShipping(25)).toBe(9.99);
    expect(calculateShipping(49.99)).toBe(9.99);
  });
});

describe('calculateTotal', () => {
  const items = [
    { id: '1', name: 'Product 1', price: 50, quantity: 1 },
    { id: '2', name: 'Product 2', price: 30, quantity: 1 },
  ];
  
  it('calculates total with tax and shipping', () => {
    // Subtotal: 80
    // Tax (8%): 6.40
    // Shipping: 5.99 (between 50-100)
    // Total: 92.39
    const total = calculateTotal(items);
    expect(total).toBeCloseTo(92.39);
  });
  
  it('uses custom tax rate', () => {
    const total = calculateTotal(items, 0.10);
    // Subtotal: 80
    // Tax (10%): 8.00
    // Shipping: 5.99
    // Total: 93.99
    expect(total).toBeCloseTo(93.99);
  });
  
  it('includes free shipping for large orders', () => {
    const largeOrder = [
      { id: '1', name: 'Expensive Item', price: 100, quantity: 1 },
    ];
    const total = calculateTotal(largeOrder);
    // Subtotal: 100
    // Tax (8%): 8.00
    // Shipping: 0
    // Total: 108.00
    expect(total).toBe(108);
  });
});

describe('formatCurrency', () => {
  it('formats whole numbers', () => {
    expect(formatCurrency(100)).toBe('$100.00');
  });
  
  it('formats decimal numbers', () => {
    expect(formatCurrency(99.99)).toBe('$99.99');
  });
  
  it('formats zero', () => {
    expect(formatCurrency(0)).toBe('$0.00');
  });
  
  it('formats large numbers with commas', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56');
  });
  
  it('handles negative numbers', () => {
    expect(formatCurrency(-50)).toBe('-$50.00');
  });
});

describe('discount calculations', () => {
  describe('calculateDiscount', () => {
    it('calculates discount amount', () => {
      expect(calculateDiscount(100, 10)).toBe(10);
      expect(calculateDiscount(50, 20)).toBe(10);
    });
    
    it('handles zero discount', () => {
      expect(calculateDiscount(100, 0)).toBe(0);
    });
    
    it('handles 100% discount', () => {
      expect(calculateDiscount(100, 100)).toBe(100);
    });
  });
  
  describe('applyDiscount', () => {
    it('applies discount to price', () => {
      expect(applyDiscount(100, 10)).toBe(90);
      expect(applyDiscount(50, 20)).toBe(40);
    });
    
    it('returns original price for zero discount', () => {
      expect(applyDiscount(100, 0)).toBe(100);
    });
    
    it('returns zero for 100% discount', () => {
      expect(applyDiscount(100, 100)).toBe(0);
    });
  });
});
                

Testing Product Filtering Logic


// src/utils/filters.ts
export interface FilterOptions {
  searchQuery: string;
  category: string;
  minPrice: number;
  maxPrice: number;
  inStock: boolean;
}

export function filterProducts(
  products: Product[],
  filters: Partial<FilterOptions>
): Product[] {
  return products.filter(product => {
    // Search by name or description
    if (filters.searchQuery) {
      const query = filters.searchQuery.toLowerCase();
      const matchesName = product.name.toLowerCase().includes(query);
      const matchesDesc = product.description?.toLowerCase().includes(query);
      if (!matchesName && !matchesDesc) return false;
    }
    
    // Filter by category
    if (filters.category && filters.category !== 'all') {
      if (product.category !== filters.category) return false;
    }
    
    // Filter by price range
    if (filters.minPrice !== undefined && product.price < filters.minPrice) {
      return false;
    }
    if (filters.maxPrice !== undefined && product.price > filters.maxPrice) {
      return false;
    }
    
    // Filter by stock status
    if (filters.inStock && product.stock === 0) {
      return false;
    }
    
    return true;
  });
}

export function sortProducts(
  products: Product[],
  sortBy: 'name' | 'price-low' | 'price-high' | 'newest'
): Product[] {
  const sorted = [...products];
  
  switch (sortBy) {
    case 'name':
      return sorted.sort((a, b) => a.name.localeCompare(b.name));
    case 'price-low':
      return sorted.sort((a, b) => a.price - b.price);
    case 'price-high':
      return sorted.sort((a, b) => b.price - a.price);
    case 'newest':
      return sorted.sort((a, b) => 
        new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
      );
    default:
      return sorted;
  }
}
                

Unit Tests for Filters


// src/utils/filters.test.ts
import { describe, it, expect } from 'vitest';
import { filterProducts, sortProducts } from './filters';

const mockProducts = [
  {
    id: '1',
    name: 'Laptop',
    description: 'Powerful laptop',
    category: 'electronics',
    price: 999,
    stock: 5,
    createdAt: '2024-01-01',
  },
  {
    id: '2',
    name: 'Mouse',
    description: 'Wireless mouse',
    category: 'electronics',
    price: 29,
    stock: 0,
    createdAt: '2024-01-15',
  },
  {
    id: '3',
    name: 'Desk',
    description: 'Standing desk',
    category: 'furniture',
    price: 300,
    stock: 3,
    createdAt: '2024-01-10',
  },
];

describe('filterProducts', () => {
  it('returns all products with empty filters', () => {
    const result = filterProducts(mockProducts, {});
    expect(result).toHaveLength(3);
  });
  
  it('filters by search query in name', () => {
    const result = filterProducts(mockProducts, { searchQuery: 'laptop' });
    expect(result).toHaveLength(1);
    expect(result[0].name).toBe('Laptop');
  });
  
  it('filters by search query in description', () => {
    const result = filterProducts(mockProducts, { searchQuery: 'wireless' });
    expect(result).toHaveLength(1);
    expect(result[0].name).toBe('Mouse');
  });
  
  it('filters by category', () => {
    const result = filterProducts(mockProducts, { category: 'electronics' });
    expect(result).toHaveLength(2);
  });
  
  it('filters by price range', () => {
    const result = filterProducts(mockProducts, { 
      minPrice: 50, 
      maxPrice: 500 
    });
    expect(result).toHaveLength(1);
    expect(result[0].name).toBe('Desk');
  });
  
  it('filters by stock status', () => {
    const result = filterProducts(mockProducts, { inStock: true });
    expect(result).toHaveLength(2);
    expect(result.every(p => p.stock > 0)).toBe(true);
  });
  
  it('applies multiple filters together', () => {
    const result = filterProducts(mockProducts, {
      category: 'electronics',
      minPrice: 100,
      inStock: true,
    });
    expect(result).toHaveLength(1);
    expect(result[0].name).toBe('Laptop');
  });
});

describe('sortProducts', () => {
  it('sorts by name alphabetically', () => {
    const result = sortProducts(mockProducts, 'name');
    expect(result[0].name).toBe('Desk');
    expect(result[1].name).toBe('Laptop');
    expect(result[2].name).toBe('Mouse');
  });
  
  it('sorts by price low to high', () => {
    const result = sortProducts(mockProducts, 'price-low');
    expect(result[0].price).toBe(29);
    expect(result[2].price).toBe(999);
  });
  
  it('sorts by price high to low', () => {
    const result = sortProducts(mockProducts, 'price-high');
    expect(result[0].price).toBe(999);
    expect(result[2].price).toBe(29);
  });
  
  it('sorts by newest first', () => {
    const result = sortProducts(mockProducts, 'newest');
    expect(result[0].id).toBe('2'); // Created Jan 15
    expect(result[1].id).toBe('3'); // Created Jan 10
    expect(result[2].id).toBe('1'); // Created Jan 1
  });
  
  it('does not mutate original array', () => {
    const original = [...mockProducts];
    sortProducts(mockProducts, 'price-low');
    expect(mockProducts).toEqual(original);
  });
});
                

βœ… Unit Testing Best Practices

  • Test one thing: Each test should verify one specific behavior
  • Use descriptive names: Test names should clearly state what's being tested
  • Test edge cases: Zero values, empty arrays, boundaries
  • Don't test implementation: Test inputs and outputs, not how it works
  • Keep tests independent: Each test should run in isolation
  • Use appropriate matchers: toBe for primitives, toEqual for objects

πŸ’‘ Running Unit Tests


# Run all tests
npm test

# Run specific test file
npm test calculations.test

# Run with coverage
npm run test:coverage

# Watch mode during development
npm test -- --watch
                    

🧩 Component Tests: UI Components

Now let's test our React components using React Testing Library. We'll focus on testing user-facing behavior, not implementation details.

Testing ProductCard Component

First, let's look at the component we're testing:


// src/components/Product/ProductCard.tsx
interface ProductCardProps {
  product: Product;
  onAddToCart: (product: Product) => void;
}

export function ProductCard({ product, onAddToCart }: ProductCardProps) {
  const [isAdding, setIsAdding] = React.useState(false);
  
  const handleAddToCart = async () => {
    setIsAdding(true);
    await new Promise(resolve => setTimeout(resolve, 300)); // Simulate async
    onAddToCart(product);
    setIsAdding(false);
  };
  
  return (
    <article className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="description">{product.description}</p>
      <p className="price">{formatCurrency(product.price)}</p>
      
      {product.stock === 0 ? (
        <p className="out-of-stock">Out of Stock</p>
      ) : (
        <button
          onClick={handleAddToCart}
          disabled={isAdding}
          aria-label={`Add ${product.name} to cart`}
        >
          {isAdding ? 'Adding...' : 'Add to Cart'}
        </button>
      )}
      
      {product.stock > 0 && product.stock < 5 && (
        <p className="low-stock">Only {product.stock} left!</p>
      )}
    </article>
  );
}
                

ProductCard Component Tests


// src/components/Product/ProductCard.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductCard } from './ProductCard';

const mockProduct = {
  id: '1',
  name: 'Gaming Laptop',
  description: 'High-performance laptop for gaming',
  price: 999.99,
  image: '/images/laptop.jpg',
  category: 'electronics',
  stock: 10,
};

describe('ProductCard', () => {
  it('renders product information', () => {
    const onAddToCart = vi.fn();
    
    render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
    
    expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
    expect(screen.getByText('High-performance laptop for gaming')).toBeInTheDocument();
    expect(screen.getByText('$999.99')).toBeInTheDocument();
    expect(screen.getByAltText('Gaming Laptop')).toBeInTheDocument();
  });
  
  it('calls onAddToCart when button is clicked', async () => {
    const user = userEvent.setup();
    const onAddToCart = vi.fn();
    
    render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
    
    const button = screen.getByRole('button', { name: /add.*to cart/i });
    await user.click(button);
    
    expect(onAddToCart).toHaveBeenCalledWith(mockProduct);
    expect(onAddToCart).toHaveBeenCalledTimes(1);
  });
  
  it('shows loading state while adding to cart', async () => {
    const user = userEvent.setup();
    const onAddToCart = vi.fn();
    
    render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
    
    const button = screen.getByRole('button', { name: /add.*to cart/i });
    
    // Click button
    await user.click(button);
    
    // Should show loading text
    expect(screen.getByText('Adding...')).toBeInTheDocument();
    
    // Button should be disabled
    expect(button).toBeDisabled();
  });
  
  it('shows "Out of Stock" when stock is 0', () => {
    const onAddToCart = vi.fn();
    const outOfStockProduct = { ...mockProduct, stock: 0 };
    
    render(<ProductCard product={outOfStockProduct} onAddToCart={onAddToCart} />);
    
    expect(screen.getByText('Out of Stock')).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /add.*to cart/i })).not.toBeInTheDocument();
  });
  
  it('shows low stock warning when stock is less than 5', () => {
    const onAddToCart = vi.fn();
    const lowStockProduct = { ...mockProduct, stock: 3 };
    
    render(<ProductCard product={lowStockProduct} onAddToCart={onAddToCart} />);
    
    expect(screen.getByText('Only 3 left!')).toBeInTheDocument();
  });
  
  it('does not show low stock warning when stock is 5 or more', () => {
    const onAddToCart = vi.fn();
    
    render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
    
    expect(screen.queryByText(/only.*left/i)).not.toBeInTheDocument();
  });
  
  it('has accessible add to cart button', () => {
    const onAddToCart = vi.fn();
    
    render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
    
    const button = screen.getByRole('button', { name: 'Add Gaming Laptop to cart' });
    expect(button).toBeInTheDocument();
  });
});
                

Testing CartItem Component


// src/components/Cart/CartItem.tsx
interface CartItemProps {
  item: CartItem;
  onUpdateQuantity: (id: string, quantity: number) => void;
  onRemove: (id: string) => void;
}

export function CartItem({ item, onUpdateQuantity, onRemove }: CartItemProps) {
  const handleQuantityChange = (delta: number) => {
    const newQuantity = item.quantity + delta;
    if (newQuantity > 0) {
      onUpdateQuantity(item.id, newQuantity);
    }
  };
  
  const subtotal = item.price * item.quantity;
  
  return (
    <div className="cart-item">
      <img src={item.image} alt={item.name} />
      
      <div className="item-details">
        <h4>{item.name}</h4>
        <p className="price">{formatCurrency(item.price)}</p>
      </div>
      
      <div className="quantity-controls">
        <button
          onClick={() => handleQuantityChange(-1)}
          aria-label="Decrease quantity"
          disabled={item.quantity === 1}
        >
          -
        </button>
        
        <span aria-label="Quantity">{item.quantity}</span>
        
        <button
          onClick={() => handleQuantityChange(1)}
          aria-label="Increase quantity"
        >
          +
        </button>
      </div>
      
      <p className="subtotal">{formatCurrency(subtotal)}</p>
      
      <button
        onClick={() => onRemove(item.id)}
        aria-label={`Remove ${item.name} from cart`}
        className="remove-button"
      >
        Remove
      </button>
    </div>
  );
}
                

CartItem Component Tests


// src/components/Cart/CartItem.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CartItem } from './CartItem';

const mockItem = {
  id: '1',
  name: 'Gaming Laptop',
  price: 999.99,
  quantity: 2,
  image: '/images/laptop.jpg',
};

describe('CartItem', () => {
  it('renders item information', () => {
    const onUpdateQuantity = vi.fn();
    const onRemove = vi.fn();
    
    render(
      <CartItem
        item={mockItem}
        onUpdateQuantity={onUpdateQuantity}
        onRemove={onRemove}
      />
    );
    
    expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
    expect(screen.getByText('$999.99')).toBeInTheDocument();
    expect(screen.getByLabelText('Quantity')).toHaveTextContent('2');
    expect(screen.getByText('$1,999.98')).toBeInTheDocument(); // Subtotal
  });
  
  it('increases quantity when + button is clicked', async () => {
    const user = userEvent.setup();
    const onUpdateQuantity = vi.fn();
    const onRemove = vi.fn();
    
    render(
      <CartItem
        item={mockItem}
        onUpdateQuantity={onUpdateQuantity}
        onRemove={onRemove}
      />
    );
    
    await user.click(screen.getByLabelText('Increase quantity'));
    
    expect(onUpdateQuantity).toHaveBeenCalledWith('1', 3);
  });
  
  it('decreases quantity when - button is clicked', async () => {
    const user = userEvent.setup();
    const onUpdateQuantity = vi.fn();
    const onRemove = vi.fn();
    
    render(
      <CartItem
        item={mockItem}
        onUpdateQuantity={onUpdateQuantity}
        onRemove={onRemove}
      />
    );
    
    await user.click(screen.getByLabelText('Decrease quantity'));
    
    expect(onUpdateQuantity).toHaveBeenCalledWith('1', 1);
  });
  
  it('disables decrease button when quantity is 1', () => {
    const onUpdateQuantity = vi.fn();
    const onRemove = vi.fn();
    const singleItem = { ...mockItem, quantity: 1 };
    
    render(
      <CartItem
        item={singleItem}
        onUpdateQuantity={onUpdateQuantity}
        onRemove={onRemove}
      />
    );
    
    const decreaseButton = screen.getByLabelText('Decrease quantity');
    expect(decreaseButton).toBeDisabled();
  });
  
  it('calls onRemove when remove button is clicked', async () => {
    const user = userEvent.setup();
    const onUpdateQuantity = vi.fn();
    const onRemove = vi.fn();
    
    render(
      <CartItem
        item={mockItem}
        onUpdateQuantity={onUpdateQuantity}
        onRemove={onRemove}
      />
    );
    
    await user.click(screen.getByLabelText('Remove Gaming Laptop from cart'));
    
    expect(onRemove).toHaveBeenCalledWith('1');
  });
  
  it('calculates subtotal correctly', () => {
    const onUpdateQuantity = vi.fn();
    const onRemove = vi.fn();
    const item = { ...mockItem, price: 50, quantity: 3 };
    
    render(
      <CartItem
        item={item}
        onUpdateQuantity={onUpdateQuantity}
        onRemove={onRemove}
      />
    );
    
    expect(screen.getByText('$150.00')).toBeInTheDocument();
  });
});
                

Testing SearchBar Component


// src/components/Filters/SearchBar.tsx
interface SearchBarProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
}

export function SearchBar({ 
  value, 
  onChange, 
  placeholder = 'Search products...' 
}: SearchBarProps) {
  const [localValue, setLocalValue] = React.useState(value);
  const timeoutRef = React.useRef<NodeJS.Timeout>();
  
  React.useEffect(() => {
    setLocalValue(value);
  }, [value]);
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value;
    setLocalValue(newValue);
    
    // Debounce the onChange callback
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    
    timeoutRef.current = setTimeout(() => {
      onChange(newValue);
    }, 300);
  };
  
  const handleClear = () => {
    setLocalValue('');
    onChange('');
  };
  
  return (
    <div className="search-bar">
      <label htmlFor="search-input" className="sr-only">
        Search products
      </label>
      
      <input
        id="search-input"
        type="text"
        value={localValue}
        onChange={handleChange}
        placeholder={placeholder}
        aria-label="Search products"
      />
      
      {localValue && (
        <button
          onClick={handleClear}
          aria-label="Clear search"
          className="clear-button"
        >
          βœ•
        </button>
      )}
    </div>
  );
}
                

SearchBar Component Tests


// src/components/Filters/SearchBar.test.tsx
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBar } from './SearchBar';

describe('SearchBar', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  
  afterEach(() => {
    vi.useRealTimers();
  });
  
  it('renders with initial value', () => {
    const onChange = vi.fn();
    
    render(<SearchBar value="laptop" onChange={onChange} />);
    
    const input = screen.getByRole('textbox', { name: 'Search products' });
    expect(input).toHaveValue('laptop');
  });
  
  it('calls onChange after debounce delay', async () => {
    const user = userEvent.setup({ delay: null });
    const onChange = vi.fn();
    
    render(<SearchBar value="" onChange={onChange} />);
    
    const input = screen.getByRole('textbox');
    await user.type(input, 'laptop');
    
    // Should not call immediately
    expect(onChange).not.toHaveBeenCalled();
    
    // Advance timers past debounce delay
    vi.advanceTimersByTime(300);
    
    // Should have been called once with final value
    await waitFor(() => {
      expect(onChange).toHaveBeenCalledWith('laptop');
      expect(onChange).toHaveBeenCalledTimes(1);
    });
  });
  
  it('debounces multiple rapid changes', async () => {
    const user = userEvent.setup({ delay: null });
    const onChange = vi.fn();
    
    render(<SearchBar value="" onChange={onChange} />);
    
    const input = screen.getByRole('textbox');
    
    // Type quickly
    await user.type(input, 'l');
    vi.advanceTimersByTime(100);
    await user.type(input, 'a');
    vi.advanceTimersByTime(100);
    await user.type(input, 'p');
    
    // Should not have called yet
    expect(onChange).not.toHaveBeenCalled();
    
    // Advance past debounce delay
    vi.advanceTimersByTime(300);
    
    // Should have been called only once with final value
    await waitFor(() => {
      expect(onChange).toHaveBeenCalledTimes(1);
      expect(onChange).toHaveBeenCalledWith('lap');
    });
  });
  
  it('shows clear button when input has value', async () => {
    const user = userEvent.setup({ delay: null });
    const onChange = vi.fn();
    
    render(<SearchBar value="" onChange={onChange} />);
    
    // Initially no clear button
    expect(screen.queryByLabelText('Clear search')).not.toBeInTheDocument();
    
    // Type something
    await user.type(screen.getByRole('textbox'), 'laptop');
    
    // Clear button should appear
    expect(screen.getByLabelText('Clear search')).toBeInTheDocument();
  });
  
  it('clears input when clear button is clicked', async () => {
    const user = userEvent.setup();
    const onChange = vi.fn();
    
    render(<SearchBar value="laptop" onChange={onChange} />);
    
    const clearButton = screen.getByLabelText('Clear search');
    await user.click(clearButton);
    
    expect(screen.getByRole('textbox')).toHaveValue('');
    expect(onChange).toHaveBeenCalledWith('');
  });
  
  it('uses custom placeholder', () => {
    const onChange = vi.fn();
    
    render(
      <SearchBar
        value=""
        onChange={onChange}
        placeholder="Find your product..."
      />
    );
    
    expect(screen.getByPlaceholderText('Find your product...')).toBeInTheDocument();
  });
  
  it('is accessible', () => {
    const onChange = vi.fn();
    
    render(<SearchBar value="" onChange={onChange} />);
    
    const input = screen.getByLabelText('Search products');
    expect(input).toBeInTheDocument();
    expect(input).toHaveAttribute('type', 'text');
  });
});
                

βœ… Component Testing Best Practices

  • Test user behavior: Focus on what users see and do
  • Use accessible queries: getByRole, getByLabelText, getByText
  • Avoid implementation details: Don't test state or props directly
  • Test all states: Loading, success, error, empty
  • Mock callbacks: Use vi.fn() to verify function calls
  • Test accessibility: Ensure components are accessible
  • Use userEvent: More realistic than fireEvent

βš™οΈ Testing the Cart Reducer

Reducers contain critical business logic and should be thoroughly tested. The good news is they're pure functions, making them easy to test!

Cart Reducer Implementation


// src/context/CartReducer.ts
export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

export interface CartState {
  items: CartItem[];
}

export type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'CLEAR_CART' };

export function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItem = state.items.find(
        item => item.id === action.payload.id
      );
      
      if (existingItem) {
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + action.payload.quantity }
              : item
          ),
        };
      }
      
      return {
        ...state,
        items: [...state.items, action.payload],
      };
    }
    
    case 'REMOVE_ITEM': {
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
      };
    }
    
    case 'UPDATE_QUANTITY': {
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        ),
      };
    }
    
    case 'CLEAR_CART': {
      return {
        ...state,
        items: [],
      };
    }
    
    default:
      return state;
  }
}
                

Reducer Tests


// src/context/CartReducer.test.ts
import { describe, it, expect } from 'vitest';
import { cartReducer, CartState, CartItem } from './CartReducer';

const mockItem: CartItem = {
  id: '1',
  name: 'Laptop',
  price: 999,
  quantity: 1,
  image: '/laptop.jpg',
};

const mockItem2: CartItem = {
  id: '2',
  name: 'Mouse',
  price: 29,
  quantity: 1,
  image: '/mouse.jpg',
};

describe('cartReducer', () => {
  describe('ADD_ITEM', () => {
    it('adds new item to empty cart', () => {
      const initialState: CartState = { items: [] };
      
      const newState = cartReducer(initialState, {
        type: 'ADD_ITEM',
        payload: mockItem,
      });
      
      expect(newState.items).toHaveLength(1);
      expect(newState.items[0]).toEqual(mockItem);
    });
    
    it('adds new item to cart with existing items', () => {
      const initialState: CartState = { items: [mockItem] };
      
      const newState = cartReducer(initialState, {
        type: 'ADD_ITEM',
        payload: mockItem2,
      });
      
      expect(newState.items).toHaveLength(2);
      expect(newState.items[1]).toEqual(mockItem2);
    });
    
    it('increases quantity when adding existing item', () => {
      const initialState: CartState = { items: [mockItem] };
      
      const newState = cartReducer(initialState, {
        type: 'ADD_ITEM',
        payload: { ...mockItem, quantity: 2 },
      });
      
      expect(newState.items).toHaveLength(1);
      expect(newState.items[0].quantity).toBe(3); // 1 + 2
    });
    
    it('does not mutate original state', () => {
      const initialState: CartState = { items: [mockItem] };
      const originalItems = [...initialState.items];
      
      cartReducer(initialState, {
        type: 'ADD_ITEM',
        payload: mockItem2,
      });
      
      expect(initialState.items).toEqual(originalItems);
    });
  });
  
  describe('REMOVE_ITEM', () => {
    it('removes item from cart', () => {
      const initialState: CartState = {
        items: [mockItem, mockItem2],
      };
      
      const newState = cartReducer(initialState, {
        type: 'REMOVE_ITEM',
        payload: '1',
      });
      
      expect(newState.items).toHaveLength(1);
      expect(newState.items[0].id).toBe('2');
    });
    
    it('handles removing non-existent item', () => {
      const initialState: CartState = { items: [mockItem] };
      
      const newState = cartReducer(initialState, {
        type: 'REMOVE_ITEM',
        payload: 'non-existent',
      });
      
      expect(newState.items).toHaveLength(1);
      expect(newState.items[0]).toEqual(mockItem);
    });
    
    it('handles removing from empty cart', () => {
      const initialState: CartState = { items: [] };
      
      const newState = cartReducer(initialState, {
        type: 'REMOVE_ITEM',
        payload: '1',
      });
      
      expect(newState.items).toHaveLength(0);
    });
  });
  
  describe('UPDATE_QUANTITY', () => {
    it('updates quantity of existing item', () => {
      const initialState: CartState = { items: [mockItem] };
      
      const newState = cartReducer(initialState, {
        type: 'UPDATE_QUANTITY',
        payload: { id: '1', quantity: 5 },
      });
      
      expect(newState.items[0].quantity).toBe(5);
    });
    
    it('does not affect other items', () => {
      const initialState: CartState = {
        items: [mockItem, mockItem2],
      };
      
      const newState = cartReducer(initialState, {
        type: 'UPDATE_QUANTITY',
        payload: { id: '1', quantity: 3 },
      });
      
      expect(newState.items[0].quantity).toBe(3);
      expect(newState.items[1].quantity).toBe(1);
    });
    
    it('handles updating non-existent item', () => {
      const initialState: CartState = { items: [mockItem] };
      
      const newState = cartReducer(initialState, {
        type: 'UPDATE_QUANTITY',
        payload: { id: 'non-existent', quantity: 5 },
      });
      
      expect(newState.items).toEqual(initialState.items);
    });
  });
  
  describe('CLEAR_CART', () => {
    it('removes all items from cart', () => {
      const initialState: CartState = {
        items: [mockItem, mockItem2],
      };
      
      const newState = cartReducer(initialState, {
        type: 'CLEAR_CART',
      });
      
      expect(newState.items).toHaveLength(0);
    });
    
    it('handles clearing empty cart', () => {
      const initialState: CartState = { items: [] };
      
      const newState = cartReducer(initialState, {
        type: 'CLEAR_CART',
      });
      
      expect(newState.items).toHaveLength(0);
    });
  });
  
  describe('Complex scenarios', () => {
    it('handles multiple operations in sequence', () => {
      let state: CartState = { items: [] };
      
      // Add first item
      state = cartReducer(state, {
        type: 'ADD_ITEM',
        payload: mockItem,
      });
      expect(state.items).toHaveLength(1);
      
      // Add second item
      state = cartReducer(state, {
        type: 'ADD_ITEM',
        payload: mockItem2,
      });
      expect(state.items).toHaveLength(2);
      
      // Update quantity of first item
      state = cartReducer(state, {
        type: 'UPDATE_QUANTITY',
        payload: { id: '1', quantity: 3 },
      });
      expect(state.items[0].quantity).toBe(3);
      
      // Remove second item
      state = cartReducer(state, {
        type: 'REMOVE_ITEM',
        payload: '2',
      });
      expect(state.items).toHaveLength(1);
      
      // Clear cart
      state = cartReducer(state, {
        type: 'CLEAR_CART',
      });
      expect(state.items).toHaveLength(0);
    });
  });
});
                

πŸ’‘ Reducer Testing Tips

  • Test all action types: Every case in the switch statement
  • Test immutability: Verify state isn't mutated
  • Test edge cases: Empty arrays, non-existent IDs, etc.
  • Test sequences: Multiple operations in a row
  • Keep tests simple: One action per test (usually)
  • Use descriptive names: Clear what scenario is being tested

πŸͺ Testing Custom Hooks

Custom hooks contain reusable logic and should be tested independently. We'll use the renderHook utility from Testing Library.

Custom Hook: useCart


// src/hooks/useCart.ts
export function useCart() {
  const context = React.useContext(CartContext);
  
  if (!context) {
    throw new Error('useCart must be used within CartProvider');
  }
  
  const { state, dispatch } = context;
  
  const addItem = React.useCallback((product: Product) => {
    dispatch({
      type: 'ADD_ITEM',
      payload: {
        id: product.id,
        name: product.name,
        price: product.price,
        quantity: 1,
        image: product.image,
      },
    });
  }, [dispatch]);
  
  const removeItem = React.useCallback((id: string) => {
    dispatch({ type: 'REMOVE_ITEM', payload: id });
  }, [dispatch]);
  
  const updateQuantity = React.useCallback((id: string, quantity: number) => {
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } });
  }, [dispatch]);
  
  const clearCart = React.useCallback(() => {
    dispatch({ type: 'CLEAR_CART' });
  }, [dispatch]);
  
  const itemCount = state.items.reduce((sum, item) => sum + item.quantity, 0);
  const subtotal = calculateSubtotal(state.items);
  
  return {
    items: state.items,
    itemCount,
    subtotal,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
  };
}
                

Hook Tests


// src/hooks/useCart.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { CartProvider } from '../context/CartContext';
import { useCart } from './useCart';

const wrapper = ({ children }: { children: React.ReactNode }) => (
  <CartProvider>{children}</CartProvider>
);

const mockProduct = {
  id: '1',
  name: 'Laptop',
  price: 999,
  image: '/laptop.jpg',
  description: 'A laptop',
  category: 'electronics',
  stock: 10,
};

describe('useCart', () => {
  it('starts with empty cart', () => {
    const { result } = renderHook(() => useCart(), { wrapper });
    
    expect(result.current.items).toHaveLength(0);
    expect(result.current.itemCount).toBe(0);
    expect(result.current.subtotal).toBe(0);
  });
  
  it('adds item to cart', () => {
    const { result } = renderHook(() => useCart(), { wrapper });
    
    act(() => {
      result.current.addItem(mockProduct);
    });
    
    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0].name).toBe('Laptop');
    expect(result.current.itemCount).toBe(1);
  });
  
  it('calculates itemCount correctly with multiple items', () => {
    const { result } = renderHook(() => useCart(), { wrapper });
    
    act(() => {
      result.current.addItem(mockProduct);
      result.current.addItem(mockProduct); // Add same item twice
      result.current.addItem({ ...mockProduct, id: '2', name: 'Mouse' });
    });
    
    expect(result.current.itemCount).toBe(3); // 2 laptops + 1 mouse
  });
  
  it('calculates subtotal correctly', () => {
    const { result } = renderHook(() => useCart(), { wrapper });
    
    act(() => {
      result.current.addItem(mockProduct);
      result.current.addItem(mockProduct);
    });
    
    expect(result.current.subtotal).toBe(1998); // 999 * 2
  });
  
  it('removes item from cart', () => {
    const { result } = renderHook(() => useCart(), { wrapper });
    
    act(() => {
      result.current.addItem(mockProduct);
    });
    
    expect(result.current.items).toHaveLength(1);
    
    act(() => {
      result.current.removeItem('1');
    });
    
    expect(result.current.items).toHaveLength(0);
  });
  
  it('updates item quantity', () => {
    const { result } = renderHook(() => useCart(), { wrapper });
    
    act(() => {
      result.current.addItem(mockProduct);
    });
    
    expect(result.current.items[0].quantity).toBe(1);
    
    act(() => {
      result.current.updateQuantity('1', 5);
    });
    
    expect(result.current.items[0].quantity).toBe(5);
    expect(result.current.itemCount).toBe(5);
  });
  
  it('clears entire cart', () => {
    const { result } = renderHook(() => useCart(), { wrapper });
    
    act(() => {
      result.current.addItem(mockProduct);
      result.current.addItem({ ...mockProduct, id: '2' });
    });
    
    expect(result.current.items).toHaveLength(2);
    
    act(() => {
      result.current.clearCart();
    });
    
    expect(result.current.items).toHaveLength(0);
    expect(result.current.itemCount).toBe(0);
    expect(result.current.subtotal).toBe(0);
  });
  
  it('throws error when used outside CartProvider', () => {
    // Suppress console.error for this test
    const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
    
    expect(() => {
      renderHook(() => useCart());
    }).toThrow('useCart must be used within CartProvider');
    
    spy.mockRestore();
  });
});
                

Testing Custom Hook with Dependencies


// src/hooks/useLocalStorage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = React.useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });
  
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue] as const;
}

// src/hooks/useLocalStorage.test.ts
describe('useLocalStorage', () => {
  beforeEach(() => {
    window.localStorage.clear();
  });
  
  it('returns initial value when no stored value', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
    
    expect(result.current[0]).toBe('initial');
  });
  
  it('stores and retrieves value', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
    
    act(() => {
      result.current[1]('new value');
    });
    
    expect(result.current[0]).toBe('new value');
    expect(window.localStorage.getItem('test-key')).toBe('"new value"');
  });
  
  it('reads existing value from localStorage', () => {
    window.localStorage.setItem('test-key', JSON.stringify('existing'));
    
    const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
    
    expect(result.current[0]).toBe('existing');
  });
  
  it('handles complex objects', () => {
    const { result } = renderHook(() => 
      useLocalStorage('test-key', { count: 0, name: 'test' })
    );
    
    act(() => {
      result.current[1]({ count: 5, name: 'updated' });
    });
    
    expect(result.current[0]).toEqual({ count: 5, name: 'updated' });
  });
  
  it('handles function updater', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 0));
    
    act(() => {
      result.current[1](prev => prev + 1);
    });
    
    expect(result.current[0]).toBe(1);
  });
});
                

βœ… Hook Testing Best Practices

  • Use renderHook: Properly render hooks in test environment
  • Wrap in act(): All state updates must be in act()
  • Test with providers: Provide necessary context
  • Test return values: Verify hook returns expected data
  • Test side effects: localStorage, API calls, etc.
  • Test error cases: Missing providers, invalid inputs

πŸ”— Integration Tests: Cart Functionality

Integration tests verify that multiple components work together correctly. Let's test the complete shopping cart flow from adding products to calculating totals.

Testing Complete Cart Flow


// src/components/Cart/Cart.integration.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/utils'; // Our custom render with providers
import userEvent from '@testing-library/user-event';
import { ProductCard } from '../Product/ProductCard';
import { Cart } from './Cart';

const mockProducts = [
  {
    id: '1',
    name: 'Gaming Laptop',
    description: 'High-performance laptop',
    price: 999.99,
    image: '/laptop.jpg',
    category: 'electronics',
    stock: 10,
  },
  {
    id: '2',
    name: 'Wireless Mouse',
    description: 'Ergonomic mouse',
    price: 29.99,
    image: '/mouse.jpg',
    category: 'electronics',
    stock: 15,
  },
];

describe('Cart Integration', () => {
  it('adds product to cart and displays in cart component', async () => {
    const user = userEvent.setup();
    
    render(
      <>
        <ProductCard product={mockProducts[0]} />
        <Cart />
      </>
    );
    
    // Initially cart is empty
    expect(screen.getByText(/cart is empty/i)).toBeInTheDocument();
    
    // Add product to cart
    await user.click(screen.getByRole('button', { name: /add.*to cart/i }));
    
    // Product appears in cart
    expect(await screen.findByText('Gaming Laptop')).toBeInTheDocument();
    expect(screen.getByText('$999.99')).toBeInTheDocument();
    
    // Cart shows item count
    expect(screen.getByText(/1 item/i)).toBeInTheDocument();
  });
  
  it('adds multiple products and calculates total correctly', async () => {
    const user = userEvent.setup();
    
    render(
      <>
        <ProductCard product={mockProducts[0]} />
        <ProductCard product={mockProducts[1]} />
        <Cart />
      </>
    );
    
    // Add laptop
    const addButtons = screen.getAllByRole('button', { name: /add.*to cart/i });
    await user.click(addButtons[0]);
    
    // Add mouse
    await user.click(addButtons[1]);
    
    // Both products in cart
    expect(await screen.findByText('Gaming Laptop')).toBeInTheDocument();
    expect(screen.getByText('Wireless Mouse')).toBeInTheDocument();
    
    // Item count is 2
    expect(screen.getByText(/2 items/i)).toBeInTheDocument();
    
    // Subtotal is correct (999.99 + 29.99 = 1029.98)
    expect(screen.getByText('$1,029.98')).toBeInTheDocument();
  });
  
  it('increases quantity when same product added twice', async () => {
    const user = userEvent.setup();
    
    render(
      <>
        <ProductCard product={mockProducts[0]} />
        <Cart />
      </>
    );
    
    const addButton = screen.getByRole('button', { name: /add.*to cart/i });
    
    // Add product twice
    await user.click(addButton);
    await user.click(addButton);
    
    // Should have 1 product line with quantity 2
    expect(await screen.findByText('Gaming Laptop')).toBeInTheDocument();
    expect(screen.getByLabelText('Quantity')).toHaveTextContent('2');
    
    // Item count is 2
    expect(screen.getByText(/2 items/i)).toBeInTheDocument();
    
    // Subtotal reflects quantity (999.99 * 2)
    expect(screen.getByText('$1,999.98')).toBeInTheDocument();
  });
  
  it('updates quantity using cart controls', async () => {
    const user = userEvent.setup();
    
    render(
      <>
        <ProductCard product={mockProducts[0]} />
        <Cart />
      </>
    );
    
    // Add product
    await user.click(screen.getByRole('button', { name: /add.*to cart/i }));
    
    // Wait for product in cart
    await screen.findByText('Gaming Laptop');
    
    // Increase quantity
    await user.click(screen.getByLabelText('Increase quantity'));
    
    expect(screen.getByLabelText('Quantity')).toHaveTextContent('2');
    expect(screen.getByText(/2 items/i)).toBeInTheDocument();
    
    // Decrease quantity
    await user.click(screen.getByLabelText('Decrease quantity'));
    
    expect(screen.getByLabelText('Quantity')).toHaveTextContent('1');
    expect(screen.getByText(/1 item/i)).toBeInTheDocument();
  });
  
  it('removes product from cart', async () => {
    const user = userEvent.setup();
    
    render(
      <>
        <ProductCard product={mockProducts[0]} />
        <ProductCard product={mockProducts[1]} />
        <Cart />
      </>
    );
    
    // Add both products
    const addButtons = screen.getAllByRole('button', { name: /add.*to cart/i });
    await user.click(addButtons[0]);
    await user.click(addButtons[1]);
    
    // Both in cart
    await screen.findByText('Gaming Laptop');
    expect(screen.getByText('Wireless Mouse')).toBeInTheDocument();
    
    // Remove laptop
    const removeButtons = screen.getAllByLabelText(/remove.*from cart/i);
    await user.click(removeButtons[0]);
    
    // Laptop gone, mouse remains
    expect(screen.queryByText('Gaming Laptop')).not.toBeInTheDocument();
    expect(screen.getByText('Wireless Mouse')).toBeInTheDocument();
    
    // Item count updated
    expect(screen.getByText(/1 item/i)).toBeInTheDocument();
  });
  
  it('clears entire cart', async () => {
    const user = userEvent.setup();
    
    render(
      <>
        <ProductCard product={mockProducts[0]} />
        <ProductCard product={mockProducts[1]} />
        <Cart />
      </>
    );
    
    // Add products
    const addButtons = screen.getAllByRole('button', { name: /add.*to cart/i });
    await user.click(addButtons[0]);
    await user.click(addButtons[1]);
    
    await screen.findByText('Gaming Laptop');
    
    // Clear cart
    await user.click(screen.getByRole('button', { name: /clear cart/i }));
    
    // Cart is empty
    expect(screen.getByText(/cart is empty/i)).toBeInTheDocument();
    expect(screen.queryByText('Gaming Laptop')).not.toBeInTheDocument();
    expect(screen.queryByText('Wireless Mouse')).not.toBeInTheDocument();
  });
  
  it('calculates tax and shipping correctly', async () => {
    const user = userEvent.setup();
    
    render(
      <>
        <ProductCard product={mockProducts[0]} />
        <Cart />
      </>
    );
    
    await user.click(screen.getByRole('button', { name: /add.*to cart/i }));
    
    await screen.findByText('Gaming Laptop');
    
    // Subtotal: $999.99
    expect(screen.getByText(/subtotal.*\$999.99/i)).toBeInTheDocument();
    
    // Tax (8%): $80.00
    expect(screen.getByText(/tax.*\$80.00/i)).toBeInTheDocument();
    
    // Shipping: Free (over $100)
    expect(screen.getByText(/shipping.*free/i)).toBeInTheDocument();
    
    // Total: $1,079.99
    expect(screen.getByText(/total.*\$1,079.99/i)).toBeInTheDocument();
  });
});
                

Testing Product Filtering Integration


// src/components/Product/ProductList.integration.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductList } from './ProductList';

describe('Product Filtering Integration', () => {
  it('filters products by search query', async () => {
    const user = userEvent.setup();
    
    render(<ProductList />);
    
    // Initially shows all products
    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
      expect(screen.getByText('Wireless Mouse')).toBeInTheDocument();
      expect(screen.getByText('Standing Desk')).toBeInTheDocument();
    });
    
    // Search for "laptop"
    await user.type(screen.getByPlaceholderText(/search/i), 'laptop');
    
    // Wait for debounce
    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
      expect(screen.queryByText('Wireless Mouse')).not.toBeInTheDocument();
      expect(screen.queryByText('Standing Desk')).not.toBeInTheDocument();
    });
  });
  
  it('filters by category', async () => {
    const user = userEvent.setup();
    
    render(<ProductList />);
    
    await screen.findByText('Gaming Laptop');
    
    // Select electronics category
    await user.selectOptions(
      screen.getByLabelText(/category/i),
      'electronics'
    );
    
    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
      expect(screen.getByText('Wireless Mouse')).toBeInTheDocument();
      expect(screen.queryByText('Standing Desk')).not.toBeInTheDocument();
    });
  });
  
  it('filters by price range', async () => {
    const user = userEvent.setup();
    
    render(<ProductList />);
    
    await screen.findByText('Gaming Laptop');
    
    // Set price range: $0-$100
    await user.clear(screen.getByLabelText(/min price/i));
    await user.type(screen.getByLabelText(/min price/i), '0');
    await user.clear(screen.getByLabelText(/max price/i));
    await user.type(screen.getByLabelText(/max price/i), '100');
    
    await waitFor(() => {
      expect(screen.getByText('Wireless Mouse')).toBeInTheDocument(); // $29.99
      expect(screen.queryByText('Gaming Laptop')).not.toBeInTheDocument(); // $999.99
    });
  });
  
  it('combines multiple filters', async () => {
    const user = userEvent.setup();
    
    render(<ProductList />);
    
    await screen.findByText('Gaming Laptop');
    
    // Search + category + price
    await user.type(screen.getByPlaceholderText(/search/i), 'gaming');
    await user.selectOptions(screen.getByLabelText(/category/i), 'electronics');
    await user.type(screen.getByLabelText(/min price/i), '500');
    
    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
      expect(screen.queryByText('Wireless Mouse')).not.toBeInTheDocument();
    });
  });
  
  it('shows "no results" when filters match nothing', async () => {
    const user = userEvent.setup();
    
    render(<ProductList />);
    
    await screen.findByText('Gaming Laptop');
    
    // Search for non-existent product
    await user.type(screen.getByPlaceholderText(/search/i), 'xyznonexistent');
    
    await waitFor(() => {
      expect(screen.getByText(/no products found/i)).toBeInTheDocument();
      expect(screen.queryByText('Gaming Laptop')).not.toBeInTheDocument();
    });
  });
  
  it('clears all filters', async () => {
    const user = userEvent.setup();
    
    render(<ProductList />);
    
    await screen.findByText('Gaming Laptop');
    
    // Apply filters
    await user.type(screen.getByPlaceholderText(/search/i), 'laptop');
    await user.selectOptions(screen.getByLabelText(/category/i), 'electronics');
    
    // Verify filtered
    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
      expect(screen.queryByText('Standing Desk')).not.toBeInTheDocument();
    });
    
    // Clear filters
    await user.click(screen.getByRole('button', { name: /clear filters/i }));
    
    // All products visible again
    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
      expect(screen.getByText('Standing Desk')).toBeInTheDocument();
    });
  });
});
                

πŸ’‘ Integration Testing Tips

  • Test realistic workflows: Simulate actual user behavior
  • Test data flow: Verify data passes correctly between components
  • Test state updates: Ensure changes propagate everywhere
  • Use waitFor: Handle async operations properly
  • Test edge cases: Empty states, errors, boundary conditions
  • Keep tests focused: One workflow per test

🌐 Testing Context Providers

Context providers manage global state. Let's test that they properly share state across components.

CartContext Provider Tests


// src/context/CartContext.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CartProvider, useCart } from './CartContext';

// Test component that uses the context
function TestComponent() {
  const { items, itemCount, subtotal, addItem, clearCart } = useCart();
  
  const handleAdd = () => {
    addItem({
      id: '1',
      name: 'Test Product',
      description: 'A test',
      price: 100,
      image: '/test.jpg',
      category: 'test',
      stock: 10,
    });
  };
  
  return (
    <div>
      <p>Items: {itemCount}</p>
      <p>Subtotal: ${subtotal}</p>
      <button onClick={handleAdd}>Add Item</button>
      <button onClick={clearCart}>Clear</button>
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

describe('CartContext', () => {
  it('provides initial empty cart state', () => {
    render(
      <CartProvider>
        <TestComponent />
      </CartProvider>
    );
    
    expect(screen.getByText('Items: 0')).toBeInTheDocument();
    expect(screen.getByText('Subtotal: $0')).toBeInTheDocument();
  });
  
  it('updates state when items are added', async () => {
    const user = userEvent.setup();
    
    render(
      <CartProvider>
        <TestComponent />
      </CartProvider>
    );
    
    await user.click(screen.getByText('Add Item'));
    
    expect(screen.getByText('Items: 1')).toBeInTheDocument();
    expect(screen.getByText('Subtotal: $100')).toBeInTheDocument();
    expect(screen.getByText('Test Product')).toBeInTheDocument();
  });
  
  it('shares state across multiple consumers', async () => {
    const user = userEvent.setup();
    
    render(
      <CartProvider>
        <TestComponent />
        <TestComponent />
      </CartProvider>
    );
    
    const addButtons = screen.getAllByText('Add Item');
    
    // Add from first component
    await user.click(addButtons[0]);
    
    // Both components see the update
    const itemCounts = screen.getAllByText('Items: 1');
    expect(itemCounts).toHaveLength(2);
    
    const subtotals = screen.getAllByText('Subtotal: $100');
    expect(subtotals).toHaveLength(2);
  });
  
  it('clears cart from any consumer', async () => {
    const user = userEvent.setup();
    
    render(
      <CartProvider>
        <TestComponent />
        <TestComponent />
      </CartProvider>
    );
    
    // Add item from first component
    await user.click(screen.getAllByText('Add Item')[0]);
    
    expect(screen.getAllByText('Items: 1')).toHaveLength(2);
    
    // Clear from second component
    await user.click(screen.getAllByText('Clear')[1]);
    
    // Both see empty cart
    expect(screen.getAllByText('Items: 0')).toHaveLength(2);
  });
});
                

Testing Provider with localStorage Persistence


// src/context/CartContext.tsx (with persistence)
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = React.useReducer(
    cartReducer,
    { items: [] },
    (initial) => {
      // Load from localStorage on init
      const saved = localStorage.getItem('cart');
      return saved ? JSON.parse(saved) : initial;
    }
  );
  
  // Save to localStorage on every change
  React.useEffect(() => {
    localStorage.setItem('cart', JSON.stringify(state));
  }, [state]);
  
  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

// Test with localStorage
describe('CartContext with localStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });
  
  it('loads initial state from localStorage', () => {
    // Pre-populate localStorage
    const savedCart = {
      items: [
        { id: '1', name: 'Saved Item', price: 50, quantity: 1, image: '/test.jpg' }
      ]
    };
    localStorage.setItem('cart', JSON.stringify(savedCart));
    
    render(
      <CartProvider>
        <TestComponent />
      </CartProvider>
    );
    
    expect(screen.getByText('Items: 1')).toBeInTheDocument();
    expect(screen.getByText('Saved Item')).toBeInTheDocument();
  });
  
  it('persists state to localStorage', async () => {
    const user = userEvent.setup();
    
    render(
      <CartProvider>
        <TestComponent />
      </CartProvider>
    );
    
    await user.click(screen.getByText('Add Item'));
    
    // Check localStorage
    const saved = localStorage.getItem('cart');
    expect(saved).toBeTruthy();
    
    const parsed = JSON.parse(saved!);
    expect(parsed.items).toHaveLength(1);
    expect(parsed.items[0].name).toBe('Test Product');
  });
  
  it('maintains state across provider remounts', async () => {
    const user = userEvent.setup();
    
    const { unmount } = render(
      <CartProvider>
        <TestComponent />
      </CartProvider>
    );
    
    await user.click(screen.getByText('Add Item'));
    
    // Unmount
    unmount();
    
    // Remount - should restore from localStorage
    render(
      <CartProvider>
        <TestComponent />
      </CartProvider>
    );
    
    expect(screen.getByText('Items: 1')).toBeInTheDocument();
    expect(screen.getByText('Test Product')).toBeInTheDocument();
  });
});
                

βœ… Context Testing Best Practices

  • Test the provider: Not just individual components
  • Test multiple consumers: Verify state is shared
  • Test persistence: If using localStorage/sessionStorage
  • Test initialization: Verify initial state is correct
  • Test error handling: Missing providers, invalid state
  • Clean up: Clear localStorage between tests

🌐 Testing with API Mocks (MSW)

Let's test components that fetch data from APIs using Mock Service Worker.

Testing Data Fetching Component


// src/components/Product/ProductList.tsx
export function ProductList() {
  const [products, setProducts] = React.useState<Product[]>([]);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<string | null>(null);
  
  React.useEffect(() => {
    fetch('/api/products')
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch products');
        return res.json();
      })
      .then(data => {
        setProducts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);
  
  if (loading) return <div>Loading products...</div>;
  if (error) return <div>Error: {error}</div>;
  if (products.length === 0) return <div>No products available</div>;
  
  return (
    <div className="product-grid">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
                

Tests with MSW


// src/components/Product/ProductList.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { server } from '@/test/mocks/server';
import { ProductList } from './ProductList';

describe('ProductList with API', () => {
  it('displays loading state initially', () => {
    render(<ProductList />);
    
    expect(screen.getByText('Loading products...')).toBeInTheDocument();
  });
  
  it('displays products after successful fetch', async () => {
    render(<ProductList />);
    
    // Wait for products to load
    await waitFor(() => {
      expect(screen.queryByText('Loading products...')).not.toBeInTheDocument();
    });
    
    // Products should be visible
    expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
    expect(screen.getByText('Wireless Mouse')).toBeInTheDocument();
  });
  
  it('displays error message on fetch failure', async () => {
    // Override default handler to return error
    server.use(
      rest.get('/api/products', (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );
    
    render(<ProductList />);
    
    await waitFor(() => {
      expect(screen.getByText(/error.*failed to fetch/i)).toBeInTheDocument();
    });
    
    expect(screen.queryByText('Gaming Laptop')).not.toBeInTheDocument();
  });
  
  it('displays empty state when no products', async () => {
    server.use(
      rest.get('/api/products', (req, res, ctx) => {
        return res(ctx.json([]));
      })
    );
    
    render(<ProductList />);
    
    await waitFor(() => {
      expect(screen.getByText('No products available')).toBeInTheDocument();
    });
  });
  
  it('handles network errors', async () => {
    server.use(
      rest.get('/api/products', (req, res, ctx) => {
        return res.networkError('Failed to connect');
      })
    );
    
    render(<ProductList />);
    
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});
                

Testing Search with API


// src/components/Product/ProductSearch.tsx
export function ProductSearch() {
  const [query, setQuery] = React.useState('');
  const [results, setResults] = React.useState<Product[]>([]);
  const [searching, setSearching] = React.useState(false);
  
  const handleSearch = React.useMemo(
    () => debounce(async (searchQuery: string) => {
      if (!searchQuery) {
        setResults([]);
        return;
      }
      
      setSearching(true);
      
      try {
        const res = await fetch(`/api/products/search?q=${searchQuery}`);
        const data = await res.json();
        setResults(data);
      } catch (error) {
        console.error('Search failed:', error);
        setResults([]);
      } finally {
        setSearching(false);
      }
    }, 300),
    []
  );
  
  React.useEffect(() => {
    handleSearch(query);
  }, [query, handleSearch]);
  
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      
      {searching && <p>Searching...</p>}
      
      <div>
        {results.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

// Test
describe('ProductSearch with API', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  
  afterEach(() => {
    vi.useRealTimers();
  });
  
  it('searches products via API', async () => {
    const user = userEvent.setup({ delay: null });
    
    server.use(
      rest.get('/api/products/search', (req, res, ctx) => {
        const query = req.url.searchParams.get('q');
        
        const allProducts = [
          { id: '1', name: 'Gaming Laptop', price: 999 },
          { id: '2', name: 'Wireless Mouse', price: 29 },
        ];
        
        const filtered = allProducts.filter(p =>
          p.name.toLowerCase().includes(query?.toLowerCase() || '')
        );
        
        return res(ctx.json(filtered));
      })
    );
    
    render(<ProductSearch />);
    
    const input = screen.getByPlaceholderText('Search products...');
    await user.type(input, 'laptop');
    
    // Advance timers past debounce
    vi.advanceTimersByTime(300);
    
    // Wait for results
    await waitFor(() => {
      expect(screen.getByText('Gaming Laptop')).toBeInTheDocument();
      expect(screen.queryByText('Wireless Mouse')).not.toBeInTheDocument();
    });
  });
  
  it('shows loading state while searching', async () => {
    const user = userEvent.setup({ delay: null });
    
    server.use(
      rest.get('/api/products/search', async (req, res, ctx) => {
        await ctx.delay(100);
        return res(ctx.json([]));
      })
    );
    
    render(<ProductSearch />);
    
    await user.type(screen.getByPlaceholderText('Search products...'), 'test');
    vi.advanceTimersByTime(300);
    
    // Should show searching state
    expect(await screen.findByText('Searching...')).toBeInTheDocument();
  });
  
  it('clears results when search is empty', async () => {
    const user = userEvent.setup({ delay: null });
    
    render(<ProductSearch />);
    
    const input = screen.getByPlaceholderText('Search products...');
    
    // Search for something
    await user.type(input, 'laptop');
    vi.advanceTimersByTime(300);
    
    await screen.findByText('Gaming Laptop');
    
    // Clear search
    await user.clear(input);
    vi.advanceTimersByTime(300);
    
    // Results should be gone
    await waitFor(() => {
      expect(screen.queryByText('Gaming Laptop')).not.toBeInTheDocument();
    });
  });
});
                

Testing Optimistic Updates


// Component with optimistic update
export function AddToCartButton({ product }: { product: Product }) {
  const [isAdded, setIsAdded] = React.useState(false);
  const { addItem } = useCart();
  
  const handleClick = async () => {
    // Optimistic update
    setIsAdded(true);
    addItem(product);
    
    try {
      // Send to API
      await fetch('/api/cart', {
        method: 'POST',
        body: JSON.stringify({ productId: product.id }),
      });
    } catch (error) {
      // Rollback on error
      setIsAdded(false);
      removeItem(product.id);
    }
  };
  
  return (
    <button onClick={handleClick} disabled={isAdded}>
      {isAdded ? 'Added βœ“' : 'Add to Cart'}
    </button>
  );
}

// Test
it('shows immediate feedback with optimistic update', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.post('/api/cart', async (req, res, ctx) => {
      await ctx.delay(500); // Slow API
      return res(ctx.status(200));
    })
  );
  
  render(<AddToCartButton product={mockProduct} />);
  
  const button = screen.getByRole('button');
  
  // Initially shows "Add to Cart"
  expect(button).toHaveTextContent('Add to Cart');
  
  await user.click(button);
  
  // Immediately shows "Added" (optimistic)
  expect(button).toHaveTextContent('Added βœ“');
  expect(button).toBeDisabled();
  
  // Wait for API call
  await waitFor(() => {
    expect(button).toHaveTextContent('Added βœ“');
  }, { timeout: 1000 });
});

it('rolls back on API error', async () => {
  const user = userEvent.setup();
  
  server.use(
    rest.post('/api/cart', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  render(<AddToCartButton product={mockProduct} />);
  
  const button = screen.getByRole('button');
  
  await user.click(button);
  
  // Initially optimistic
  expect(button).toHaveTextContent('Added βœ“');
  
  // Rolls back after error
  await waitFor(() => {
    expect(button).toHaveTextContent('Add to Cart');
    expect(button).not.toBeDisabled();
  });
});
                

πŸ’‘ API Testing with MSW Tips

  • Set up global handlers: Default responses for all tests
  • Override per test: Use server.use() for specific scenarios
  • Test all states: Loading, success, error, empty
  • Use realistic delays: Test loading states properly
  • Test error scenarios: Network errors, timeouts, 4xx/5xx
  • Verify request data: Check what's sent to the API
  • Test optimistic updates: Immediate feedback + rollback

🌐 E2E Tests: Complete Shopping Workflows

Now let's write end-to-end tests that verify complete user workflows in a real browser using Playwright.

Complete Shopping Journey Test


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

test.describe('Complete Shopping Flow', () => {
  test('user can browse, add to cart, and checkout', async ({ page }) => {
    // 1. Visit homepage
    await page.goto('/');
    
    // Verify homepage loaded
    await expect(page.getByRole('heading', { name: /products/i })).toBeVisible();
    
    // 2. Browse products
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('Wireless Mouse')).toBeVisible();
    
    // 3. Search for a product
    await page.getByPlaceholder('Search products...').fill('laptop');
    
    // Wait for search results
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('Wireless Mouse')).not.toBeVisible();
    
    // 4. View product details
    await page.getByText('Gaming Laptop').click();
    
    await expect(page).toHaveURL(/\/products\/\d+/);
    await expect(page.getByRole('heading', { name: 'Gaming Laptop' })).toBeVisible();
    await expect(page.getByText('$999.99')).toBeVisible();
    
    // 5. Add to cart
    await page.getByRole('button', { name: /add to cart/i }).click();
    
    // Verify added notification or cart update
    await expect(page.getByText(/added to cart/i)).toBeVisible();
    
    // 6. View cart
    await page.getByRole('link', { name: /cart/i }).click();
    
    await expect(page).toHaveURL(/\/cart/);
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('1 item')).toBeVisible();
    
    // 7. Update quantity
    await page.getByLabel('Increase quantity').click();
    
    await expect(page.getByText('2 items')).toBeVisible();
    await expect(page.getByText('$1,999.98')).toBeVisible(); // Subtotal
    
    // 8. Proceed to checkout
    await page.getByRole('button', { name: /checkout/i }).click();
    
    await expect(page).toHaveURL(/\/checkout/);
    
    // 9. Fill shipping information
    await page.getByLabel('Full Name').fill('John Doe');
    await page.getByLabel('Email').fill('john@example.com');
    await page.getByLabel('Address').fill('123 Main St');
    await page.getByLabel('City').fill('New York');
    await page.getByLabel('State').selectOption('NY');
    await page.getByLabel('Zip Code').fill('10001');
    
    // 10. Fill payment information
    await page.getByLabel('Card Number').fill('4111111111111111');
    await page.getByLabel('Expiry Date').fill('12/25');
    await page.getByLabel('CVV').fill('123');
    
    // 11. Review order summary
    await expect(page.getByText('Order Summary')).toBeVisible();
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('Quantity: 2')).toBeVisible();
    
    // Verify totals
    await expect(page.getByText(/subtotal.*\$1,999.98/i)).toBeVisible();
    await expect(page.getByText(/tax.*\$160.00/i)).toBeVisible();
    await expect(page.getByText(/shipping.*free/i)).toBeVisible();
    await expect(page.getByText(/total.*\$2,159.98/i)).toBeVisible();
    
    // 12. Place order
    await page.getByRole('button', { name: /place order/i }).click();
    
    // 13. Verify order confirmation
    await expect(page).toHaveURL(/\/order-confirmation/);
    await expect(page.getByText(/order confirmed/i)).toBeVisible();
    await expect(page.getByText(/order #/i)).toBeVisible();
    
    // Verify order details
    await expect(page.getByText('John Doe')).toBeVisible();
    await expect(page.getByText('john@example.com')).toBeVisible();
  });
});
                

Testing Product Filtering


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

test.describe('Product Filtering', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });
  
  test('filters by search query', async ({ page }) => {
    // Type in search
    await page.getByPlaceholder('Search products...').fill('mouse');
    
    // Should show only matching products
    await expect(page.getByText('Wireless Mouse')).toBeVisible();
    await expect(page.getByText('Gaming Laptop')).not.toBeVisible();
  });
  
  test('filters by category', async ({ page }) => {
    // Select category
    await page.getByLabel('Category').selectOption('electronics');
    
    // Should show only electronics
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('Wireless Mouse')).toBeVisible();
    await expect(page.getByText('Standing Desk')).not.toBeVisible();
  });
  
  test('filters by price range', async ({ page }) => {
    // Set price range
    await page.getByLabel('Min Price').fill('0');
    await page.getByLabel('Max Price').fill('100');
    
    // Should show only products in range
    await expect(page.getByText('Wireless Mouse')).toBeVisible(); // $29.99
    await expect(page.getByText('Gaming Laptop')).not.toBeVisible(); // $999.99
  });
  
  test('combines multiple filters', async ({ page }) => {
    // Apply multiple filters
    await page.getByPlaceholder('Search products...').fill('gaming');
    await page.getByLabel('Category').selectOption('electronics');
    await page.getByLabel('Min Price').fill('500');
    
    // Should show only products matching all filters
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('Wireless Mouse')).not.toBeVisible();
    await expect(page.getByText('Standing Desk')).not.toBeVisible();
  });
  
  test('clears all filters', async ({ page }) => {
    // Apply filters
    await page.getByPlaceholder('Search products...').fill('laptop');
    await page.getByLabel('Category').selectOption('electronics');
    
    // Verify filtered
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('Standing Desk')).not.toBeVisible();
    
    // Clear filters
    await page.getByRole('button', { name: /clear filters/i }).click();
    
    // All products should be visible
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('Wireless Mouse')).toBeVisible();
    await expect(page.getByText('Standing Desk')).toBeVisible();
  });
  
  test('shows no results message', async ({ page }) => {
    await page.getByPlaceholder('Search products...').fill('nonexistentproduct');
    
    await expect(page.getByText(/no products found/i)).toBeVisible();
  });
});
                

Testing Cart Management


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

test.describe('Cart Management', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });
  
  test('adds and removes items from cart', async ({ page }) => {
    // Add item
    await page.getByText('Gaming Laptop').click();
    await page.getByRole('button', { name: /add to cart/i }).click();
    
    // Go to cart
    await page.getByRole('link', { name: /cart/i }).click();
    
    // Verify item in cart
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('1 item')).toBeVisible();
    
    // Remove item
    await page.getByLabel(/remove.*from cart/i).click();
    
    // Cart should be empty
    await expect(page.getByText(/cart is empty/i)).toBeVisible();
  });
  
  test('updates item quantity', async ({ page }) => {
    // Add item to cart
    await page.getByText('Gaming Laptop').click();
    await page.getByRole('button', { name: /add to cart/i }).click();
    await page.getByRole('link', { name: /cart/i }).click();
    
    // Initial quantity
    await expect(page.getByLabelText('Quantity')).toHaveText('1');
    
    // Increase quantity
    await page.getByLabel('Increase quantity').click();
    await expect(page.getByLabelText('Quantity')).toHaveText('2');
    
    await page.getByLabel('Increase quantity').click();
    await expect(page.getByLabelText('Quantity')).toHaveText('3');
    
    // Decrease quantity
    await page.getByLabel('Decrease quantity').click();
    await expect(page.getByLabelText('Quantity')).toHaveText('2');
  });
  
  test('maintains cart across page navigation', async ({ page }) => {
    // Add item
    await page.getByText('Gaming Laptop').click();
    await page.getByRole('button', { name: /add to cart/i }).click();
    
    // Navigate away
    await page.getByRole('link', { name: /home/i }).click();
    await expect(page).toHaveURL('/');
    
    // Go back to cart
    await page.getByRole('link', { name: /cart/i }).click();
    
    // Item should still be there
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('1 item')).toBeVisible();
  });
  
  test('persists cart after page reload', async ({ page }) => {
    // Add item
    await page.getByText('Gaming Laptop').click();
    await page.getByRole('button', { name: /add to cart/i }).click();
    await page.getByRole('link', { name: /cart/i }).click();
    
    // Verify item in cart
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    
    // Reload page
    await page.reload();
    
    // Item should still be there
    await expect(page.getByText('Gaming Laptop')).toBeVisible();
    await expect(page.getByText('1 item')).toBeVisible();
  });
  
  test('calculates totals correctly', async ({ page }) => {
    // Add laptop ($999.99)
    await page.getByText('Gaming Laptop').click();
    await page.getByRole('button', { name: /add to cart/i }).click();
    
    // Go back and add mouse ($29.99)
    await page.goBack();
    await page.getByText('Wireless Mouse').click();
    await page.getByRole('button', { name: /add to cart/i }).click();
    
    // Go to cart
    await page.getByRole('link', { name: /cart/i }).click();
    
    // Verify totals
    // Subtotal: $1,029.98
    await expect(page.getByText(/subtotal.*\$1,029.98/i)).toBeVisible();
    
    // Tax (8%): $82.40
    await expect(page.getByText(/tax.*\$82.40/i)).toBeVisible();
    
    // Shipping: Free (over $100)
    await expect(page.getByText(/shipping.*free/i)).toBeVisible();
    
    // Total: $1,112.38
    await expect(page.getByText(/total.*\$1,112.38/i)).toBeVisible();
  });
});
                

Testing Responsive Design


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

test.describe('Responsive Design', () => {
  test('desktop navigation', async ({ page }) => {
    await page.goto('/');
    
    // Desktop menu should be visible
    await expect(page.getByRole('navigation')).toBeVisible();
    await expect(page.getByRole('link', { name: /products/i })).toBeVisible();
    await expect(page.getByRole('link', { name: /cart/i })).toBeVisible();
    
    // Mobile menu button should not be visible
    await expect(page.getByLabel('Open menu')).not.toBeVisible();
  });
  
  test('mobile navigation', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');
    
    // Mobile menu button should be visible
    const menuButton = page.getByLabel('Open menu');
    await expect(menuButton).toBeVisible();
    
    // Navigation links hidden initially
    const nav = page.getByRole('navigation');
    await expect(nav.getByRole('link', { name: /products/i })).not.toBeVisible();
    
    // Open menu
    await menuButton.click();
    
    // Navigation links now visible
    await expect(nav.getByRole('link', { name: /products/i })).toBeVisible();
    await expect(nav.getByRole('link', { name: /cart/i })).toBeVisible();
  });
  
  test('product grid on mobile', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');
    
    // Products should stack vertically on mobile
    const products = page.getByRole('article');
    const firstProduct = products.first();
    const secondProduct = products.nth(1);
    
    const firstBox = await firstProduct.boundingBox();
    const secondBox = await secondProduct.boundingBox();
    
    // Second product should be below first (greater Y coordinate)
    expect(secondBox!.y).toBeGreaterThan(firstBox!.y + firstBox!.height);
  });
});
                

Testing Error Scenarios


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

test.describe('Error Handling', () => {
  test('handles out of stock products', async ({ page }) => {
    await page.goto('/products/out-of-stock-item');
    
    // Add to cart button should be disabled or not present
    const addButton = page.getByRole('button', { name: /add to cart/i });
    
    if (await addButton.isVisible()) {
      await expect(addButton).toBeDisabled();
    }
    
    // Should show out of stock message
    await expect(page.getByText(/out of stock/i)).toBeVisible();
  });
  
  test('validates checkout form', async ({ page }) => {
    // Add item and go to checkout
    await page.goto('/');
    await page.getByText('Gaming Laptop').click();
    await page.getByRole('button', { name: /add to cart/i }).click();
    await page.getByRole('link', { name: /cart/i }).click();
    await page.getByRole('button', { name: /checkout/i }).click();
    
    // Try to submit without filling required fields
    await page.getByRole('button', { name: /place order/i }).click();
    
    // Should show validation errors
    await expect(page.getByText(/name is required/i)).toBeVisible();
    await expect(page.getByText(/email is required/i)).toBeVisible();
    await expect(page.getByText(/address is required/i)).toBeVisible();
  });
  
  test('handles invalid payment information', async ({ page }) => {
    // Add item and go to checkout
    await page.goto('/');
    await page.getByText('Gaming Laptop').click();
    await page.getByRole('button', { name: /add to cart/i }).click();
    await page.getByRole('link', { name: /cart/i }).click();
    await page.getByRole('button', { name: /checkout/i }).click();
    
    // Fill shipping info
    await page.getByLabel('Full Name').fill('John Doe');
    await page.getByLabel('Email').fill('john@example.com');
    await page.getByLabel('Address').fill('123 Main St');
    await page.getByLabel('City').fill('New York');
    await page.getByLabel('State').selectOption('NY');
    await page.getByLabel('Zip Code').fill('10001');
    
    // Enter invalid card number
    await page.getByLabel('Card Number').fill('1234');
    await page.getByLabel('Expiry Date').fill('12/20'); // Expired
    await page.getByLabel('CVV').fill('12'); // Too short
    
    // Try to submit
    await page.getByRole('button', { name: /place order/i }).click();
    
    // Should show payment errors
    await expect(page.getByText(/invalid card number/i)).toBeVisible();
    await expect(page.getByText(/card has expired/i)).toBeVisible();
    await expect(page.getByText(/invalid cvv/i)).toBeVisible();
  });
});
                

βœ… E2E Testing Best Practices

  • Test critical paths: Focus on main user workflows
  • Use accessible selectors: getByRole, getByLabel, getByText
  • Wait for elements: Use expect().toBeVisible() not manual waits
  • Test responsive design: Multiple viewport sizes
  • Test error scenarios: Validation, out of stock, network errors
  • Keep tests independent: Each test should run alone
  • Use page object model: For larger test suites
  • Run in CI: Automate E2E tests in pipeline

πŸ”„ CI/CD Setup

Let's set up automated testing in a CI/CD pipeline using GitHub Actions.

GitHub Actions Workflow


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

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

jobs:
  unit-and-integration:
    name: Unit & Integration Tests
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        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 -- --run
      
      - name: Generate coverage report
        run: npm run test:coverage
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json
      
      - name: Comment coverage on PR
        if: github.event_name == 'pull_request'
        uses: romeovs/lcov-reporter-action@v0.3.1
        with:
          lcov-file: ./coverage/lcov.info
          github-token: ${{ secrets.GITHUB_TOKEN }}
  
  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        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 browsers
        run: npx playwright install --with-deps
      
      - name: Build application
        run: npm run build
      
      - 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/
          retention-days: 30
      
      - name: Upload test videos
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-videos
          path: test-results/
          retention-days: 7
  
  lint:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        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 ESLint
        run: npm run lint
      
      - name: Run TypeScript check
        run: npm run type-check
                

Package.json Scripts for CI


{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:coverage": "vitest --coverage",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "lint": "eslint . --ext .ts,.tsx",
    "type-check": "tsc --noEmit"
  }
}
                

Test Coverage Requirements


// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.spec.ts',
        '**/*.test.ts',
        '**/*.test.tsx',
        '**/types.ts',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
  },
});
                

Parallel Test Execution


// playwright.config.ts
export default defineConfig({
  // Run tests in parallel
  fullyParallel: true,
  
  // Use 2 workers on CI, unlimited locally
  workers: process.env.CI ? 2 : undefined,
  
  // Retry failed tests on CI
  retries: process.env.CI ? 2 : 0,
  
  // Fail fast on CI
  maxFailures: process.env.CI ? 10 : undefined,
  
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    // Only run other browsers on CI
    ...(process.env.CI ? [
      {
        name: 'firefox',
        use: { ...devices['Desktop Firefox'] },
      },
      {
        name: 'webkit',
        use: { ...devices['Desktop Safari'] },
      },
    ] : []),
  ],
});
                

πŸ’‘ CI/CD Best Practices

  • Run tests on every PR: Catch issues before merge
  • Cache dependencies: Speed up pipeline
  • Parallel execution: Run tests faster
  • Retry flaky tests: But fix the root cause
  • Upload artifacts: Screenshots, videos, reports
  • Block merges: Require passing tests
  • Track coverage: Maintain or improve over time

πŸ“Š Test Coverage Analysis

Let's analyze test coverage and ensure we're testing the right things.

Generating Coverage Reports


# Generate coverage report
npm run test:coverage

# Open HTML report in browser
open coverage/index.html
                

Understanding Coverage Metrics

Metric Description Target
Line Coverage Percentage of code lines executed 80%+
Function Coverage Percentage of functions called 80%+
Branch Coverage Percentage of if/else branches taken 75%+
Statement Coverage Percentage of statements executed 80%+

Coverage Goals

βœ… Good Coverage Strategy

  • Utilities: 95%+ - Pure functions are easy to test
  • Business logic: 90%+ - Critical app logic
  • Components: 80%+ - Focus on user-facing behavior
  • Integration tests: Cover main user workflows
  • E2E tests: Cover critical business paths

⚠️ Coverage Anti-Patterns

  • Chasing 100%: Diminishing returns, focus on value
  • Testing implementation: High coverage but brittle tests
  • Ignoring integration: All unit tests, no integration
  • No E2E tests: Missing critical user workflows
  • Testing trivial code: Getters, setters, constants

Identifying Coverage Gaps


# Run coverage and identify uncovered code
npm run test:coverage

# Look for red/yellow highlights in HTML report
# Focus on:
# - Uncovered error handling
# - Uncovered edge cases
# - Uncovered conditional branches
                

Test Quality Over Quantity

graph TB A[Test Quality] --> B[Tests User Behavior] A --> C[Catches Real Bugs] A --> D[Easy to Maintain] A --> E[Fast Execution] F[High Coverage] -.->|Doesn't Guarantee| A style A fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style F fill:#ffc107,stroke:#e67700,stroke-width:2px,color:#333
πŸ’‘ Remember: "Coverage tells you what you haven't tested, not what you have tested well. Focus on testing behavior that matters to users."

πŸ“š Summary and Next Steps

Congratulations! You've built comprehensive test coverage for a real-world e-commerce application. Let's recap what you've learned and accomplished.

What You've Built

πŸŽ‰ Project Achievements

  • βœ… Complete test infrastructure with Vitest, RTL, MSW, and Playwright
  • βœ… Unit tests for calculations, filters, and utilities
  • βœ… Component tests for UI components
  • βœ… Reducer tests for state management logic
  • βœ… Custom hook tests with renderHook
  • βœ… Integration tests for complete features
  • βœ… Context provider tests with persistence
  • βœ… API mocking with MSW
  • βœ… E2E tests for critical user workflows
  • βœ… CI/CD pipeline configuration
  • βœ… Coverage analysis and reporting

Testing Pyramid Recap

graph TB A[Your Test Suite] A --> B[Unit Tests
70-80%
βœ“ Calculations
βœ“ Utilities
βœ“ Filters] A --> C[Integration Tests
15-25%
βœ“ Cart Flow
βœ“ Context
βœ“ API] A --> D[E2E Tests
5-10%
βœ“ Shopping Flow
βœ“ Checkout
βœ“ Errors] 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

Key Takeaways

Concept Key Learning
Test Infrastructure Proper setup is critical - invest time in configuration
Unit Tests Fast, focused tests for pure logic and calculations
Component Tests Test user behavior, not implementation details
Integration Tests Verify components work together correctly
E2E Tests Test critical paths in real browsers
MSW Mock APIs at network level for realistic tests
CI/CD Automate tests to catch issues early

Next Steps

πŸš€ Continue Your Testing Journey

  1. Add more tests: Increase coverage where needed
  2. Refactor with confidence: Your tests enable safe refactoring
  3. Monitor test health: Fix flaky tests immediately
  4. Review test failures: Every failure is a learning opportunity
  5. Share knowledge: Help teammates write better tests
  6. Improve performance: Keep tests fast
  7. Update tests: When requirements change, update tests first

Additional Resources

πŸŽ“ Module 9 Complete!

You've successfully completed the Testing React Applications module! You now have:

  • A production-ready testing infrastructure
  • Comprehensive test coverage for a real application
  • Skills to test any React + TypeScript application
  • Automated testing in CI/CD
  • Confidence to ship quality code

What's Next: Module 10 - Advanced Topics and Deployment!

πŸ’¬ Final Thought: "Testing isn't about achieving 100% coverageβ€”it's about building confidence that your application works correctly and giving yourself the freedom to improve it fearlessly."