Unit Testing React Components with Vitest and Testing Library

React components need tests too โ€” to verify that they render the right output for given props, respond correctly to user interactions, and handle edge cases gracefully. Vitest is the natural test runner for Vite projects โ€” it is faster than Jest, shares the Vite config including path aliases, and has an identical API. React Testing Library (RTL) provides utilities for rendering components and querying the DOM the way a real user would โ€” by visible text, labels, and roles rather than implementation details like CSS classes or component state. Together they let you test components confidently without ever relying on internal implementation details.

Setup โ€” Vitest and React Testing Library

cd client
npm install --save-dev vitest @testing-library/react @testing-library/user-event \
  @testing-library/jest-dom jsdom msw
// client/vite.config.js โ€” add test configuration
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { '@': path.resolve(__dirname, './src') } },
  test: {
    globals:     true,         // no need to import describe, test, expect
    environment: 'jsdom',      // simulate browser DOM
    setupFiles:  ['./src/test/setup.ts'],
    css:         false,        // skip CSS processing in tests
  },
});

// client/src/test/setup.ts
import '@testing-library/jest-dom'; // adds .toBeInTheDocument(), .toHaveValue() etc.
Note: React Testing Library’s core philosophy is to test components the way users interact with them โ€” by visible text, ARIA roles, and labels โ€” not by internal implementation details. This means querying with getByRole('button', { name: /submit/i }) instead of getByTestId('submit-btn') or container.querySelector('.btn-primary'). Tests written this way remain valid even if you rename a CSS class or refactor the component’s internal state management.
Tip: Use screen queries from @testing-library/react directly โ€” they query the globally rendered component tree without needing to capture a return value from render(). The most useful query families are getBy* (throws if not found โ€” good for elements that must exist), queryBy* (returns null if not found โ€” good for asserting absence), and findBy* (async โ€” waits up to 1000ms โ€” good for elements that appear after state updates).
Warning: Avoid testing implementation details. Never test that a specific useState variable has a particular value, that a specific internal function was called, or that a component has a specific CSS class that is not meaningful to the user. If a refactor that does not change the user-facing behaviour breaks your test, the test is testing the wrong thing.

Testing a Presentational Component

// src/components/posts/__tests__/PostCard.test.jsx
import { describe, test, expect } from 'vitest';
import { render, screen }          from '@testing-library/react';
import { BrowserRouter }           from 'react-router-dom'; // PostCard uses Link
import PostCard                    from '../PostCard';

const mockPost = {
  _id:       '64a1f2b3c8e4d5f6a7b8c9d0',
  title:     'Getting Started with MERN',
  excerpt:   'Learn how to build a full-stack application',
  viewCount: 142,
  featured:  false,
  createdAt: '2025-01-15T00:00:00.000Z',
  tags:      ['mern', 'react'],
  author:    { _id: 'author-id', name: 'Jane Smith', avatar: null },
};

const renderPostCard = (props = {}) => render(
  <BrowserRouter>
    <PostCard post={{ ...mockPost, ...props.post }} {...props} />
  </BrowserRouter>
);

describe('PostCard', () => {
  test('renders the post title', () => {
    renderPostCard();
    expect(screen.getByText('Getting Started with MERN')).toBeInTheDocument();
  });

  test('renders the author name', () => {
    renderPostCard();
    expect(screen.getByText('Jane Smith')).toBeInTheDocument();
  });

  test('renders the view count', () => {
    renderPostCard();
    expect(screen.getByText(/142/)).toBeInTheDocument();
  });

  test('shows Featured badge when post is featured', () => {
    renderPostCard({ post: { ...mockPost, featured: true } });
    expect(screen.getByText('Featured')).toBeInTheDocument();
  });

  test('does not show Featured badge for regular posts', () => {
    renderPostCard();
    expect(screen.queryByText('Featured')).not.toBeInTheDocument();
  });

  test('does not show delete button for non-owners', () => {
    renderPostCard({ isOwner: false });
    expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
  });

  test('shows delete button for the post owner', () => {
    renderPostCard({ isOwner: true, onDelete: vi.fn() });
    expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
  });

  test('calls onDelete with the post ID when delete is clicked', async () => {
    const { userEvent } = await import('@testing-library/user-event');
    const user    = userEvent.setup();
    const onDelete = vi.fn();
    renderPostCard({ isOwner: true, onDelete });

    await user.click(screen.getByRole('button', { name: /delete/i }));
    expect(onDelete).toHaveBeenCalledWith(mockPost._id);
  });
});

Mocking API Calls with MSW

// src/test/handlers.js โ€” MSW request handlers
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/posts', () => {
    return HttpResponse.json({
      success: true,
      data:    [
        { _id: '1', title: 'Post 1', author: { name: 'Alice' }, tags: [], viewCount: 0 },
        { _id: '2', title: 'Post 2', author: { name: 'Bob' },   tags: [], viewCount: 5 },
      ],
      total: 2,
    });
  }),

  http.post('/api/auth/login', () => {
    return HttpResponse.json({
      success: true,
      token:   'mock-jwt-token',
      data:    { _id: 'user-1', name: 'Test User', email: 'test@example.com', role: 'user' },
    });
  }),

  http.post('/api/auth/login', async ({ request }) => {
    const body = await request.json();
    if (body.password === 'wrongpassword') {
      return HttpResponse.json({ success: false, message: 'Invalid credentials' }, { status: 401 });
    }
    return HttpResponse.json({ success: true, token: 'mock-token', data: { ... } });
  }),
];

// src/test/setup.ts โ€” start MSW before tests
import { setupServer } from 'msw/node';
import { handlers }    from './handlers';

const server = setupServer(...handlers);
beforeAll(()    => server.listen());
afterEach(()    => server.resetHandlers()); // reset overrides between tests
afterAll(()     => server.close());

Common Mistakes

Mistake 1 โ€” Using getByTestId instead of semantic queries

โŒ Wrong โ€” querying by test ID that ties the test to implementation:

screen.getByTestId('delete-btn'); // breaks if you rename data-testid

โœ… Correct โ€” query by accessible role or text:

screen.getByRole('button', { name: /delete/i }); // โœ“ matches user intent

Mistake 2 โ€” Not wrapping Router-dependent components in BrowserRouter

โŒ Wrong โ€” component uses Link but test renders without Router:

render(<PostCard post={post} />);
// Error: useHref() may be used only in the context of a Router component

โœ… Correct โ€” wrap in BrowserRouter:

render(<BrowserRouter><PostCard post={post} /></BrowserRouter>); // โœ“

Mistake 3 โ€” Not waiting for async UI updates

โŒ Wrong โ€” asserting before the async state update completes:

fireEvent.click(loadButton);
expect(screen.getByText('Post Title')).toBeInTheDocument(); // fails โ€” data not loaded yet

โœ… Correct โ€” use findBy* which waits for the element to appear:

fireEvent.click(loadButton);
expect(await screen.findByText('Post Title')).toBeInTheDocument(); // โœ“ waits

Quick Reference

Task Code
Render component render(<Component />)
Find by role screen.getByRole('button', { name: /submit/i })
Find by text screen.getByText(/hello/i)
Assert present expect(el).toBeInTheDocument()
Assert absent expect(screen.queryByText('x')).not.toBeInTheDocument()
Wait for element await screen.findByText('Loaded!')
Click a button await user.click(screen.getByRole('button'))
Type in input await user.type(screen.getByLabelText('Email'), 'a@b.com')
Mock a function const fn = vi.fn(); expect(fn).toHaveBeenCalled()

🧠 Test Yourself

You write screen.getByText('Delete') to find a delete button. After a designer changes the button label to “Remove”, the test fails even though the feature still works. How should you have written the query?