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

Before we write a single assertion, we need a solid foundation. A test suite is only as trustworthy as its infrastructure: if the environment is misconfigured, tests pass when they should fail (false confidence) or fail when the code is fine (flaky noise that trains you to ignore red). So we'll invest a little upfront to get it right, then reap the reward across every test we write. Our stack for 2026 is Vitest (the test runner, built on Vite so it shares your app's exact config), React Testing Library for rendering components the way a user sees them, user-event v14 for realistic interactions, MSW v2 for intercepting network calls, and Playwright for full browser end-to-end runs.

Here's the map of what we're going to test and which tool covers each layer. Keep this picture in mind β€” every section below fills in one part of it:

graph TD App["E-commerce App"] --> Utils["Utilities & calc
(pure functions)"] App --> Comp["UI components
(ProductCard, Cart, SearchBar)"] App --> State["State layer
(reducer, hooks, context)"] App --> Net["Data fetching
(fetch / API)"] App --> Flows["User journeys
(browse to checkout)"] Utils --> T1["Unit tests
Vitest"] Comp --> T2["Component tests
RTL + user-event"] State --> T3["Hook + reducer tests
renderHook"] Net --> T4["API tests
MSW v2"] Flows --> T5["E2E tests
Playwright"] style T1 fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style T2 fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style T3 fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style T4 fill:#339af0,stroke:#1971c2,stroke-width:2px,color:#fff style T5 fill:#f59f00,stroke:#e67700,stroke-width:2px,color:#fff

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

# App runtime deps our render helper wraps (regular deps, not -D)
# These are already in a real app; listed here so the test utils compile.
npm install react-router-dom @tanstack/react-query
                

A quick note on versions: this project targets React 19, TypeScript 5.x, Vitest 3, MSW 2, user-event 14, and TanStack Query 5. The APIs below assume those major versions β€” mixing in an older major (especially MSW 1 or user-event 13) is the most common reason copied snippets fail to run.

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

MSW (Mock Service Worker) intercepts requests at the network layer, so your components use the real fetch they'd use in production β€” you never have to stub fetch itself. In tests it runs through msw/node. Note the MSW v2 API we use below: request handlers come from http (not the old rest), and you build responses with HttpResponse instead of the old (req, res, ctx) signature. This is the current, actively-maintained API β€” anything you find online using rest.get(...) and res(ctx.json(...)) is MSW v1 and will not run against v2.


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

export const handlers = [
  // Get all products
  http.get('/api/products', () => {
    return HttpResponse.json(products);
  }),

  // Get single product β€” params are typed and destructured from the callback arg
  http.get('/api/products/:id', ({ params }) => {
    const { id } = params;
    const product = products.find((p) => p.id === id);

    if (!product) {
      return HttpResponse.json({ message: 'Product not found' }, { status: 404 });
    }

    return HttpResponse.json(product);
  }),

  // Search products β€” read the query string from the intercepted Request
  http.get('/api/products/search', ({ request }) => {
    const url = new URL(request.url);
    const query = url.searchParams.get('q') ?? '';
    const filtered = products.filter((p) =>
      p.name.toLowerCase().includes(query.toLowerCase())
    );

    return HttpResponse.json(filtered);
  }),
];

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

// setupServer spins up the interceptor for the Node (Vitest) environment
export const server = setupServer(...handlers);
                

⚠️ MSW v1 vs v2 at a glance

If you've seen older tutorials, here's the translation you'll need constantly:

  • rest.get(url, resolver)http.get(url, resolver)
  • (req, res, ctx) => res(ctx.json(data))() => HttpResponse.json(data)
  • res(ctx.status(500))new HttpResponse(null, { status: 500 })
  • req.params → the { params } argument
  • req.url.searchParamsnew URL(request.url).searchParams
  • res.networkError('...')HttpResponse.error()
  • ctx.delay(ms)await delay(ms) (import delay from 'msw')

Test Utilities

Almost every component in a real app assumes it's rendered inside some providers β€” a router for navigation, a data-fetching client, our cart context. If we had to wire those up by hand in every test, the boilerplate would bury the actual assertions. So we create one renderWithProviders helper that wraps the component under test in the same provider tree the real app uses, then re-export it as render so tests read cleanly.

Two details matter for correctness. First, we create a fresh QueryClient for every render β€” a shared client would leak cached data between tests and make them order-dependent. Second, we disable retries so a mocked error surfaces immediately instead of TanStack Query silently retrying and timing out your test.


// src/test/utils.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { type ReactElement, type ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CartProvider } from '../context/CartContext';

// A brand-new client per render keeps tests fully isolated.
function makeTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false },        // fail fast in tests
      mutations: { retry: false },
    },
  });
}

interface RenderWithProvidersOptions extends Omit<RenderOptions, 'wrapper'> {
  route?: string; // let a test control the starting URL
}

export function renderWithProviders(
  ui: ReactElement,
  { route = '/', ...options }: RenderWithProvidersOptions = {}
) {
  const queryClient = makeTestQueryClient();

  function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <MemoryRouter initialEntries={[route]}>
          <CartProvider>{children}</CartProvider>
        </MemoryRouter>
      </QueryClientProvider>
    );
  }

  return render(ui, { wrapper: Wrapper, ...options });
}

// Re-export the full RTL API, then override render with our wrapped version.
export * from '@testing-library/react';
export { renderWithProviders as render };
                

πŸ’‘ Why MemoryRouter and not BrowserRouter?

MemoryRouter keeps its history in memory instead of touching window.location, which jsdom only partially implements. It also lets each test set its own starting route via initialEntries, so you can render a component "as if" the user landed on /products/1 without any real navigation.

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

We start with unit tests for our utility functions, and that ordering is deliberate. These functions are pure β€” same input, same output, no side effects, no DOM, no network β€” which makes them the cheapest and most reliable things to test. A pure-function test needs no render, no mocking, and no async waiting, so it runs in microseconds and never flakes. In an e-commerce app the money math lives here (subtotals, tax, shipping, discounts), so a bug in these functions is exactly the kind that silently overcharges a customer. Locking them down first gives us a rock-solid base the higher layers can build on.

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 we move up a layer to the components themselves, using React Testing Library. RTL's guiding philosophy is that the more your tests resemble the way your software is used, the more confidence they give you. So instead of reaching into a component's state or props, we query the rendered output the way a user (or a screen reader) would β€” by visible text, by role, by label β€” and we drive it with realistic clicks and typing through user-event. The payoff: these tests survive refactors. Rename an internal variable, switch useState for useReducer, restructure the JSX β€” as long as the user-visible behavior is unchanged, the tests stay green. That is exactly the safety net that lets you refactor without fear.

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);
  // In React 19 + strict TS, useRef needs an initial value; the browser/Node
  // timer type is portable via ReturnType<typeof setTimeout>.
  const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  
  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, vi } 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)

Real components talk to a server, and a good test suite must cover what happens when that server is slow, empty, or broken β€” not just the happy path. The naive approach is to stub fetch, but that couples your tests to implementation details and breaks the moment you switch to axios or TanStack Query. MSW takes a better approach: it intercepts requests at the network boundary, so your component makes its real call and MSW answers with whatever response the test needs. A global set of handlers (from our setup) gives every test a sensible default, and server.use(...) overrides a single endpoint for one test β€” for an error, an empty list, or a deliberate delay β€” with the override automatically reset afterward. That's how we test loading, success, empty, and error states with confidence.

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 { http, HttpResponse } 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 />);

    // findBy* combines "wait" + "query": it retries until the element appears
    // or times out, which is exactly what we want after an async fetch.
    expect(await screen.findByText('Gaming Laptop')).toBeInTheDocument();
    expect(screen.getByText('Wireless Mouse')).toBeInTheDocument();

    // The loading text is gone once data has arrived.
    expect(screen.queryByText('Loading products...')).not.toBeInTheDocument();
  });

  it('displays error message on fetch failure', async () => {
    // Override the default handler for THIS test only (reset after each test).
    server.use(
      http.get('/api/products', () => {
        return new HttpResponse(null, { status: 500 });
      })
    );

    render(<ProductList />);

    expect(await screen.findByText(/error.*failed to fetch/i)).toBeInTheDocument();
    expect(screen.queryByText('Gaming Laptop')).not.toBeInTheDocument();
  });

  it('displays empty state when no products', async () => {
    server.use(
      http.get('/api/products', () => {
        return HttpResponse.json([]);
      })
    );

    render(<ProductList />);

    expect(await screen.findByText('No products available')).toBeInTheDocument();
  });

  it('handles network errors', async () => {
    server.use(
      http.get('/api/products', () => {
        // HttpResponse.error() simulates a failed connection (rejected fetch).
        return HttpResponse.error();
      })
    );

    render(<ProductList />);

    expect(await screen.findByText(/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>
  );
}

// src/components/Product/ProductSearch.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 { http, HttpResponse, delay } from 'msw';
import { server } from '@/test/mocks/server';
import { ProductSearch } from './ProductSearch';

describe('ProductSearch with API', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  
  afterEach(() => {
    vi.useRealTimers();
  });
  
  it('searches products via API', async () => {
    const user = userEvent.setup({ delay: null });

    server.use(
      http.get('/api/products/search', ({ request }) => {
        const query = new URL(request.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 HttpResponse.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(
      http.get('/api/products/search', async () => {
        await delay(100); // MSW v2: import { delay } from 'msw'
        return HttpResponse.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, removeItem } = useCart(); // removeItem needed for rollback

  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(
    http.post('/api/cart', async () => {
      await delay(500); // Slow API
      return new HttpResponse(null, { 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(
    http.post('/api/cart', () => {
      return new HttpResponse(null, { 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

At the top of the pyramid sit end-to-end (E2E) tests. Everything so far ran in jsdom, a simulated DOM β€” fast, but not a real browser. E2E tests with Playwright launch an actual browser, load your real built app, and click through it the way a customer would: browse, search, add to cart, check out. They're the slowest and most involved tests we write, so we keep them few and reserve them for the workflows that must work or the business loses money. Their unique value is that they exercise the whole stack together β€” routing, real network, rendering, persistence β€” catching integration gaps that no isolated test can see.

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.getByLabel('Quantity')).toHaveText('1');
    
    // Increase quantity
    await page.getByLabel('Increase quantity').click();
    await expect(page.getByLabel('Quantity')).toHaveText('2');
    
    await page.getByLabel('Increase quantity').click();
    await expect(page.getByLabel('Quantity')).toHaveText('3');
    
    // Decrease quantity
    await page.getByLabel('Decrease quantity').click();
    await expect(page.getByLabel('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@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          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@v4
        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@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          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@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
      
      - name: Upload test videos
        if: always()
        uses: actions/upload-artifact@v4
        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@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          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."