React.js Fundamentals

Testing Advanced Patterns

18 min Lesson 27 of 40

Advanced Testing Patterns in React

In this lesson, we'll explore advanced testing techniques including mocking API calls, testing custom hooks, integration testing, snapshot testing, and measuring code coverage. These patterns help you build comprehensive test suites for complex applications.

Mocking API Calls

When testing components that make API calls, you should mock the API to avoid actual network requests:

Using Jest Mock Functions:
// todoService.js
export const fetchTodos = async () => {
  const response = await fetch('/api/todos');
  return response.json();
};

export const createTodo = async (todo) => {
  const response = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(todo)
  });
  return response.json();
};
Mocking the Service:
// TodoList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';
import * as todoService from './todoService';

// Mock the entire module
jest.mock('./todoService');

describe('TodoList Component', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('loads and displays todos', async () => {
    const mockTodos = [
      { id: 1, title: 'Buy groceries', completed: false },
      { id: 2, title: 'Walk the dog', completed: true }
    ];

    todoService.fetchTodos.mockResolvedValue(mockTodos);

    render(<TodoList />);

    await waitFor(() => {
      expect(screen.getByText('Buy groceries')).toBeInTheDocument();
      expect(screen.getByText('Walk the dog')).toBeInTheDocument();
    });

    expect(todoService.fetchTodos).toHaveBeenCalledTimes(1);
  });

  test('creates a new todo', async () => {
    const user = userEvent.setup();
    const newTodo = { id: 3, title: 'New task', completed: false };

    todoService.fetchTodos.mockResolvedValue([]);
    todoService.createTodo.mockResolvedValue(newTodo);

    render(<TodoList />);

    const input = screen.getByPlaceholderText(/add todo/i);
    const addButton = screen.getByRole('button', { name: /add/i });

    await user.type(input, 'New task');
    await user.click(addButton);

    await waitFor(() => {
      expect(screen.getByText('New task')).toBeInTheDocument();
    });

    expect(todoService.createTodo).toHaveBeenCalledWith({
      title: 'New task',
      completed: false
    });
  });

  test('handles API errors gracefully', async () => {
    todoService.fetchTodos.mockRejectedValue(
      new Error('Failed to fetch todos')
    );

    render(<TodoList />);

    const errorMessage = await screen.findByText(/failed to fetch/i);
    expect(errorMessage).toBeInTheDocument();
  });
});
Mocking Best Practice: Always clear mocks between tests using jest.clearAllMocks() or jest.resetAllMocks() in beforeEach to avoid test pollution.

Mocking Axios or Fetch

You can mock HTTP libraries at different levels:

Manual Mock with MSW (Mock Service Worker):
// src/mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/todos', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { id: 1, title: 'Test todo', completed: false }
      ])
    );
  }),

  rest.post('/api/todos', async (req, res, ctx) => {
    const todo = await req.json();
    return res(
      ctx.status(201),
      ctx.json({ id: Date.now(), ...todo })
    );
  }),

  rest.delete('/api/todos/:id', (req, res, ctx) => {
    return res(ctx.status(204));
  })
];

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

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

// setupTests.js
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
MSW Benefits: Mock Service Worker intercepts network requests at the network level, making your tests more realistic and working seamlessly with any HTTP client (fetch, axios, etc.).

Testing Custom Hooks

Custom hooks require special testing utilities:

Custom Hook (useCounter.js):
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  const decrement = useCallback(() => {
    setCount(c => c - 1);
  }, []);

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

  return { count, increment, decrement, reset };
}
Testing with renderHook:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter Hook', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  test('increments count', () => {
    const { result } = renderHook(() => useCounter());

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

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

  test('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));

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

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

  test('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10));

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

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

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

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

  test('handles multiple operations', () => {
    const { result } = renderHook(() => useCounter());

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

    expect(result.current.count).toBe(1);
  });
});
Common Mistake: Always wrap hook updates in act() to ensure React processes state updates properly. Forgetting this leads to warnings and unreliable tests.

Testing Hooks with Dependencies

Testing hooks that depend on context or props:

Hook with Context (useAuth.js):
import { useContext } from 'react';
import { AuthContext } from './AuthContext';

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}
Testing with Wrapper:
import { renderHook } from '@testing-library/react';
import { useAuth } from './useAuth';
import { AuthProvider } from './AuthContext';

describe('useAuth Hook', () => {
  test('returns auth context value', () => {
    const mockUser = { id: 1, name: 'John Doe' };

    const wrapper = ({ children }) => (
      <AuthProvider value={{ user: mockUser, isAuthenticated: true }}>
        {children}
      </AuthProvider>
    );

    const { result } = renderHook(() => useAuth(), { wrapper });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.isAuthenticated).toBe(true);
  });

  test('throws error when used outside provider', () => {
    // Suppress console.error for this test
    const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

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

    spy.mockRestore();
  });
});

Integration Testing

Integration tests verify multiple components working together:

Integration Test Example:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
import { server } from './mocks/server';
import { rest } from 'msw';

describe('Todo App Integration', () => {
  test('complete todo workflow', async () => {
    const user = userEvent.setup();
    render(<App />);

    // Wait for initial todos to load
    await waitFor(() => {
      expect(screen.getByText('Existing Todo')).toBeInTheDocument();
    });

    // Add a new todo
    const input = screen.getByPlaceholderText(/new todo/i);
    await user.type(input, 'Buy milk');
    await user.keyboard('{Enter}');

    await waitFor(() => {
      expect(screen.getByText('Buy milk')).toBeInTheDocument();
    });

    // Mark todo as complete
    const checkbox = screen.getByRole('checkbox', { name: /buy milk/i });
    await user.click(checkbox);

    await waitFor(() => {
      expect(checkbox).toBeChecked();
    });

    // Delete todo
    const deleteButton = screen.getByRole('button', { name: /delete buy milk/i });
    await user.click(deleteButton);

    await waitFor(() => {
      expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
    });
  });

  test('handles network errors', async () => {
    // Override handler to simulate error
    server.use(
      rest.get('/api/todos', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ error: 'Server error' }));
      })
    );

    render(<App />);

    const errorMessage = await screen.findByText(/failed to load todos/i);
    expect(errorMessage).toBeInTheDocument();
  });

  test('filters todos by status', async () => {
    const user = userEvent.setup();
    render(<App />);

    await waitFor(() => {
      expect(screen.getByText('Active Todo')).toBeInTheDocument();
      expect(screen.getByText('Completed Todo')).toBeInTheDocument();
    });

    // Click "Active" filter
    const activeFilter = screen.getByRole('button', { name: /active/i });
    await user.click(activeFilter);

    expect(screen.getByText('Active Todo')).toBeInTheDocument();
    expect(screen.queryByText('Completed Todo')).not.toBeInTheDocument();

    // Click "Completed" filter
    const completedFilter = screen.getByRole('button', { name: /completed/i });
    await user.click(completedFilter);

    expect(screen.queryByText('Active Todo')).not.toBeInTheDocument();
    expect(screen.getByText('Completed Todo')).toBeInTheDocument();
  });
});
Integration Testing Scope: Integration tests should cover complete user journeys through your application, testing how multiple components and features work together.

Snapshot Testing

Snapshot tests capture component output and detect unexpected changes:

Basic Snapshot Test:
import { render } from '@testing-library/react';
import Card from './Card';

describe('Card Component Snapshots', () => {
  test('matches snapshot with basic props', () => {
    const { container } = render(
      <Card title="Test Card" content="This is test content" />
    );

    expect(container.firstChild).toMatchSnapshot();
  });

  test('matches snapshot with image', () => {
    const { container } = render(
      <Card
        title="Image Card"
        content="Card with image"
        image="https://example.com/image.jpg"
      />
    );

    expect(container.firstChild).toMatchSnapshot();
  });

  test('matches snapshot in loading state', () => {
    const { container } = render(
      <Card title="Loading" isLoading={true} />
    );

    expect(container.firstChild).toMatchSnapshot();
  });
});
Inline Snapshots:
test('renders button correctly', () => {
  const { container } = render(<Button variant="primary">Click Me</Button>);

  expect(container.firstChild).toMatchInlineSnapshot(`
    <button
      class="btn btn-primary"
      type="button"
    >
      Click Me
    </button>
  `);
});
Snapshot Limitations: Snapshots are fragile and can create false positives. Use them sparingly for stable components. Don't rely solely on snapshots - combine them with behavioral tests.

Code Coverage

Measuring test coverage helps identify untested code:

Running Coverage:
# Generate coverage report
npm test -- --coverage

# Generate coverage with specific thresholds
npm test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'

# Watch mode with coverage
npm test -- --coverage --watchAll=false
Coverage Configuration (package.json):
{
  "jest": {
    "collectCoverageFrom": [
      "src/**/*.{js,jsx}",
      "!src/index.js",
      "!src/reportWebVitals.js",
      "!src/**/*.test.{js,jsx}",
      "!src/mocks/**"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coverageReporters": [
      "text",
      "lcov",
      "html"
    ]
  }
}
Coverage Metrics: Coverage reports show four metrics: Statements (lines executed), Branches (if/else paths), Functions (functions called), and Lines (source lines executed).

Exercise 1: Mock API Service

Create a user profile component that fetches data from an API. Write tests with mocked API calls for:

  • Loading state while fetching
  • Successful data display
  • Error handling with retry button
  • Updating profile data
  • Verify API called with correct parameters

Exercise 2: Test Custom Hook

Create a useLocalStorage hook that syncs state with localStorage. Test:

  • Initial value from localStorage
  • Updating value updates localStorage
  • Handling invalid JSON in localStorage
  • Clearing storage
  • Multiple hook instances sync correctly

Exercise 3: Integration Test

Build a shopping cart application with product list, cart, and checkout. Write integration tests for:

  • Adding products to cart
  • Updating quantities
  • Removing items
  • Calculating totals correctly
  • Completing checkout flow
  • Handling out-of-stock scenarios

Test Organization Best Practices

Structure your tests for maintainability:

Test File Organization:
src/
├── components/
│   ├── Button/
│   │   ├── Button.jsx
│   │   ├── Button.test.jsx
│   │   └── Button.module.css
│   └── Card/
│       ├── Card.jsx
│       ├── Card.test.jsx
│       └── __snapshots__/
│           └── Card.test.jsx.snap
├── hooks/
│   ├── useCounter.js
│   └── useCounter.test.js
├── utils/
│   ├── formatters.js
│   └── formatters.test.js
├── mocks/
│   ├── handlers.js
│   └── server.js
└── __tests__/
    └── integration/
        └── TodoApp.test.jsx
Test Helpers and Utilities:
// testUtils.js
import { render } from '@testing-library/react';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';

export function renderWithProviders(
  ui,
  {
    authValue = { user: null, isAuthenticated: false },
    theme = 'light',
    ...renderOptions
  } = {}
) {
  function Wrapper({ children }) {
    return (
      <AuthProvider value={authValue}>
        <ThemeProvider theme={theme}>
          {children}
        </ThemeProvider>
      </AuthProvider>
    );
  }

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

// Usage in tests
import { renderWithProviders } from './testUtils';

test('renders with auth context', () => {
  renderWithProviders(<MyComponent />, {
    authValue: { user: mockUser, isAuthenticated: true }
  });
  // Test assertions...
});
DRY Tests: Create custom render functions and test utilities to reduce boilerplate and make tests more maintainable. But don't over-abstract - tests should remain readable.