Test React components from the user’s perspective — what they see and do, not implementation details.

Setup with Vitest

  npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
  

vite.config.js:

  export default {
    test: {
        environment: 'jsdom',
        setupFiles: './src/test/setup.js'
    }
};
  

src/test/setup.js:

  import '@testing-library/jest-dom';
  

package.json:

  { "scripts": { "test": "vitest" } }
  

Basic Component Test

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

test('renders button with label', () => {
    render(<Button label="Click me" />);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});

test('calls onClick when clicked', async () => {
    const handleClick = vi.fn();
    render(<Button label="Click" onClick={handleClick} />);
    await userEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
});
  

Testing State Changes

  import Counter from './Counter';

test('increments count on click', async () => {
    render(<Counter />);
    const button = screen.getByRole('button', { name: /increment/i });
    await userEvent.click(button);
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
  

Testing Async Components

  import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

test('loads and displays user', async () => {
    global.fetch = vi.fn(() =>
        Promise.resolve({
            ok: true,
            json: () => Promise.resolve({ name: 'Alice' })
        })
    );

    render(<UserProfile userId={1} />);
    expect(screen.getByText(/loading/i)).toBeInTheDocument();

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

Query Priority

Prefer queries in this order:

  1. getByRole — most accessible
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByTestId — last resort
  screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
screen.getByText('Welcome');
  

Testing Forms

  test('submits form with valid data', async () => {
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await userEvent.type(screen.getByLabelText(/email/i), '[email protected]');
    await userEvent.type(screen.getByLabelText(/password/i), 'secret123');
    await userEvent.click(screen.getByRole('button', { name: /login/i }));

    expect(onSubmit).toHaveBeenCalledWith({
        email: '[email protected]',
        password: 'secret123'
    });
});
  

What NOT to Test

  • Implementation details (internal state variable names)
  • Third-party library behavior
  • Styles (unless critical to functionality)

Run Tests

  npm test           # Watch mode
npm test -- --run  # Single run
npm test -- --coverage
  

Write tests that give confidence your UI works for users — not tests that break every refactor.