+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Part 148 of 354

🎣 Testing Hooks: Custom Hook Testing

Master the art of testing custom React hooks in TypeScript with comprehensive testing strategies, mock management, and advanced patterns πŸš€

πŸš€Intermediate
25 min read

Prerequisites

  • Understanding of React hooks and TypeScript πŸ“
  • Knowledge of Jest and React Testing Library ⚑
  • Familiarity with custom hook patterns and state management πŸ’»

What you'll learn

  • Test custom React hooks with comprehensive coverage and isolation 🎯
  • Master testing patterns for stateful, async, and effect-based hooks πŸ—οΈ
  • Handle complex hook dependencies and side effects in tests πŸ›
  • Create maintainable test suites for reusable hook libraries ✨

🎯 Introduction

Welcome to the custom hook testing laboratory! 🎣 If testing regular functions were like checking individual gears in a clockwork, then testing custom hooks would be like testing the entire timing mechanism - complete with state changes, side effects, lifecycle management, and complex interactions that need to work perfectly together to keep your React application running smoothly!

Custom hooks encapsulate complex logic, state management, and side effects in reusable packages. Testing them requires special techniques because hooks can only be called within React components, and they often involve async operations, external dependencies, and intricate state transitions that need careful verification.

By the end of this tutorial, you’ll be a master of custom hook testing, capable of thoroughly testing everything from simple state hooks to complex data fetching hooks with caching, error handling, and optimistic updates. You’ll learn to test hooks in isolation while ensuring they work correctly when integrated into real components. Let’s build some bulletproof hook tests! 🌟

πŸ“š Understanding Custom Hook Testing Fundamentals

πŸ€” Why Custom Hook Testing Is Special

Custom hooks require special testing approaches because they can only be called within React components and often manage complex state and side effects.

// 🌟 Setting up comprehensive custom hook testing environment

import React, { useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react';
import { renderHook, act, waitFor } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

// Basic types for our hook examples
interface User {
  id: string;
  name: string;
  email: string;
  avatar?: string;
}

interface ApiResponse<T> {
  data: T;
  status: number;
  error?: string;
}

interface UseCounterOptions {
  min?: number;
  max?: number;
  step?: number;
  initialValue?: number;
}

interface UseLocalStorageOptions {
  serialize?: (value: any) => string;
  deserialize?: (value: string) => any;
}

interface UseFetchOptions {
  immediate?: boolean;
  retries?: number;
  retryDelay?: number;
  cacheKey?: string;
}

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => Promise<void>;
  cancel: () => void;
}

// Simple counter hook for basic testing
const useCounter = (options: UseCounterOptions = {}) => {
  const {
    min = 0,
    max = Infinity,
    step = 1,
    initialValue = 0
  } = options;

  const [count, setCount] = useState(() => {
    // Ensure initial value is within bounds
    return Math.max(min, Math.min(max, initialValue));
  });

  const increment = useCallback(() => {
    setCount(prevCount => {
      const newCount = prevCount + step;
      return newCount <= max ? newCount : prevCount;
    });
  }, [step, max]);

  const decrement = useCallback(() => {
    setCount(prevCount => {
      const newCount = prevCount - step;
      return newCount >= min ? newCount : prevCount;
    });
  }, [step, min]);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  const set = useCallback((value: number) => {
    const clampedValue = Math.max(min, Math.min(max, value));
    setCount(clampedValue);
  }, [min, max]);

  return {
    count,
    increment,
    decrement,
    reset,
    set,
    isAtMin: count === min,
    isAtMax: count === max
  };
};

// Local storage hook with serialization
const useLocalStorage = <T>(
  key: string,
  defaultValue: T,
  options: UseLocalStorageOptions = {}
) => {
  const {
    serialize = JSON.stringify,
    deserialize = JSON.parse
  } = options;

  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? deserialize(item) : defaultValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return defaultValue;
    }
  });

  const setValue = useCallback((value: T | ((val: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, serialize(valueToStore));
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, serialize, storedValue]);

  const removeValue = useCallback(() => {
    try {
      window.localStorage.removeItem(key);
      setStoredValue(defaultValue);
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error);
    }
  }, [key, defaultValue]);

  return [storedValue, setValue, removeValue] as const;
};

// Toggle hook with multiple states
const useToggle = (initialValue: boolean = false) => {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => setValue(v => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return {
    value,
    toggle,
    setTrue,
    setFalse,
    setValue
  };
};

// Previous value hook for comparing state changes
const usePrevious = <T>(value: T): T | undefined => {
  const ref = useRef<T>();
  
  useEffect(() => {
    ref.current = value;
  });
  
  return ref.current;
};

// Debounced value hook
const useDebounce = <T>(value: T, delay: number): T => {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
};

// Timer hook with controls
const useTimer = (initialTime: number = 0, interval: number = 1000) => {
  const [time, setTime] = useState(initialTime);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef<NodeJS.Timeout | null>(null);

  const start = useCallback(() => {
    if (!isRunning) {
      setIsRunning(true);
    }
  }, [isRunning]);

  const pause = useCallback(() => {
    setIsRunning(false);
  }, []);

  const reset = useCallback(() => {
    setTime(initialTime);
    setIsRunning(false);
  }, [initialTime]);

  const restart = useCallback(() => {
    setTime(initialTime);
    setIsRunning(true);
  }, [initialValue]);

  useEffect(() => {
    if (isRunning) {
      intervalRef.current = setInterval(() => {
        setTime(prevTime => prevTime + 1);
      }, interval);
    } else {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
        intervalRef.current = null;
      }
    }

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [isRunning, interval]);

  return {
    time,
    isRunning,
    start,
    pause,
    reset,
    restart
  };
};

// Fetch hook with caching and error handling
const useFetch = <T>(
  url: string | null,
  options: UseFetchOptions = {}
): UseFetchResult<T> => {
  const {
    immediate = true,
    retries = 0,
    retryDelay = 1000,
    cacheKey
  } = options;

  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const abortControllerRef = useRef<AbortController | null>(null);
  const cache = useMemo(() => new Map<string, T>(), []);

  const fetchData = useCallback(async (): Promise<void> => {
    if (!url) return;

    // Check cache first
    if (cacheKey && cache.has(cacheKey)) {
      setData(cache.get(cacheKey) as T);
      return;
    }

    // Cancel previous request
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    abortControllerRef.current = new AbortController();
    setLoading(true);
    setError(null);

    let attempt = 0;
    const maxAttempts = retries + 1;

    while (attempt < maxAttempts) {
      try {
        const response = await fetch(url, {
          signal: abortControllerRef.current.signal
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const result = await response.json();
        setData(result);
        
        // Cache the result
        if (cacheKey) {
          cache.set(cacheKey, result);
        }
        
        setLoading(false);
        return;
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          return; // Request was aborted, don't retry
        }

        attempt++;
        if (attempt >= maxAttempts) {
          setError(err instanceof Error ? err.message : 'Unknown error');
          setLoading(false);
        } else {
          // Wait before retry
          await new Promise(resolve => setTimeout(resolve, retryDelay));
        }
      }
    }
  }, [url, retries, retryDelay, cacheKey, cache]);

  const cancel = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    if (immediate && url) {
      fetchData();
    }

    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [immediate, url, fetchData]);

  return {
    data,
    loading,
    error,
    refetch: fetchData,
    cancel
  };
};

// Form hook with validation
interface UseFormOptions<T> {
  initialValues: T;
  validate?: (values: T) => Partial<Record<keyof T, string>>;
  onSubmit?: (values: T) => Promise<void> | void;
}

const useForm = <T extends Record<string, any>>(options: UseFormOptions<T>) => {
  const { initialValues, validate, onSubmit } = options;
  
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const setValue = useCallback((field: keyof T, value: any) => {
    setValues(prev => ({ ...prev, [field]: value }));
    
    // Clear error when user starts typing
    if (errors[field]) {
      setErrors(prev => ({ ...prev, [field]: undefined }));
    }
  }, [errors]);

  const setFieldTouched = useCallback((field: keyof T, isTouched: boolean = true) => {
    setTouched(prev => ({ ...prev, [field]: isTouched }));
  }, []);

  const validateForm = useCallback(() => {
    if (!validate) return {};
    
    const validationErrors = validate(values);
    setErrors(validationErrors);
    return validationErrors;
  }, [validate, values]);

  const handleSubmit = useCallback(async (e?: React.FormEvent) => {
    if (e) {
      e.preventDefault();
    }

    const validationErrors = validateForm();
    const hasErrors = Object.keys(validationErrors).length > 0;

    if (hasErrors) {
      // Mark all fields as touched to show errors
      const allTouched = Object.keys(values).reduce((acc, key) => {
        acc[key as keyof T] = true;
        return acc;
      }, {} as Partial<Record<keyof T, boolean>>);
      setTouched(allTouched);
      return;
    }

    if (onSubmit) {
      setIsSubmitting(true);
      try {
        await onSubmit(values);
      } catch (error) {
        console.error('Form submission error:', error);
      } finally {
        setIsSubmitting(false);
      }
    }
  }, [validateForm, values, onSubmit]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);

  const isValid = useMemo(() => {
    if (!validate) return true;
    const validationErrors = validate(values);
    return Object.keys(validationErrors).length === 0;
  }, [validate, values]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    setValue,
    setFieldTouched,
    handleSubmit,
    reset,
    validateForm
  };
};

// Reducer hook for complex state management
interface TodoState {
  todos: Array<{ id: string; text: string; completed: boolean }>;
  filter: 'all' | 'active' | 'completed';
  loading: boolean;
  error: string | null;
}

type TodoAction = 
  | { type: 'ADD_TODO'; payload: { id: string; text: string } }
  | { type: 'TOGGLE_TODO'; payload: { id: string } }
  | { type: 'DELETE_TODO'; payload: { id: string } }
  | { type: 'SET_FILTER'; payload: { filter: TodoState['filter'] } }
  | { type: 'SET_LOADING'; payload: { loading: boolean } }
  | { type: 'SET_ERROR'; payload: { error: string | null } }
  | { type: 'CLEAR_COMPLETED' };

const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          { id: action.payload.id, text: action.payload.text, completed: false }
        ]
      };
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };
    
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload.id)
      };
    
    case 'SET_FILTER':
      return {
        ...state,
        filter: action.payload.filter
      };
    
    case 'SET_LOADING':
      return {
        ...state,
        loading: action.payload.loading
      };
    
    case 'SET_ERROR':
      return {
        ...state,
        error: action.payload.error
      };
    
    case 'CLEAR_COMPLETED':
      return {
        ...state,
        todos: state.todos.filter(todo => !todo.completed)
      };
    
    default:
      return state;
  }
};

const useTodos = () => {
  const [state, dispatch] = useReducer(todoReducer, {
    todos: [],
    filter: 'all',
    loading: false,
    error: null
  });

  const addTodo = useCallback((text: string) => {
    const id = Date.now().toString();
    dispatch({ type: 'ADD_TODO', payload: { id, text } });
  }, []);

  const toggleTodo = useCallback((id: string) => {
    dispatch({ type: 'TOGGLE_TODO', payload: { id } });
  }, []);

  const deleteTodo = useCallback((id: string) => {
    dispatch({ type: 'DELETE_TODO', payload: { id } });
  }, []);

  const setFilter = useCallback((filter: TodoState['filter']) => {
    dispatch({ type: 'SET_FILTER', payload: { filter } });
  }, []);

  const clearCompleted = useCallback(() => {
    dispatch({ type: 'CLEAR_COMPLETED' });
  }, []);

  const filteredTodos = useMemo(() => {
    switch (state.filter) {
      case 'active':
        return state.todos.filter(todo => !todo.completed);
      case 'completed':
        return state.todos.filter(todo => todo.completed);
      default:
        return state.todos;
    }
  }, [state.todos, state.filter]);

  const stats = useMemo(() => {
    const total = state.todos.length;
    const completed = state.todos.filter(t => t.completed).length;
    const active = total - completed;
    return { total, completed, active };
  }, [state.todos]);

  return {
    state,
    filteredTodos,
    stats,
    addTodo,
    toggleTodo,
    deleteTodo,
    setFilter,
    clearCompleted
  };
};

βœ… Testing Basic Hooks

πŸ§ͺ Testing Simple State Hooks

The foundation of hook testing is verifying state changes and function behavior in isolation.

// 🌟 Comprehensive basic hook testing

describe('useCounter Hook', () => {
  // βœ… Testing initial state
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter());

    expect(result.current.count).toBe(0);
    expect(result.current.isAtMin).toBe(true);
    expect(result.current.isAtMax).toBe(false);
  });

  it('should initialize with custom initial value', () => {
    const { result } = renderHook(() => useCounter({ initialValue: 5 }));

    expect(result.current.count).toBe(5);
    expect(result.current.isAtMin).toBe(false);
    expect(result.current.isAtMax).toBe(false);
  });

  it('should clamp initial value to bounds', () => {
    const { result } = renderHook(() => 
      useCounter({ initialValue: -10, min: 0, max: 100 })
    );

    expect(result.current.count).toBe(0);
    expect(result.current.isAtMin).toBe(true);
  });

  // βœ… Testing increment functionality
  it('should increment by step', () => {
    const { result } = renderHook(() => useCounter({ step: 2 }));

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(2);

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(4);
  });

  it('should not increment beyond max', () => {
    const { result } = renderHook(() => 
      useCounter({ initialValue: 8, max: 10, step: 3 })
    );

    act(() => {
      result.current.increment();
    });

    // Should not exceed max
    expect(result.current.count).toBe(8);
    expect(result.current.isAtMax).toBe(false);

    act(() => {
      result.current.increment();
    });

    // Still should not exceed max
    expect(result.current.count).toBe(8);
  });

  // βœ… Testing decrement functionality
  it('should decrement by step', () => {
    const { result } = renderHook(() => useCounter({ initialValue: 10, step: 3 }));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(7);

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });

  it('should not decrement below min', () => {
    const { result } = renderHook(() => 
      useCounter({ initialValue: 2, min: 0, step: 3 })
    );

    act(() => {
      result.current.decrement();
    });

    // Should not go below min
    expect(result.current.count).toBe(2);
    expect(result.current.isAtMin).toBe(false);
  });

  // βœ… Testing set functionality
  it('should set value directly', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.set(15);
    });

    expect(result.current.count).toBe(15);
  });

  it('should clamp set value to bounds', () => {
    const { result } = renderHook(() => useCounter({ min: 0, max: 100 }));

    act(() => {
      result.current.set(-50);
    });
    expect(result.current.count).toBe(0);

    act(() => {
      result.current.set(150);
    });
    expect(result.current.count).toBe(100);
  });

  // βœ… Testing reset functionality
  it('should reset to initial value', () => {
    const { result } = renderHook(() => useCounter({ initialValue: 5 }));

    act(() => {
      result.current.increment();
      result.current.increment();
    });
    expect(result.current.count).toBe(7);

    act(() => {
      result.current.reset();
    });
    expect(result.current.count).toBe(5);
  });

  // βœ… Testing boundary flags
  it('should correctly report boundary states', () => {
    const { result } = renderHook(() => 
      useCounter({ initialValue: 5, min: 0, max: 10 })
    );

    // Middle state
    expect(result.current.isAtMin).toBe(false);
    expect(result.current.isAtMax).toBe(false);

    // Set to min
    act(() => {
      result.current.set(0);
    });
    expect(result.current.isAtMin).toBe(true);
    expect(result.current.isAtMax).toBe(false);

    // Set to max
    act(() => {
      result.current.set(10);
    });
    expect(result.current.isAtMin).toBe(false);
    expect(result.current.isAtMax).toBe(true);
  });

  // βœ… Testing function reference stability
  it('should maintain stable function references', () => {
    const { result, rerender } = renderHook(() => useCounter({ step: 1 }));

    const firstRenderFunctions = {
      increment: result.current.increment,
      decrement: result.current.decrement,
      reset: result.current.reset,
      set: result.current.set
    };

    // Trigger rerender
    rerender();

    expect(result.current.increment).toBe(firstRenderFunctions.increment);
    expect(result.current.decrement).toBe(firstRenderFunctions.decrement);
    expect(result.current.reset).toBe(firstRenderFunctions.reset);
    expect(result.current.set).toBe(firstRenderFunctions.set);
  });
});

describe('useToggle Hook', () => {
  // βœ… Testing toggle functionality
  it('should initialize with default false value', () => {
    const { result } = renderHook(() => useToggle());

    expect(result.current.value).toBe(false);
  });

  it('should initialize with custom value', () => {
    const { result } = renderHook(() => useToggle(true));

    expect(result.current.value).toBe(true);
  });

  it('should toggle value', () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current.toggle();
    });
    expect(result.current.value).toBe(true);

    act(() => {
      result.current.toggle();
    });
    expect(result.current.value).toBe(false);
  });

  it('should set to true', () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current.setTrue();
    });
    expect(result.current.value).toBe(true);

    // Should remain true
    act(() => {
      result.current.setTrue();
    });
    expect(result.current.value).toBe(true);
  });

  it('should set to false', () => {
    const { result } = renderHook(() => useToggle(true));

    act(() => {
      result.current.setFalse();
    });
    expect(result.current.value).toBe(false);

    // Should remain false
    act(() => {
      result.current.setFalse();
    });
    expect(result.current.value).toBe(false);
  });

  it('should set arbitrary value', () => {
    const { result } = renderHook(() => useToggle());

    act(() => {
      result.current.setValue(true);
    });
    expect(result.current.value).toBe(true);

    act(() => {
      result.current.setValue(false);
    });
    expect(result.current.value).toBe(false);
  });
});

describe('usePrevious Hook', () => {
  it('should return undefined on first render', () => {
    const { result } = renderHook(() => usePrevious('initial'));

    expect(result.current).toBeUndefined();
  });

  it('should return previous value after updates', () => {
    let value = 'first';
    const { result, rerender } = renderHook(() => usePrevious(value));

    expect(result.current).toBeUndefined();

    // Update value and rerender
    value = 'second';
    rerender();

    expect(result.current).toBe('first');

    // Update again
    value = 'third';
    rerender();

    expect(result.current).toBe('second');
  });

  it('should work with complex objects', () => {
    let value = { count: 1, name: 'test' };
    const { result, rerender } = renderHook(() => usePrevious(value));

    expect(result.current).toBeUndefined();

    value = { count: 2, name: 'updated' };
    rerender();

    expect(result.current).toEqual({ count: 1, name: 'test' });
  });
});

⏰ Testing Hooks with Side Effects

πŸ”„ Testing useEffect and Timer Hooks

Hooks with side effects require special handling for timers, async operations, and cleanup.

// 🌟 Comprehensive side effect hook testing

describe('useDebounce Hook', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('should return initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('initial', 500));

    expect(result.current).toBe('initial');
  });

  it('should debounce value changes', () => {
    let value = 'first';
    const { result, rerender } = renderHook(() => useDebounce(value, 500));

    expect(result.current).toBe('first');

    // Change value
    value = 'second';
    rerender();

    // Should still show old value
    expect(result.current).toBe('first');

    // Fast-forward time partially
    act(() => {
      jest.advanceTimersByTime(300);
    });

    // Still old value
    expect(result.current).toBe('first');

    // Fast-forward past delay
    act(() => {
      jest.advanceTimersByTime(300);
    });

    // Now should show new value
    expect(result.current).toBe('second');
  });

  it('should reset timer on rapid changes', () => {
    let value = 'first';
    const { result, rerender } = renderHook(() => useDebounce(value, 500));

    // Change value multiple times quickly
    value = 'second';
    rerender();

    act(() => {
      jest.advanceTimersByTime(300);
    });

    value = 'third';
    rerender();

    act(() => {
      jest.advanceTimersByTime(300);
    });

    // Should still show original value
    expect(result.current).toBe('first');

    // Fast-forward remaining time
    act(() => {
      jest.advanceTimersByTime(300);
    });

    // Should show final value
    expect(result.current).toBe('third');
  });

  it('should cleanup timeout on unmount', () => {
    const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
    
    const { unmount } = renderHook(() => useDebounce('value', 500));

    unmount();

    expect(clearTimeoutSpy).toHaveBeenCalled();

    clearTimeoutSpy.mockRestore();
  });
});

describe('useTimer Hook', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('should initialize with default values', () => {
    const { result } = renderHook(() => useTimer());

    expect(result.current.time).toBe(0);
    expect(result.current.isRunning).toBe(false);
  });

  it('should initialize with custom initial time', () => {
    const { result } = renderHook(() => useTimer(10));

    expect(result.current.time).toBe(10);
    expect(result.current.isRunning).toBe(false);
  });

  it('should start and increment timer', () => {
    const { result } = renderHook(() => useTimer(0, 1000));

    act(() => {
      result.current.start();
    });

    expect(result.current.isRunning).toBe(true);

    // Advance time
    act(() => {
      jest.advanceTimersByTime(1000);
    });

    expect(result.current.time).toBe(1);

    act(() => {
      jest.advanceTimersByTime(2000);
    });

    expect(result.current.time).toBe(3);
  });

  it('should pause timer', () => {
    const { result } = renderHook(() => useTimer());

    act(() => {
      result.current.start();
    });

    act(() => {
      jest.advanceTimersByTime(2000);
    });

    expect(result.current.time).toBe(2);

    act(() => {
      result.current.pause();
    });

    expect(result.current.isRunning).toBe(false);

    act(() => {
      jest.advanceTimersByTime(3000);
    });

    // Time should not have changed
    expect(result.current.time).toBe(2);
  });

  it('should reset timer', () => {
    const { result } = renderHook(() => useTimer(5));

    act(() => {
      result.current.start();
    });

    act(() => {
      jest.advanceTimersByTime(3000);
    });

    expect(result.current.time).toBe(8);
    expect(result.current.isRunning).toBe(true);

    act(() => {
      result.current.reset();
    });

    expect(result.current.time).toBe(5);
    expect(result.current.isRunning).toBe(false);
  });

  it('should restart timer', () => {
    const { result } = renderHook(() => useTimer(10));

    act(() => {
      result.current.start();
    });

    act(() => {
      jest.advanceTimersByTime(3000);
    });

    expect(result.current.time).toBe(13);

    act(() => {
      result.current.restart();
    });

    expect(result.current.time).toBe(10);
    expect(result.current.isRunning).toBe(true);

    act(() => {
      jest.advanceTimersByTime(1000);
    });

    expect(result.current.time).toBe(11);
  });

  it('should use custom interval', () => {
    const { result } = renderHook(() => useTimer(0, 500));

    act(() => {
      result.current.start();
    });

    act(() => {
      jest.advanceTimersByTime(1000);
    });

    // Should have incremented twice (500ms interval)
    expect(result.current.time).toBe(2);
  });

  it('should cleanup interval on unmount', () => {
    const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
    
    const { result, unmount } = renderHook(() => useTimer());

    act(() => {
      result.current.start();
    });

    unmount();

    expect(clearIntervalSpy).toHaveBeenCalled();

    clearIntervalSpy.mockRestore();
  });
});

describe('useLocalStorage Hook', () => {
  beforeEach(() => {
    // Clear localStorage before each test
    localStorage.clear();
    
    // Mock console.warn to avoid cluttering test output
    jest.spyOn(console, 'warn').mockImplementation(() => {});
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('should initialize with default value', () => {
    const { result } = renderHook(() => 
      useLocalStorage('test-key', 'default-value')
    );

    expect(result.current[0]).toBe('default-value');
  });

  it('should read existing value from localStorage', () => {
    localStorage.setItem('existing-key', JSON.stringify('existing-value'));

    const { result } = renderHook(() => 
      useLocalStorage('existing-key', 'default-value')
    );

    expect(result.current[0]).toBe('existing-value');
  });

  it('should set value and update localStorage', () => {
    const { result } = renderHook(() => 
      useLocalStorage('new-key', 'initial')
    );

    act(() => {
      result.current[1]('updated-value');
    });

    expect(result.current[0]).toBe('updated-value');
    expect(localStorage.getItem('new-key')).toBe('"updated-value"');
  });

  it('should work with functional updates', () => {
    const { result } = renderHook(() => 
      useLocalStorage('counter-key', 0)
    );

    act(() => {
      result.current[1](prev => prev + 1);
    });

    expect(result.current[0]).toBe(1);

    act(() => {
      result.current[1](prev => prev * 2);
    });

    expect(result.current[0]).toBe(2);
  });

  it('should remove value and reset to default', () => {
    localStorage.setItem('remove-key', JSON.stringify('stored-value'));

    const { result } = renderHook(() => 
      useLocalStorage('remove-key', 'default')
    );

    expect(result.current[0]).toBe('stored-value');

    act(() => {
      result.current[2](); // Remove function
    });

    expect(result.current[0]).toBe('default');
    expect(localStorage.getItem('remove-key')).toBeNull();
  });

  it('should work with complex objects', () => {
    const complexObject = { id: 1, name: 'test', items: [1, 2, 3] };

    const { result } = renderHook(() => 
      useLocalStorage('object-key', complexObject)
    );

    expect(result.current[0]).toEqual(complexObject);

    const updatedObject = { ...complexObject, name: 'updated' };

    act(() => {
      result.current[1](updatedObject);
    });

    expect(result.current[0]).toEqual(updatedObject);
  });

  it('should handle localStorage errors gracefully', () => {
    // Mock localStorage to throw errors
    const originalSetItem = localStorage.setItem;
    localStorage.setItem = jest.fn(() => {
      throw new Error('Storage quota exceeded');
    });

    const { result } = renderHook(() => 
      useLocalStorage('error-key', 'default')
    );

    // Should not throw error
    act(() => {
      result.current[1]('new-value');
    });

    // Value should remain unchanged due to error
    expect(result.current[0]).toBe('default');
    expect(console.warn).toHaveBeenCalled();

    // Restore original method
    localStorage.setItem = originalSetItem;
  });

  it('should use custom serialization functions', () => {
    const customSerialize = (value: any) => `custom:${JSON.stringify(value)}`;
    const customDeserialize = (value: string) => JSON.parse(value.replace('custom:', ''));

    const { result } = renderHook(() => 
      useLocalStorage('custom-key', 'test', {
        serialize: customSerialize,
        deserialize: customDeserialize
      })
    );

    act(() => {
      result.current[1]('serialized-value');
    });

    expect(localStorage.getItem('custom-key')).toBe('custom:"serialized-value"');
    expect(result.current[0]).toBe('serialized-value');
  });
});

🌐 Testing Async Hooks

πŸ“‘ Testing Data Fetching and Async Operations

Async hooks require careful handling of loading states, error conditions, and cleanup.

// 🌟 Comprehensive async hook testing

describe('useFetch Hook', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.resetAllMocks();
  });

  it('should initialize with default state', () => {
    const { result } = renderHook(() => useFetch<any>(null));

    expect(result.current.data).toBeNull();
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBeNull();
  });

  it('should not fetch when url is null', () => {
    renderHook(() => useFetch<any>(null));

    expect(global.fetch).not.toHaveBeenCalled();
  });

  it('should fetch data successfully', async () => {
    const mockData = { id: 1, name: 'Test User' };
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    
    mockFetch.mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockData)
    } as Response);

    const { result } = renderHook(() => useFetch<typeof mockData>('/api/users/1'));

    // Should start loading
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBeNull();
    expect(result.current.error).toBeNull();

    // Wait for fetch to complete
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBeNull();
    expect(mockFetch).toHaveBeenCalledWith('/api/users/1', expect.any(Object));
  });

  it('should handle fetch errors', async () => {
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    
    mockFetch.mockResolvedValue({
      ok: false,
      status: 404
    } as Response);

    const { result } = renderHook(() => useFetch<any>('/api/users/999'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBeNull();
    expect(result.current.error).toBe('HTTP error! status: 404');
  });

  it('should handle network errors', async () => {
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    
    mockFetch.mockRejectedValue(new Error('Network error'));

    const { result } = renderHook(() => useFetch<any>('/api/users/1'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBeNull();
    expect(result.current.error).toBe('Network error');
  });

  it('should retry on failure', async () => {
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    
    // First two calls fail, third succeeds
    mockFetch
      .mockRejectedValueOnce(new Error('Network error'))
      .mockRejectedValueOnce(new Error('Network error'))
      .mockResolvedValueOnce({
        ok: true,
        json: () => Promise.resolve({ success: true })
      } as Response);

    const { result } = renderHook(() => 
      useFetch<any>('/api/users/1', { retries: 2, retryDelay: 100 })
    );

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    }, { timeout: 3000 });

    expect(result.current.data).toEqual({ success: true });
    expect(result.current.error).toBeNull();
    expect(mockFetch).toHaveBeenCalledTimes(3);
  });

  it('should not fetch immediately when immediate is false', () => {
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    
    const { result } = renderHook(() => 
      useFetch<any>('/api/users/1', { immediate: false })
    );

    expect(result.current.loading).toBe(false);
    expect(mockFetch).not.toHaveBeenCalled();
  });

  it('should refetch when refetch is called', async () => {
    const mockData = { id: 1, name: 'Test User' };
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    
    mockFetch.mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockData)
    } as Response);

    const { result } = renderHook(() => 
      useFetch<typeof mockData>('/api/users/1', { immediate: false })
    );

    expect(mockFetch).not.toHaveBeenCalled();

    // Manually trigger fetch
    act(() => {
      result.current.refetch();
    });

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(mockFetch).toHaveBeenCalledWith('/api/users/1', expect.any(Object));
  });

  it('should cancel requests when cancel is called', async () => {
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    
    mockFetch.mockImplementation(() => 
      new Promise((resolve, reject) => {
        // Simulate long request
        setTimeout(() => {
          const error = new Error('Request was aborted');
          error.name = 'AbortError';
          reject(error);
        }, 1000);
      })
    );

    const { result } = renderHook(() => useFetch<any>('/api/users/1'));

    expect(result.current.loading).toBe(true);

    // Cancel the request
    act(() => {
      result.current.cancel();
    });

    expect(result.current.loading).toBe(false);
  });

  it('should use cache when cacheKey is provided', async () => {
    const mockData = { id: 1, name: 'Cached User' };
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    
    mockFetch.mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockData)
    } as Response);

    // First hook should fetch
    const { result: result1 } = renderHook(() => 
      useFetch<typeof mockData>('/api/users/1', { cacheKey: 'user-1' })
    );

    await waitFor(() => {
      expect(result1.current.loading).toBe(false);
    });

    expect(result1.current.data).toEqual(mockData);
    expect(mockFetch).toHaveBeenCalledTimes(1);

    // Second hook with same cache key should use cache
    const { result: result2 } = renderHook(() => 
      useFetch<typeof mockData>('/api/users/1', { cacheKey: 'user-1' })
    );

    // Should immediately have data from cache
    expect(result2.current.data).toEqual(mockData);
    expect(result2.current.loading).toBe(false);
    expect(mockFetch).toHaveBeenCalledTimes(1); // Still only one call
  });

  it('should abort request on unmount', () => {
    const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
    const abortSpy = jest.fn();
    
    // Mock AbortController
    const mockAbortController = {
      abort: abortSpy,
      signal: {}
    };
    
    jest.spyOn(window, 'AbortController').mockImplementation(() => mockAbortController as any);

    mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves

    const { unmount } = renderHook(() => useFetch<any>('/api/users/1'));

    unmount();

    expect(abortSpy).toHaveBeenCalled();
  });
});

describe('useForm Hook', () => {
  interface FormData {
    name: string;
    email: string;
    age: number;
  }

  const initialValues: FormData = {
    name: '',
    email: '',
    age: 0
  };

  const mockValidate = (values: FormData) => {
    const errors: Partial<Record<keyof FormData, string>> = {};
    
    if (!values.name.trim()) {
      errors.name = 'Name is required';
    }
    
    if (!values.email.trim()) {
      errors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(values.email)) {
      errors.email = 'Email is invalid';
    }
    
    if (values.age < 18) {
      errors.age = 'Must be 18 or older';
    }
    
    return errors;
  };

  it('should initialize with initial values', () => {
    const { result } = renderHook(() => 
      useForm({ initialValues })
    );

    expect(result.current.values).toEqual(initialValues);
    expect(result.current.errors).toEqual({});
    expect(result.current.touched).toEqual({});
    expect(result.current.isSubmitting).toBe(false);
  });

  it('should update field values', () => {
    const { result } = renderHook(() => 
      useForm({ initialValues })
    );

    act(() => {
      result.current.setValue('name', 'John Doe');
    });

    expect(result.current.values.name).toBe('John Doe');
  });

  it('should clear errors when field value changes', () => {
    const { result } = renderHook(() => 
      useForm({ initialValues, validate: mockValidate })
    );

    // Trigger validation
    act(() => {
      result.current.validateForm();
    });

    expect(result.current.errors.name).toBe('Name is required');

    // Update field should clear error
    act(() => {
      result.current.setValue('name', 'John');
    });

    expect(result.current.errors.name).toBeUndefined();
  });

  it('should validate form and return errors', () => {
    const { result } = renderHook(() => 
      useForm({ initialValues, validate: mockValidate })
    );

    act(() => {
      const errors = result.current.validateForm();
      expect(errors).toEqual({
        name: 'Name is required',
        email: 'Email is required',
        age: 'Must be 18 or older'
      });
    });

    expect(result.current.errors).toEqual({
      name: 'Name is required',
      email: 'Email is required',
      age: 'Must be 18 or older'
    });
  });

  it('should handle form submission with valid data', async () => {
    const mockOnSubmit = jest.fn().mockResolvedValue(undefined);
    
    const { result } = renderHook(() => 
      useForm({ 
        initialValues: {
          name: 'John Doe',
          email: '[email protected]',
          age: 25
        },
        validate: mockValidate,
        onSubmit: mockOnSubmit
      })
    );

    await act(async () => {
      await result.current.handleSubmit();
    });

    expect(mockOnSubmit).toHaveBeenCalledWith({
      name: 'John Doe',
      email: '[email protected]',
      age: 25
    });

    expect(result.current.isSubmitting).toBe(false);
  });

  it('should prevent submission with invalid data', async () => {
    const mockOnSubmit = jest.fn();
    
    const { result } = renderHook(() => 
      useForm({ 
        initialValues,
        validate: mockValidate,
        onSubmit: mockOnSubmit
      })
    );

    await act(async () => {
      await result.current.handleSubmit();
    });

    expect(mockOnSubmit).not.toHaveBeenCalled();
    expect(result.current.errors).toEqual({
      name: 'Name is required',
      email: 'Email is required',
      age: 'Must be 18 or older'
    });

    // All fields should be marked as touched
    expect(result.current.touched).toEqual({
      name: true,
      email: true,
      age: true
    });
  });

  it('should reset form to initial state', () => {
    const { result } = renderHook(() => 
      useForm({ 
        initialValues,
        validate: mockValidate
      })
    );

    // Make changes
    act(() => {
      result.current.setValue('name', 'John');
      result.current.setValue('email', '[email protected]');
      result.current.setFieldTouched('name', true);
      result.current.validateForm();
    });

    expect(result.current.values.name).toBe('John');
    expect(result.current.touched.name).toBe(true);

    // Reset
    act(() => {
      result.current.reset();
    });

    expect(result.current.values).toEqual(initialValues);
    expect(result.current.errors).toEqual({});
    expect(result.current.touched).toEqual({});
    expect(result.current.isSubmitting).toBe(false);
  });

  it('should calculate isValid correctly', () => {
    const { result } = renderHook(() => 
      useForm({ 
        initialValues,
        validate: mockValidate
      })
    );

    // Initially invalid
    expect(result.current.isValid).toBe(false);

    // Set valid values
    act(() => {
      result.current.setValue('name', 'John Doe');
      result.current.setValue('email', '[email protected]');
      result.current.setValue('age', 25);
    });

    expect(result.current.isValid).toBe(true);
  });
});

πŸ”„ Testing Complex State Management Hooks

πŸ—ƒοΈ Testing useReducer and Complex State

Complex hooks with reducers and multiple state transitions require comprehensive testing strategies.

// 🌟 Comprehensive complex state hook testing

describe('useTodos Hook', () => {
  it('should initialize with empty state', () => {
    const { result } = renderHook(() => useTodos());

    expect(result.current.state.todos).toEqual([]);
    expect(result.current.state.filter).toBe('all');
    expect(result.current.state.loading).toBe(false);
    expect(result.current.state.error).toBeNull();
    expect(result.current.stats).toEqual({ total: 0, completed: 0, active: 0 });
  });

  it('should add todos', () => {
    const { result } = renderHook(() => useTodos());

    act(() => {
      result.current.addTodo('First todo');
    });

    expect(result.current.state.todos).toHaveLength(1);
    expect(result.current.state.todos[0]).toMatchObject({
      text: 'First todo',
      completed: false
    });
    expect(result.current.state.todos[0].id).toBeDefined();

    act(() => {
      result.current.addTodo('Second todo');
    });

    expect(result.current.state.todos).toHaveLength(2);
    expect(result.current.stats.total).toBe(2);
    expect(result.current.stats.active).toBe(2);
    expect(result.current.stats.completed).toBe(0);
  });

  it('should toggle todo completion', () => {
    const { result } = renderHook(() => useTodos());

    act(() => {
      result.current.addTodo('Test todo');
    });

    const todoId = result.current.state.todos[0].id;

    expect(result.current.state.todos[0].completed).toBe(false);

    act(() => {
      result.current.toggleTodo(todoId);
    });

    expect(result.current.state.todos[0].completed).toBe(true);
    expect(result.current.stats.completed).toBe(1);
    expect(result.current.stats.active).toBe(0);

    act(() => {
      result.current.toggleTodo(todoId);
    });

    expect(result.current.state.todos[0].completed).toBe(false);
    expect(result.current.stats.completed).toBe(0);
    expect(result.current.stats.active).toBe(1);
  });

  it('should delete todos', () => {
    const { result } = renderHook(() => useTodos());

    act(() => {
      result.current.addTodo('Todo to delete');
      result.current.addTodo('Todo to keep');
    });

    const deleteId = result.current.state.todos[0].id;

    expect(result.current.state.todos).toHaveLength(2);

    act(() => {
      result.current.deleteTodo(deleteId);
    });

    expect(result.current.state.todos).toHaveLength(1);
    expect(result.current.state.todos[0].text).toBe('Todo to keep');
  });

  it('should filter todos correctly', () => {
    const { result } = renderHook(() => useTodos());

    // Add multiple todos
    act(() => {
      result.current.addTodo('Active todo 1');
      result.current.addTodo('Active todo 2');
      result.current.addTodo('Todo to complete');
    });

    // Complete one todo
    const todoToComplete = result.current.state.todos[2].id;
    act(() => {
      result.current.toggleTodo(todoToComplete);
    });

    // Test 'all' filter (default)
    expect(result.current.filteredTodos).toHaveLength(3);

    // Test 'active' filter
    act(() => {
      result.current.setFilter('active');
    });

    expect(result.current.filteredTodos).toHaveLength(2);
    expect(result.current.filteredTodos.every(todo => !todo.completed)).toBe(true);

    // Test 'completed' filter
    act(() => {
      result.current.setFilter('completed');
    });

    expect(result.current.filteredTodos).toHaveLength(1);
    expect(result.current.filteredTodos[0].completed).toBe(true);

    // Back to 'all'
    act(() => {
      result.current.setFilter('all');
    });

    expect(result.current.filteredTodos).toHaveLength(3);
  });

  it('should clear completed todos', () => {
    const { result } = renderHook(() => useTodos());

    // Add and complete some todos
    act(() => {
      result.current.addTodo('Keep this');
      result.current.addTodo('Complete this');
      result.current.addTodo('And this');
    });

    const [keep, complete1, complete2] = result.current.state.todos;

    act(() => {
      result.current.toggleTodo(complete1.id);
      result.current.toggleTodo(complete2.id);
    });

    expect(result.current.stats.completed).toBe(2);
    expect(result.current.stats.active).toBe(1);

    act(() => {
      result.current.clearCompleted();
    });

    expect(result.current.state.todos).toHaveLength(1);
    expect(result.current.state.todos[0].text).toBe('Keep this');
    expect(result.current.stats.completed).toBe(0);
    expect(result.current.stats.active).toBe(1);
  });

  it('should calculate stats correctly', () => {
    const { result } = renderHook(() => useTodos());

    // Add todos
    act(() => {
      result.current.addTodo('Todo 1');
      result.current.addTodo('Todo 2');
      result.current.addTodo('Todo 3');
      result.current.addTodo('Todo 4');
    });

    expect(result.current.stats).toEqual({
      total: 4,
      active: 4,
      completed: 0
    });

    // Complete some todos
    const [todo1, todo2] = result.current.state.todos;

    act(() => {
      result.current.toggleTodo(todo1.id);
      result.current.toggleTodo(todo2.id);
    });

    expect(result.current.stats).toEqual({
      total: 4,
      active: 2,
      completed: 2
    });
  });

  it('should handle invalid operations gracefully', () => {
    const { result } = renderHook(() => useTodos());

    // Try to toggle non-existent todo
    act(() => {
      result.current.toggleTodo('non-existent-id');
    });

    // Should not crash or change state
    expect(result.current.state.todos).toEqual([]);

    // Try to delete non-existent todo
    act(() => {
      result.current.deleteTodo('non-existent-id');
    });

    expect(result.current.state.todos).toEqual([]);
  });
});

// Testing hook composition and integration
describe('Hook Integration Testing', () => {
  // Component that uses multiple hooks together
  const TodoApp: React.FC = () => {
    const todos = useTodos();
    const [newTodoText, setNewTodoText] = useLocalStorage('newTodoText', '');
    const debouncedText = useDebounce(newTodoText, 300);
    const [showCompleted, toggle] = useToggle(true);

    const addTodo = () => {
      if (debouncedText.trim()) {
        todos.addTodo(debouncedText.trim());
        setNewTodoText('');
      }
    };

    const visibleTodos = showCompleted 
      ? todos.filteredTodos 
      : todos.filteredTodos.filter(t => !t.completed);

    return (
      <div>
        <input
          value={newTodoText}
          onChange={(e) => setNewTodoText(e.target.value)}
          data-testid="todo-input"
        />
        <button onClick={addTodo} data-testid="add-todo">
          Add Todo
        </button>
        <button onClick={toggle.toggle} data-testid="toggle-completed">
          {showCompleted ? 'Hide' : 'Show'} Completed
        </button>
        
        <div data-testid="stats">
          Total: {todos.stats.total}, Active: {todos.stats.active}
        </div>
        
        <div data-testid="todo-list">
          {visibleTodos.map(todo => (
            <div key={todo.id} data-testid={`todo-${todo.id}`}>
              <span>{todo.text}</span>
              <button 
                onClick={() => todos.toggleTodo(todo.id)}
                data-testid={`toggle-${todo.id}`}
              >
                {todo.completed ? 'Undo' : 'Complete'}
              </button>
            </div>
          ))}
        </div>
      </div>
    );
  };

  beforeEach(() => {
    localStorage.clear();
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('should integrate multiple hooks correctly', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    render(<TodoApp />);

    const input = screen.getByTestId('todo-input');
    const addButton = screen.getByTestId('add-todo');
    const stats = screen.getByTestId('stats');

    // Type in input (should be debounced)
    await user.type(input, 'Test todo');

    expect(input).toHaveValue('Test todo');

    // Should not add immediately due to debounce
    await user.click(addButton);
    expect(stats).toHaveTextContent('Total: 0');

    // Wait for debounce
    act(() => {
      jest.advanceTimersByTime(300);
    });

    // Now should add todo
    await user.click(addButton);
    expect(stats).toHaveTextContent('Total: 1, Active: 1');

    // Input should be cleared
    expect(input).toHaveValue('');

    // Todo should be visible
    expect(screen.getByText('Test todo')).toBeInTheDocument();
  });

  it('should persist input text to localStorage', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    render(<TodoApp />);

    const input = screen.getByTestId('todo-input');

    await user.type(input, 'Persistent text');

    expect(localStorage.getItem('newTodoText')).toBe('"Persistent text"');
  });

  it('should toggle completed todo visibility', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    render(<TodoApp />);

    const input = screen.getByTestId('todo-input');
    const addButton = screen.getByTestId('add-todo');
    const toggleButton = screen.getByTestId('toggle-completed');

    // Add a todo
    await user.type(input, 'Test todo');
    act(() => {
      jest.advanceTimersByTime(300);
    });
    await user.click(addButton);

    const todoElement = screen.getByTestId(/^todo-/);
    const todoToggleButton = screen.getByTestId(/^toggle-/);

    // Complete the todo
    await user.click(todoToggleButton);

    // Todo should still be visible (showCompleted is true by default)
    expect(todoElement).toBeInTheDocument();

    // Hide completed todos
    await user.click(toggleButton);

    // Todo should be hidden
    expect(screen.queryByTestId(/^todo-/)).not.toBeInTheDocument();

    // Show completed todos again
    await user.click(toggleButton);

    // Todo should be visible again
    expect(screen.getByTestId(/^todo-/)).toBeInTheDocument();
  });
});

🎯 Best Practices and Advanced Patterns

πŸ† Production-Ready Hook Testing

Here are advanced patterns for comprehensive hook testing in production applications.

// 🌟 Advanced hook testing patterns and best practices

describe('Advanced Hook Testing Patterns', () => {
  // βœ… Testing hook with dependencies
  describe('Hook with Dependencies', () => {
    interface UseApiOptions {
      baseUrl?: string;
      headers?: Record<string, string>;
      timeout?: number;
    }

    const useApi = (options: UseApiOptions = {}) => {
      const { baseUrl = '/api', headers = {}, timeout = 5000 } = options;

      const [loading, setLoading] = useState(false);
      const [error, setError] = useState<string | null>(null);

      const request = useCallback(async <T>(
        endpoint: string,
        config: RequestInit = {}
      ): Promise<T> => {
        setLoading(true);
        setError(null);

        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), timeout);

        try {
          const response = await fetch(`${baseUrl}${endpoint}`, {
            ...config,
            headers: { ...headers, ...config.headers },
            signal: controller.signal
          });

          clearTimeout(timeoutId);

          if (!response.ok) {
            throw new Error(`HTTP ${response.status}: ${response.statusText}`);
          }

          const data = await response.json();
          return data;
        } catch (err) {
          if (err instanceof Error && err.name === 'AbortError') {
            throw new Error('Request timeout');
          }
          throw err;
        } finally {
          setLoading(false);
        }
      }, [baseUrl, headers, timeout]);

      return { request, loading, error };
    };

    beforeEach(() => {
      global.fetch = jest.fn();
    });

    afterEach(() => {
      jest.resetAllMocks();
    });

    it('should make requests with custom configuration', async () => {
      const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
      mockFetch.mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ success: true })
      } as Response);

      const { result } = renderHook(() => 
        useApi({
          baseUrl: 'https://api.example.com',
          headers: { 'Authorization': 'Bearer token' },
          timeout: 10000
        })
      );

      await act(async () => {
        await result.current.request('/users');
      });

      expect(mockFetch).toHaveBeenCalledWith(
        'https://api.example.com/users',
        expect.objectContaining({
          headers: expect.objectContaining({
            'Authorization': 'Bearer token'
          })
        })
      );
    });

    it('should handle timeout', async () => {
      const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
      
      // Mock a long-running request
      mockFetch.mockImplementation(() => 
        new Promise((resolve) => {
          setTimeout(resolve, 2000);
        })
      );

      const { result } = renderHook(() => useApi({ timeout: 100 }));

      await act(async () => {
        try {
          await result.current.request('/slow-endpoint');
        } catch (error) {
          expect(error).toBeInstanceOf(Error);
          expect((error as Error).message).toBe('Request timeout');
        }
      });
    });
  });

  // βœ… Testing hooks with context dependencies
  describe('Hook with Context Dependencies', () => {
    interface AuthContextType {
      user: { id: string; name: string } | null;
      login: (user: { id: string; name: string }) => void;
      logout: () => void;
    }

    const AuthContext = React.createContext<AuthContextType | undefined>(undefined);

    const useAuth = () => {
      const context = useContext(AuthContext);
      if (!context) {
        throw new Error('useAuth must be used within AuthProvider');
      }
      return context;
    };

    const useProtectedAction = () => {
      const { user } = useAuth();
      const [actionCount, setActionCount] = useState(0);

      const performAction = useCallback(() => {
        if (!user) {
          throw new Error('User must be logged in');
        }
        setActionCount(prev => prev + 1);
      }, [user]);

      return { actionCount, performAction, canPerform: !!user };
    };

    const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
      const [user, setUser] = useState<{ id: string; name: string } | null>(null);

      const login = useCallback((userData: { id: string; name: string }) => {
        setUser(userData);
      }, []);

      const logout = useCallback(() => {
        setUser(null);
      }, []);

      return (
        <AuthContext.Provider value={{ user, login, logout }}>
          {children}
        </AuthContext.Provider>
      );
    };

    it('should throw error when used outside provider', () => {
      const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

      expect(() => {
        renderHook(() => useProtectedAction());
      }).toThrow('useAuth must be used within AuthProvider');

      consoleSpy.mockRestore();
    });

    it('should work correctly when user is logged in', () => {
      const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
        <AuthProvider>{children}</AuthProvider>
      );

      const { result } = renderHook(() => {
        const auth = useAuth();
        const protectedAction = useProtectedAction();
        return { auth, protectedAction };
      }, { wrapper });

      // Initially no user
      expect(result.current.protectedAction.canPerform).toBe(false);

      // Login user
      act(() => {
        result.current.auth.login({ id: '1', name: 'Test User' });
      });

      expect(result.current.protectedAction.canPerform).toBe(true);

      // Perform action
      act(() => {
        result.current.protectedAction.performAction();
      });

      expect(result.current.protectedAction.actionCount).toBe(1);
    });

    it('should prevent action when user logs out', () => {
      const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
        <AuthProvider>{children}</AuthProvider>
      );

      const { result } = renderHook(() => {
        const auth = useAuth();
        const protectedAction = useProtectedAction();
        return { auth, protectedAction };
      }, { wrapper });

      // Login and perform action
      act(() => {
        result.current.auth.login({ id: '1', name: 'Test User' });
        result.current.protectedAction.performAction();
      });

      expect(result.current.protectedAction.actionCount).toBe(1);

      // Logout
      act(() => {
        result.current.auth.logout();
      });

      expect(result.current.protectedAction.canPerform).toBe(false);

      // Should throw error when trying to perform action
      expect(() => {
        result.current.protectedAction.performAction();
      }).toThrow('User must be logged in');
    });
  });

  // βœ… Testing performance optimizations
  describe('Performance Optimization Testing', () => {
    const useOptimizedCounter = (step: number = 1) => {
      const [count, setCount] = useState(0);
      const [renderCount, setRenderCount] = useState(0);

      // Track renders
      useEffect(() => {
        setRenderCount(prev => prev + 1);
      });

      // Memoized functions to prevent unnecessary re-renders
      const increment = useCallback(() => {
        setCount(prev => prev + step);
      }, [step]);

      const decrement = useCallback(() => {
        setCount(prev => prev - step);
      }, [step]);

      const reset = useCallback(() => {
        setCount(0);
      }, []);

      // Memoized computed values
      const doubleCount = useMemo(() => count * 2, [count]);
      const isEven = useMemo(() => count % 2 === 0, [count]);

      return {
        count,
        doubleCount,
        isEven,
        renderCount,
        increment,
        decrement,
        reset
      };
    };

    it('should minimize re-renders with stable functions', () => {
      const { result, rerender } = renderHook(({ step }) => 
        useOptimizedCounter(step), 
        { initialProps: { step: 1 } }
      );

      const firstRender = {
        increment: result.current.increment,
        decrement: result.current.decrement,
        reset: result.current.reset
      };

      // Re-render with same props
      rerender({ step: 1 });

      // Functions should be the same (referentially equal)
      expect(result.current.increment).toBe(firstRender.increment);
      expect(result.current.decrement).toBe(firstRender.decrement);
      expect(result.current.reset).toBe(firstRender.reset);
    });

    it('should update functions when dependencies change', () => {
      const { result, rerender } = renderHook(({ step }) => 
        useOptimizedCounter(step), 
        { initialProps: { step: 1 } }
      );

      const firstIncrement = result.current.increment;

      // Re-render with different step
      rerender({ step: 2 });

      // Increment function should be different
      expect(result.current.increment).not.toBe(firstIncrement);

      // But reset should still be the same (no dependencies)
      const resetAfterRerender = result.current.reset;
      rerender({ step: 3 });
      expect(result.current.reset).toBe(resetAfterRerender);
    });

    it('should memoize computed values correctly', () => {
      const { result } = renderHook(() => useOptimizedCounter());

      expect(result.current.doubleCount).toBe(0);
      expect(result.current.isEven).toBe(true);

      act(() => {
        result.current.increment();
      });

      expect(result.current.doubleCount).toBe(2);
      expect(result.current.isEven).toBe(false);

      act(() => {
        result.current.increment();
      });

      expect(result.current.doubleCount).toBe(4);
      expect(result.current.isEven).toBe(true);
    });
  });

  // βœ… Testing error boundaries with hooks
  describe('Error Handling in Hooks', () => {
    const useErrorHandler = () => {
      const [error, setError] = useState<Error | null>(null);

      const throwError = useCallback((message: string) => {
        const error = new Error(message);
        setError(error);
        throw error;
      }, []);

      const clearError = useCallback(() => {
        setError(null);
      }, []);

      const handleAsyncError = useCallback(async (shouldFail: boolean) => {
        try {
          if (shouldFail) {
            throw new Error('Async operation failed');
          }
          return 'Success';
        } catch (error) {
          setError(error as Error);
          throw error;
        }
      }, []);

      return { error, throwError, clearError, handleAsyncError };
    };

    it('should capture and store errors', () => {
      const { result } = renderHook(() => useErrorHandler());

      expect(result.current.error).toBeNull();

      expect(() => {
        result.current.throwError('Test error');
      }).toThrow('Test error');

      expect(result.current.error).toBeInstanceOf(Error);
      expect(result.current.error?.message).toBe('Test error');
    });

    it('should clear errors', () => {
      const { result } = renderHook(() => useErrorHandler());

      // Throw error
      expect(() => {
        result.current.throwError('Test error');
      }).toThrow();

      expect(result.current.error).not.toBeNull();

      // Clear error
      act(() => {
        result.current.clearError();
      });

      expect(result.current.error).toBeNull();
    });

    it('should handle async errors', async () => {
      const { result } = renderHook(() => useErrorHandler());

      // Test successful async operation
      await act(async () => {
        const success = await result.current.handleAsyncError(false);
        expect(success).toBe('Success');
      });

      expect(result.current.error).toBeNull();

      // Test failed async operation
      await act(async () => {
        try {
          await result.current.handleAsyncError(true);
        } catch (error) {
          expect(error).toBeInstanceOf(Error);
        }
      });

      expect(result.current.error).toBeInstanceOf(Error);
      expect(result.current.error?.message).toBe('Async operation failed');
    });
  });
});

πŸŽ‰ Conclusion

Congratulations! You’ve mastered the art of testing custom React hooks in TypeScript! 🎯

πŸ”‘ Key Takeaways

  1. renderHook API: Use @testing-library/react for isolated hook testing
  2. Act Wrapping: Wrap state updates in act() for proper testing
  3. Async Testing: Handle async hooks with waitFor and proper cleanup
  4. Mock Management: Mock external dependencies and APIs effectively
  5. Context Testing: Test hooks with context dependencies using wrapper components
  6. Performance Testing: Verify function reference stability and memoization
  7. Error Handling: Test error states and recovery mechanisms
  8. Integration Testing: Test hooks within real components for complete coverage

πŸš€ Next Steps

  • Testing Redux: Learn Redux store and action testing patterns
  • API Testing: Master API mocking with Mock Service Worker (MSW)
  • Integration Testing: Build comprehensive integration test suites
  • E2E Testing: Test complete user workflows with real hooks
  • Performance Testing: Benchmark hook performance and optimization

You now have the skills to test any custom React hook with confidence, ensuring your reusable logic is reliable, performant, and maintainable! 🌟