Testing

Writing Tests with Vitest and React Testing Library

I switched from Jest to Vitest on a large React project. The test suite went from 45 seconds to 8 seconds. Same tests, same assertions, dramatically less ...

17 Apr 2024

Writing Tests with Vitest and React Testing Library

I switched from Jest to Vitest on a large React project. The test suite went from 45 seconds to 8 seconds. Same tests, same assertions, dramatically less waiting.

Vitest runs on Vite's infrastructure. It shares your Vite config, understands your aliases, and transforms your code the same way. No separate Babel or webpack pipeline for tests. That is why it is fast.

React Testing Library stays the same. You still query by role, text, and test ID. You still simulate user behavior. The runner changed, not the philosophy.

Setup

Install the dependencies:

Bash
pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom

Create a vitest.config.ts:

Ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    css: true,
  },
});

Create the setup file at src/test/setup.ts:

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

This gives you matchers like toBeInTheDocument() and toHaveTextContent() without importing them in every test file.

Writing a test

Here is a simple Counter component:

Tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

And the test:

Tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Counter } from './Counter';

describe('Counter', () => {
  it('starts at zero', () => {
    render(<Counter />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });

  it('increments on click', async () => {
    const user = userEvent.setup();
    render(<Counter />);
    await user.click(screen.getByRole('button', { name: 'Increment' }));
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

Use userEvent over fireEvent. It simulates real browser behavior: focus, pointer events, keyboard input. fireEvent dispatches a single synthetic event. userEvent dispatches the full sequence a real user would trigger.

Testing async components

Components that fetch data need a bit more setup. Use findBy queries, which wait for elements to appear:

Tsx
it('shows user data after loading', async () => {
  render(<UserProfile id="123" />);
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  expect(await screen.findByText('John Doe')).toBeInTheDocument();
});

findBy queries retry until the element appears or a timeout is reached. No manual waitFor needed for simple cases.

Vitest vs Jest: the trade-off

Vitest is faster and shares your Vite config. But Jest has a larger ecosystem, more mature IDE integrations, and broader community knowledge. If your project already runs on Vite, Vitest is the obvious choice. If you are on webpack or a non-Vite setup, the migration cost may not be worth it.

The tests themselves are nearly identical. The API is compatible. Switching runners is a tooling decision, not a rewrite.

Keep reading