•7 min read

Modern React Testing with Vitest and React Testing Library

Modern React Testing with Vitest and React Testing Library

Testing React applications has evolved significantly over the years. For a long time, Jest has been the undisputed king of the JavaScript testing ecosystem. However, as the industry shifted towards ES Modules (ESM) and modern build tools like Vite, Jest began to show its age—particularly regarding execution speed and ESM compatibility. Enter Vitest, a blazing fast, Vite-native testing framework that is quickly becoming the new standard for React projects.

In this comprehensive guide, we'll dive deep into migrating from Jest to Vitest, integrating it with React Testing Library (RTL), and leveraging modern testing patterns that surpass traditional tutorials.

Audio Briefing
0:00 / 0:00

Why Migrate from Jest to Vitest?

Before we dive into the code, it's crucial to understand why this shift is happening. While comprehensive tutorials (like those from Robin Wieruch) have historically championed Jest, the modern frontend landscape demands faster iteration cycles.

  1. Native ESM Support: Jest has historically struggled with ESM, often requiring complex Babel configurations or experimental flags. Vitest supports ESM out-of-the-box.
  2. Vite Integration: If your React app is already using Vite, Vitest shares the same configuration (vite.config.ts), plugins, and transformation pipeline. This eliminates the dreaded "works in dev, fails in test" discrepancies.
  3. Speed: By leveraging ESBuild for transformation, Vitest executes tests significantly faster than Jest, especially in large codebases.
  4. Watch Mode: Vitest's intelligent watch mode (powered by Vite's HMR mechanism) instantly reruns only the tests affected by file changes.
  5. API Compatibility: Vitest provides a Jest-compatible API, making migration surprisingly straightforward. describe, it, expect, and vi (the Vitest equivalent of jest) are drop-in replacements for the most part.
Advertisement

Step 1: Installation and Configuration

Let's start by ripping out Jest and installing Vitest along with React Testing Library.

npm uninstall jest @types/jest ts-jest @testing-library/jest-dom
npm install -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom jsdom @testing-library/user-event

Next, configure Vitest. If you're using Vite, simply add the test property to your vite.config.ts:

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

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true, // Enables 'describe', 'it', 'expect' globally like Jest
    environment: 'jsdom', // Simulates a browser environment for React components
    setupFiles: './src/setupTests.ts', // Path to your setup file
    css: true, // Process CSS imports
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

Setting up the Environment

Create src/setupTests.ts to extend Vitest's expect with DOM matchers from @testing-library/jest-dom:

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

// Automatically unmount and cleanup DOM after the test is finished.
afterEach(() => {
  cleanup();
});

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

Notice we import @testing-library/jest-dom/vitest. This is a newer addition that provides native Vitest types, avoiding namespace collisions with Jest.

Step 2: The Migration Process (Jest to Vitest)

Migrating your test files is usually a search-and-replace operation:

  1. Globals: If you set globals: true in your config, you don't need to import describe, it, or expect from vitest.
  2. Mocking: Replace jest.fn() with vi.fn(), jest.spyOn() with vi.spyOn(), and jest.mock() with vi.mock().
  3. Timers: Replace jest.useFakeTimers() with vi.useFakeTimers().

Advanced Mocking Example

Let's look at how mocking a module changes.

Jest:

jest.mock('axios', () => ({
  default: { get: jest.fn().mockResolvedValue({ data: {} }) }
}));

Vitest:

import { vi } from 'vitest';
import axios from 'axios';

vi.mock('axios', () => ({
  default: { get: vi.fn().mockResolvedValue({ data: {} }) }
}));

While identical in structure, Vitest's hoisting mechanism is slightly different. Vitest hoists vi.mock to the top of the file, just like Jest, but it integrates more seamlessly with ESM, reducing the edge cases where mocked modules evaluate before the mock is applied.

Step 3: Modern Component Testing with React Testing Library

React Testing Library (RTL) encourages testing components from the user's perspective rather than testing implementation details. Let's build a complex asynchronous component and test it properly.

// src/components/UserProfile.tsx
import React, { useState, useEffect } from 'react';

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

export const UserProfile = ({ userId }: { userId: string }) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error');
      } finally {
        setLoading(false);
      }
    };
    fetchUser();
  }, [userId]);

  if (loading) return <div role="status">Loading...</div>;
  if (error) return <div role="alert">{error}</div>;
  if (!user) return <div>No user found</div>;

  return (
    <article>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <button onClick={() => console.log('Edit clicked')}>Edit Profile</button>
    </article>
  );
};

Writing the Test

Here is how you write a modern, resilient test suite for this component using Vitest and RTL, avoiding common anti-patterns like using waitFor unnecessarily.

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

// Mock the global fetch
global.fetch = vi.fn();

describe('UserProfile Component', () => {
  const mockUser = { id: '1', name: 'Jane Doe', email: 'jane@example.com' };

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('renders loading state initially, then displays user data', async () => {
    (global.fetch as any).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    });

    render(<UserProfile userId="1" />);

    // 1. Assert loading state
    expect(screen.getByRole('status')).toHaveTextContent('Loading...');

    // 2. Wait for loading to finish (Modern pattern instead of waitFor)
    await waitForElementToBeRemoved(() => screen.queryByRole('status'));

    // 3. Assert user data
    expect(screen.getByRole('heading', { name: 'Jane Doe' })).toBeInTheDocument();
    expect(screen.getByText('jane@example.com')).toBeInTheDocument();
  });

  it('renders error state on API failure', async () => {
    (global.fetch as any).mockResolvedValueOnce({
      ok: false,
    });

    render(<UserProfile userId="2" />);

    // Wait for the alert role to appear
    const alertElement = await screen.findByRole('alert');
    expect(alertElement).toHaveTextContent('Failed to fetch user');
  });

  it('simulates user interaction correctly', async () => {
    (global.fetch as any).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    });
    const consoleSpy = vi.spyOn(console, 'log');

    render(<UserProfile userId="1" />);
    
    // Wait for data to load using findByRole
    const editButton = await screen.findByRole('button', { name: /edit profile/i });

    // Use userEvent instead of fireEvent for more realistic interactions
    const user = userEvent.setup();
    await user.click(editButton);

    expect(consoleSpy).toHaveBeenCalledWith('Edit clicked');
  });
});

Key Takeaways from the Test Suite:

  1. userEvent over fireEvent: We utilize @testing-library/user-event. It simulates full DOM events (like keyboard focus, bubbling, etc.), making tests significantly closer to how a real user interacts with the browser.
  2. findBy* instead of waitFor: Instead of wrapping a getBy* in a waitFor block, modern RTL encourages using await screen.findBy*(). It is cleaner and handles the polling internally.
  3. waitForElementToBeRemoved: This is the cleanest way to assert that a loading skeleton or spinner has vanished before proceeding to assert the rendered content.
Advertisement

Continuous Integration and Coverage

Vitest uses c8 (via v8) or istanbul for code coverage. To enable coverage, run:

npx vitest run --coverage

You might need to install the provider: npm i -D @vitest/coverage-v8. The output is instantly generated, and because it runs on ESBuild, it's remarkably fast in CI pipelines.

Conclusion

Migrating from Jest to Vitest is not just about keeping up with trends; it's a pragmatic upgrade for developer experience. You get native ESM support, shared configuration with Vite, and execution speeds that can cut CI times in half.

When combined with the philosophical approach of React Testing Library—testing behaviors over implementation details—your test suite becomes a robust safety net rather than a brittle maintenance burden. Embrace Vitest, utilize userEvent, and stop testing your components' internal states. Happy testing!

You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement