Front-end Engineering Lab
PatternsTesting Strategies

Component Testing Strategies

Test React components effectively with Testing Library best practices

Component Testing Strategies

Component tests verify that your UI components work correctly in isolation. Testing Library helps you test components the way users interact with them, not how they're implemented.

Testing Library Philosophy

The more your tests resemble the way your software is used, the more confidence they can give you. - Kent C. Dodds

// ❌ BAD: Testing implementation
test('uses useState', () => {
  const wrapper = shallow(<Counter />);
  expect(wrapper.state('count')).toBe(0);
});

// ✅ GOOD: Testing behavior
test('increments counter', () => {
  render(<Counter />);
  const button = screen.getByRole('button', { name: /increment/i });
  
  fireEvent.click(button);
  
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Setup

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
// jest.setup.ts
import '@testing-library/jest-dom';

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

Query Priority

Use queries in this order:

  1. Accessible to everyone: getByRole, getByLabelText, getByPlaceholderText, getByText
  2. Semantic queries: getByAltText, getByTitle
  3. Test IDs: getByTestId (last resort)
// 1. ✅ BEST: By role (accessible)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });

// 2. ✅ GOOD: By label (accessible)
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter your email');

// 3. ✅ OK: By text
screen.getByText('Welcome back');

// 4. ⚠️ LAST RESORT: By test ID
screen.getByTestId('submit-button');

Testing Patterns

1. User Interactions

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('user can submit form', async () => {
  const user = userEvent.setup();
  const onSubmit = jest.fn();
  
  render(<LoginForm onSubmit={onSubmit} />);
  
  // Type in inputs
  await user.type(
    screen.getByRole('textbox', { name: /email/i }),
    'test@example.com'
  );
  
  await user.type(
    screen.getByLabelText(/password/i),
    'password123'
  );
  
  // Submit form
  await user.click(screen.getByRole('button', { name: /sign in/i }));
  
  // Verify
  expect(onSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

2. Async Operations

test('loads and displays data', async () => {
  render(<UserProfile userId="123" />);
  
  // Initially shows loading
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  
  // Wait for data to load
  const name = await screen.findByText('John Doe');
  expect(name).toBeInTheDocument();
  
  // Loading indicator is gone
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

// With waitFor
test('displays error message', async () => {
  render(<DataComponent />);
  
  await waitFor(() => {
    expect(screen.getByText(/error/i)).toBeInTheDocument();
  });
});

3. Testing Hooks

import { renderHook, act } from '@testing-library/react';

test('useCounter increments count', () => {
  const { result } = renderHook(() => useCounter());
  
  expect(result.current.count).toBe(0);
  
  act(() => {
    result.current.increment();
  });
  
  expect(result.current.count).toBe(1);
});

// With props
test('useCounter starts with initial value', () => {
  const { result } = renderHook(() => useCounter(10));
  
  expect(result.current.count).toBe(10);
});

// With context
test('useAuth returns user', () => {
  const wrapper = ({ children }) => (
    <AuthProvider>{children}</AuthProvider>
  );
  
  const { result } = renderHook(() => useAuth(), { wrapper });
  
  expect(result.current.user).toBeDefined();
});

4. Component States

test('button shows all states', async () => {
  const user = userEvent.setup();
  const onClick = jest.fn();
  
  render(<Button onClick={onClick}>Click me</Button>);
  
  const button = screen.getByRole('button');
  
  // Default state
  expect(button).toBeEnabled();
  expect(button).toHaveTextContent('Click me');
  
  // Hover state (CSS, check with toHaveStyle if needed)
  await user.hover(button);
  
  // Click
  await user.click(button);
  expect(onClick).toHaveBeenCalled();
  
  // Disabled state
  render(<Button disabled>Click me</Button>);
  expect(screen.getByRole('button')).toBeDisabled();
  
  // Loading state
  render(<Button isLoading>Click me</Button>);
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
});

5. Error Boundaries

test('error boundary catches errors', () => {
  const ThrowError = () => {
    throw new Error('Test error');
  };
  
  // Suppress console.error for this test
  const spy = jest.spyOn(console, 'error').mockImplementation();
  
  render(
    <ErrorBoundary fallback={<div>Error occurred</div>}>
      <ThrowError />
    </ErrorBoundary>
  );
  
  expect(screen.getByText('Error occurred')).toBeInTheDocument();
  
  spy.mockRestore();
});

Custom Render Function

// test-utils.tsx
import { render as rtlRender } from '@testing-library/react';
import { ThemeProvider } from '@/components/ThemeProvider';
import { AuthProvider } from '@/contexts/AuthContext';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function render(
  ui: React.ReactElement,
  {
    initialAuth = null,
    theme = 'light',
    ...options
  } = {}
) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <ThemeProvider defaultTheme={theme}>
          <AuthProvider initialUser={initialAuth}>
            {children}
          </AuthProvider>
        </ThemeProvider>
      </QueryClientProvider>
    );
  }

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

export * from '@testing-library/react';
export { render };

// Usage
import { render, screen } from '@/test-utils';

test('displays user name', () => {
  render(<Dashboard />, {
    initialAuth: { name: 'John' },
    theme: 'dark',
  });
  
  expect(screen.getByText('John')).toBeInTheDocument();
});

Mocking

API Calls (MSW)

// mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/user', (req, res, ctx) => {
    return res(
      ctx.json({
        id: '1',
        name: 'John Doe',
        email: 'john@example.com',
      })
    );
  }),
  
  rest.post('/api/login', (req, res, ctx) => {
    return res(
      ctx.json({ success: true, token: 'abc123' })
    );
  }),
];

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

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

// jest.setup.ts
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Context/Providers

test('uses theme context', () => {
  const MockThemeProvider = ({ children }) => (
    <ThemeContext.Provider value={{ theme: 'dark', toggleTheme: jest.fn() }}>
      {children}
    </ThemeContext.Provider>
  );
  
  render(
    <MockThemeProvider>
      <ThemedComponent />
    </MockThemeProvider>
  );
  
  expect(screen.getByTestId('theme-indicator')).toHaveTextContent('dark');
});

Router

// Mock Next.js router
jest.mock('next/navigation', () => ({
  useRouter: () => ({
    push: jest.fn(),
    pathname: '/test',
    query: {},
  }),
  usePathname: () => '/test',
  useSearchParams: () => new URLSearchParams(),
}));

test('navigates on click', () => {
  const push = jest.fn();
  jest.spyOn(require('next/navigation'), 'useRouter').mockReturnValue({
    push,
  });
  
  render(<NavigationButton />);
  
  fireEvent.click(screen.getByRole('button'));
  
  expect(push).toHaveBeenCalledWith('/new-page');
});

Accessibility Testing

import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('has no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  
  expect(results).toHaveNoViolations();
});

Snapshot Testing

// Use sparingly!
test('matches snapshot', () => {
  const { container } = render(<Button>Click me</Button>);
  
  expect(container.firstChild).toMatchSnapshot();
});

// Inline snapshots (better)
test('renders correctly', () => {
  const { container } = render(<Button>Click me</Button>);
  
  expect(container.firstChild).toMatchInlineSnapshot(`
    <button class="btn btn-primary">
      Click me
    </button>
  `);
});

Best Practices

  1. Test behavior, not implementation
  2. Use accessible queries (role, label)
  3. Avoid test IDs unless necessary
  4. Don't test implementation details
  5. Test user interactions with userEvent
  6. Mock external dependencies (API, router)
  7. Use custom render for providers
  8. Keep tests simple and readable

Common Mistakes

Using getByTestId everywhere
Use getByRole, getByLabelText

Testing implementation details
Test user-facing behavior

Checking state/props directly
Test what user sees

Shallow rendering
Full rendering with Testing Library

Not waiting for async
Use findBy, waitFor

Component testing done right gives you confidence to refactor without breaking functionality.

On this page