Skip to content
Guides

Testing Applications with Vitest and React Testing Library

Published on:
·4 min read·Author: MDS Software Solutions Group

Testing Applications with

poradniki

Testing Applications with Vitest and React Testing Library

Testing is an essential part of professional software development. Well-written tests give you confidence that your application works as expected, protect against regressions, and make refactoring easier. In the React ecosystem, Jest dominated for years, but Vitest is quickly becoming the new standard thanks to its blazing-fast speed, native ESM support, and excellent integration with Vite. In this guide, we will show you how to configure Vitest, write tests for React components with Testing Library, mock dependencies, test hooks, APIs, and much more.

Why Vitest Over Jest?#

Jest is a proven testing framework that has served the React community well for years. However, Vitest offers several significant advantages that make it worth considering for your next project or migration.

Speed#

Vitest leverages Vite's transformations (esbuild/SWC), which means significantly faster TypeScript and JSX compilation compared to Babel used by Jest. Tests run almost instantly, and watch mode reacts to changes in milliseconds.

Native ESM Support#

Vitest natively supports ES Modules, eliminating the headaches of configuring transformIgnorePatterns and moduleNameMapper that often cause issues with Jest.

API Compatibility#

Vitest's API is largely compatible with Jest. Functions like describe, it, expect, vi.fn(), and vi.mock() work analogously, making migration of existing tests straightforward.

Vite Integration#

If your project uses Vite as a bundler, Vitest shares the same configuration. You do not need to maintain a separate configuration for tests - aliases, plugins, and transformations are shared.

// vite.config.ts - shared configuration
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': '/src',
      '@components': '/src/components',
      '@utils': '/src/utils',
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    css: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
  },
});

Project Setup#

Let us start by installing the necessary dependencies.

Installation#

# Vitest and DOM environment
npm install -D vitest @vitest/coverage-v8 jsdom

# React Testing Library
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event

# MSW for API mocking
npm install -D msw

Setup File#

Create a setup file that will be loaded before every test file.

// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

// Automatic cleanup after each test
afterEach(() => {
  cleanup();
});

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

// Mock IntersectionObserver
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}

Object.defineProperty(window, 'IntersectionObserver', {
  writable: true,
  value: MockIntersectionObserver,
});

Package.json Scripts#

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

Testing React Components#

React Testing Library promotes testing components from the user's perspective. Instead of testing implementation details, we focus on what the user sees and interacts with.

Basic Component Test#

// src/components/Button/Button.tsx
interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
  variant?: 'primary' | 'secondary' | 'danger';
}

export function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant}`}
      onClick={onClick}
      disabled={disabled}
      data-testid="custom-button"
    >
      {label}
    </button>
  );
}
// src/components/Button/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';

describe('Button', () => {
  it('renders the button with the provided text', () => {
    render(<Button label="Click me" onClick={() => {}} />);

    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const handleClick = vi.fn();
    const user = userEvent.setup();

    render(<Button label="Save" onClick={handleClick} />);

    await user.click(screen.getByRole('button', { name: 'Save' }));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('does not call onClick when the button is disabled', async () => {
    const handleClick = vi.fn();
    const user = userEvent.setup();

    render(<Button label="Save" onClick={handleClick} disabled />);

    await user.click(screen.getByRole('button', { name: 'Save' }));

    expect(handleClick).not.toHaveBeenCalled();
  });

  it('applies the correct CSS class for the variant', () => {
    render(<Button label="Delete" onClick={() => {}} variant="danger" />);

    expect(screen.getByRole('button')).toHaveClass('btn-danger');
  });
});

Testing Forms#

Forms are among the most frequently tested elements in applications. Testing Library provides tools for simulating user interactions with form fields.

// src/components/LoginForm/LoginForm.tsx
import { useState } from 'react';

interface LoginFormProps {
  onSubmit: (credentials: { email: string; password: string }) => Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    if (!email || !password) {
      setError('Please fill in all fields');
      return;
    }

    setIsLoading(true);
    try {
      await onSubmit({ email, password });
    } catch (err) {
      setError('Invalid login credentials');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} aria-label="Login form">
      {error && <div role="alert">{error}</div>}
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  );
}
// src/components/LoginForm/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  const mockSubmit = vi.fn();

  beforeEach(() => {
    mockSubmit.mockReset();
  });

  it('shows an error when fields are empty', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockSubmit} />);

    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    expect(screen.getByRole('alert')).toHaveTextContent('Please fill in all fields');
    expect(mockSubmit).not.toHaveBeenCalled();
  });

  it('submits the form with valid data', async () => {
    const user = userEvent.setup();
    mockSubmit.mockResolvedValueOnce(undefined);

    render(<LoginForm onSubmit={mockSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'john@example.com');
    await user.type(screen.getByLabelText('Password'), 'secretpassword123');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith({
        email: 'john@example.com',
        password: 'secretpassword123',
      });
    });
  });

  it('shows loading state during submission', async () => {
    const user = userEvent.setup();
    mockSubmit.mockImplementation(() => new Promise(() => {}));

    render(<LoginForm onSubmit={mockSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'john@example.com');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    expect(screen.getByRole('button', { name: 'Signing in...' })).toBeDisabled();
  });

  it('displays an error on failed login', async () => {
    const user = userEvent.setup();
    mockSubmit.mockRejectedValueOnce(new Error('Unauthorized'));

    render(<LoginForm onSubmit={mockSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'john@example.com');
    await user.type(screen.getByLabelText('Password'), 'wrongpassword');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Invalid login credentials');
    });
  });
});

Mocking with vi.mock and vi.fn#

Vitest provides powerful tools for mocking modules and functions. With these, you can isolate the code under test from external dependencies.

Module Mocking#

// src/services/api.ts
export async function fetchUsers(): Promise<User[]> {
  const response = await fetch('/api/users');
  if (!response.ok) throw new Error('Failed to fetch users');
  return response.json();
}

export async function createUser(data: CreateUserDto): Promise<User> {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!response.ok) throw new Error('Failed to create user');
  return response.json();
}
// src/components/UserList/UserList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { UserList } from './UserList';
import * as api from '@/services/api';

// Mock the entire module
vi.mock('@/services/api', () => ({
  fetchUsers: vi.fn(),
}));

const mockFetchUsers = vi.mocked(api.fetchUsers);

describe('UserList', () => {
  it('displays a list of users', async () => {
    mockFetchUsers.mockResolvedValueOnce([
      { id: 1, name: 'John Smith', email: 'john@example.com' },
      { id: 2, name: 'Jane Doe', email: 'jane@example.com' },
    ]);

    render(<UserList />);

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('John Smith')).toBeInTheDocument();
      expect(screen.getByText('Jane Doe')).toBeInTheDocument();
    });
  });

  it('displays an error when fetching fails', async () => {
    mockFetchUsers.mockRejectedValueOnce(new Error('Network error'));

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Failed to load users');
    });
  });
});

Function Mocking and Spying#

// Creating a mock with implementation
const mockCallback = vi.fn((x: number) => x * 2);

// Checking invocations
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback).toHaveBeenCalledWith(5);
expect(mockCallback).toHaveReturnedWith(10);

// Spying on existing methods
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
// ... test
expect(spy).toHaveBeenCalledWith('Something went wrong');
spy.mockRestore();

// Mocking timers
vi.useFakeTimers();
setTimeout(() => callback(), 1000);
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
vi.useRealTimers();

Snapshot Testing#

Snapshot tests are useful for detecting unexpected changes in rendered HTML. Vitest supports both traditional file-based snapshots and inline snapshots.

import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Card } from './Card';

describe('Card', () => {
  it('renders the correct HTML structure', () => {
    const { container } = render(
      <Card title="Example title" description="Card description">
        <p>Card content</p>
      </Card>
    );

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

  it('renders with inline snapshot', () => {
    const { container } = render(<Card title="Test" />);

    expect(container.firstChild).toMatchInlineSnapshot(`
      <div class="card">
        <h3 class="card-title">Test</h3>
      </div>
    `);
  });
});

Remember that snapshots should be used sparingly. Overly large snapshots become difficult to review and are often mindlessly updated, which reduces their diagnostic value.

Code Coverage#

Vitest uses the v8 or istanbul provider to measure code coverage.

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/types/**',
      ],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
});

Run tests with coverage:

npx vitest run --coverage

The HTML report will be generated in the coverage/ directory and can be opened in a browser.

Async Testing#

React applications frequently perform asynchronous operations - data fetching, timers, animations. Vitest and Testing Library provide tools for testing them.

// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}
// src/components/SearchBox/SearchBox.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SearchBox } from './SearchBox';

describe('SearchBox with debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

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

  it('calls onSearch after the debounce delay', async () => {
    const onSearch = vi.fn();
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

    render(<SearchBox onSearch={onSearch} debounceMs={300} />);

    await user.type(screen.getByRole('searchbox'), 'React');

    // Before debounce expires - should not be called
    expect(onSearch).not.toHaveBeenCalled();

    // After debounce expires
    vi.advanceTimersByTime(300);

    await waitFor(() => {
      expect(onSearch).toHaveBeenCalledWith('React');
    });
  });
});

API Mocking with MSW#

Mock Service Worker (MSW) is an excellent tool for mocking APIs at the network level. Instead of mocking fetch or axios, MSW intercepts HTTP requests and returns defined responses.

MSW Configuration#

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

interface User {
  id: number;
  name: string;
  email: string;
}

const users: User[] = [
  { id: 1, name: 'John Smith', email: 'john@example.com' },
  { id: 2, name: 'Jane Doe', email: 'jane@example.com' },
];

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json(users);
  }),

  http.get('/api/users/:id', ({ params }) => {
    const user = users.find((u) => u.id === Number(params.id));
    if (!user) {
      return new HttpResponse(null, { status: 404 });
    }
    return HttpResponse.json(user);
  }),

  http.post('/api/users', async ({ request }) => {
    const body = (await request.json()) as Omit<User, 'id'>;
    const newUser: User = { id: Date.now(), ...body };
    return HttpResponse.json(newUser, { status: 201 });
  }),

  http.delete('/api/users/:id', () => {
    return new HttpResponse(null, { status: 204 });
  }),
];
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

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

Integration with Vitest#

// src/test/setup.ts - add MSW configuration
import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Using MSW in Tests#

// src/components/UserProfile/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
import { UserProfile } from './UserProfile';

describe('UserProfile', () => {
  it('displays user data', async () => {
    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('John Smith')).toBeInTheDocument();
      expect(screen.getByText('john@example.com')).toBeInTheDocument();
    });
  });

  it('shows a message when the user is not found', async () => {
    server.use(
      http.get('/api/users/:id', () => {
        return new HttpResponse(null, { status: 404 });
      })
    );

    render(<UserProfile userId={999} />);

    await waitFor(() => {
      expect(screen.getByText('User not found')).toBeInTheDocument();
    });
  });

  it('handles server errors', async () => {
    server.use(
      http.get('/api/users/:id', () => {
        return HttpResponse.error();
      })
    );

    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Error loading data');
    });
  });
});

Testing Hooks#

Custom hooks can be tested using renderHook from React Testing Library.

// src/hooks/useLocalStorage.ts
import { useState, useCallback } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    },
    [key, storedValue]
  );

  const removeValue = useCallback(() => {
    setStoredValue(initialValue);
    window.localStorage.removeItem(key);
  }, [key, initialValue]);

  return [storedValue, setValue, removeValue] as const;
}
// src/hooks/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { useLocalStorage } from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('returns the initial value when localStorage is empty', () => {
    const { result } = renderHook(() => useLocalStorage('key', 'default'));

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

  it('reads an existing value from localStorage', () => {
    localStorage.setItem('key', JSON.stringify('saved'));

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

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

  it('updates the value in state and localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('key', 'initial'));

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

    expect(result.current[0]).toBe('updated');
    expect(JSON.parse(localStorage.getItem('key')!)).toBe('updated');
  });

  it('supports functional updates', () => {
    const { result } = renderHook(() => useLocalStorage('count', 0));

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

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

  it('removes the value from localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('key', 'default'));

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

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

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

Integration Tests#

Integration tests verify the collaboration of multiple components. They test realistic usage scenarios without mocking internal dependencies.

// src/features/TodoApp/TodoApp.test.tsx
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { TodoApp } from './TodoApp';

describe('TodoApp - integration test', () => {
  it('allows adding, completing, and deleting tasks', async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    // Add a new task
    const input = screen.getByPlaceholderText('Add a new task...');
    await user.type(input, 'Write tests{Enter}');

    expect(screen.getByText('Write tests')).toBeInTheDocument();
    expect(input).toHaveValue('');

    // Add a second task
    await user.type(input, 'Review PR{Enter}');

    const items = screen.getAllByRole('listitem');
    expect(items).toHaveLength(2);

    // Mark task as completed
    const firstItem = items[0];
    const checkbox = within(firstItem).getByRole('checkbox');
    await user.click(checkbox);

    expect(checkbox).toBeChecked();
    expect(firstItem).toHaveClass('completed');

    // Delete the task
    const deleteButton = within(firstItem).getByRole('button', { name: /delete/i });
    await user.click(deleteButton);

    expect(screen.queryByText('Write tests')).not.toBeInTheDocument();
    expect(screen.getAllByRole('listitem')).toHaveLength(1);
  });

  it('filters tasks by status', async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    // Add tasks
    const input = screen.getByPlaceholderText('Add a new task...');
    await user.type(input, 'Task 1{Enter}');
    await user.type(input, 'Task 2{Enter}');

    // Mark the first as completed
    const items = screen.getAllByRole('listitem');
    await user.click(within(items[0]).getByRole('checkbox'));

    // Filter active
    await user.click(screen.getByRole('button', { name: 'Active' }));

    expect(screen.getAllByRole('listitem')).toHaveLength(1);
    expect(screen.getByText('Task 2')).toBeInTheDocument();

    // Filter completed
    await user.click(screen.getByRole('button', { name: 'Completed' }));

    expect(screen.getAllByRole('listitem')).toHaveLength(1);
    expect(screen.getByText('Task 1')).toBeInTheDocument();
  });
});

CI/CD Integration#

Vitest integrates seamlessly with popular CI/CD platforms. Here is an example configuration for GitHub Actions.

# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18, 20]

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npx vitest run --coverage --reporter=junit --outputFile=test-results.xml

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results-${{ matrix.node-version }}
          path: test-results.xml

Configuring Reporters#

Vitest supports various report formats that are useful in CI environments.

// vitest.config.ts
export default defineConfig({
  test: {
    reporters: ['default', 'junit', 'json'],
    outputFile: {
      junit: './test-results/junit.xml',
      json: './test-results/results.json',
    },
  },
});

Best Practices#

Following proven testing practices will help you write tests that are easy to maintain, readable, and deliver real value.

1. Test Behavior, Not Implementation#

// Bad - testing implementation details
expect(component.state.isOpen).toBe(true);

// Good - testing from the user's perspective
expect(screen.getByRole('dialog')).toBeVisible();

2. Use Queries by Priority#

React Testing Library recommends the following query order:

  1. getByRole - most accessible
  2. getByLabelText - great for form fields
  3. getByPlaceholderText - when no label exists
  4. getByText - for text content
  5. getByTestId - last resort

3. Avoid Testing Implementation Details#

// Bad - relying on internal state
const { result } = renderHook(() => useCounter());
expect(result.current.state.internalFlag).toBe(true);

// Good - testing the public API
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);

4. Organize Tests with AAA (Arrange, Act, Assert)#

it('adds a product to the cart', async () => {
  // Arrange
  const user = userEvent.setup();
  render(<ProductPage productId="123" />);
  await waitFor(() => expect(screen.getByText('Test Product')).toBeInTheDocument());

  // Act
  await user.click(screen.getByRole('button', { name: 'Add to cart' }));

  // Assert
  expect(screen.getByText('Added to cart')).toBeInTheDocument();
});

5. Create Render Helpers#

// src/test/utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/contexts/ThemeContext';

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });
}

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  queryClient?: QueryClient;
}

export function renderWithProviders(
  ui: ReactElement,
  { queryClient = createTestQueryClient(), ...options }: CustomRenderOptions = {}
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <BrowserRouter>
          <ThemeProvider>{children}</ThemeProvider>
        </BrowserRouter>
      </QueryClientProvider>
    );
  }

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

export { screen, waitFor, within } from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';

6. Use Test Data Factories#

// src/test/factories.ts
interface UserFactory {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

let nextId = 1;

export function createUser(overrides: Partial<UserFactory> = {}): UserFactory {
  return {
    id: nextId++,
    name: `User ${nextId}`,
    email: `user${nextId}@example.com`,
    role: 'user',
    ...overrides,
  };
}

export function createUsers(count: number, overrides: Partial<UserFactory> = {}): UserFactory[] {
  return Array.from({ length: count }, () => createUser(overrides));
}

Summary#

Vitest combined with React Testing Library creates a powerful toolkit for testing React applications. Key takeaways from this guide:

  • Vitest offers blazing-fast speed, native ESM support, and excellent Vite integration
  • React Testing Library promotes testing from the user's perspective, leading to more valuable tests
  • MSW enables realistic API mocking at the network level
  • Hook tests can be written using renderHook without creating wrapper components
  • Integration tests verify realistic usage scenarios
  • CI/CD integration ensures tests run automatically on every change

Remember that tests should give you confidence that your application works correctly, not be a burden. Focus on testing key business scenarios and critical user paths.


Need help building a well-tested React application? The MDS Software Solutions Group team specializes in creating reliable web applications with comprehensive test coverage. Get in touch with us and let us discuss your project.

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Testing Applications with Vitest and React Testing Library | MDS Software Solutions Group | MDS Software Solutions Group