Moving my tests from Jest to Vitest

Eight months ago I left the tests behind when I moved to Vite. Moving them took an evening. Reading them took longer, because most of them tested the wrong thing.

When I moved my side project from Create React App to Vite in December, I left the tests on Jest and wrote that they would wait a few releases. Vitest was two weeks old then. It is past 0.20 now, the API has stopped moving every week, and Testing Library 13 supports React 18. So I spent an evening on it.

The move itself was short. What took the time was reading the tests I was moving, because a good half of them were written in 2019 by someone who knew less about testing than I'd like to admit. That someone was me.

The move

Vitest reads the same vite.config.js as the app, and that's the main reason to use it. The plugins, the aliases and import.meta.env work in tests without a second config that drifts from the first, which is exactly what my Jest setup had become.

bash
npm uninstall jest babel-jest
npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
js
/// <reference types="vitest" />
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // a DOM for the components to render into, Jest had this on by default
    environment: 'jsdom',
    // describe, it, expect, vi and afterEach as globals, like Jest. Keep reading for why
    globals: true,
    // the jest-dom matchers like toBeInTheDocument
    setupFiles: './src/setupTests.js',
    // undo every spy and mock after each test, so tests can't leak into each other
    restoreMocks: true,
  },
});
js
// src/setupTests.js
import '@testing-library/jest-dom';
json
{
  "scripts": {
    "test": "vitest",
    "test:ci": "vitest run"
  }
}

vitest on its own is watch mode and reruns the tests that import what you just saved. vitest run is the one for CI.

Then the rename. Vitest's mocking lives on vi instead of jest, with the same names:

bash
# jest.fn, jest.mock, jest.spyOn and friends become vi.fn, vi.mock, vi.spyOn
grep -rl 'jest\.' src | xargs sed -i 's/\bjest\./vi./g'

That was it. The CSS imports that needed identity-obj-proxy in Jest just work, because Vite understands CSS. The import.meta.env reads that stopped Jest in its tracks just work, same reason. The tests ran.

What they tested

Here is one of them, more or less as I found it:

jsx
// src/components/TodoList.test.jsx, the 2019 version
import { render, fireEvent } from '@testing-library/react';
import TodoList from './TodoList';

test('adds a todo', () => {
  const { container } = render(<TodoList />);

  fireEvent.change(container.querySelector('.todo-input'), { target: { value: 'Buy milk' } });
  fireEvent.click(container.querySelector('.todo-add'));

  expect(container.querySelectorAll('.todo-item').length).toBe(1);
  expect(container.querySelector('.todo-item').textContent).toBe('Buy milk');
});

It passes. It also passes if the input has no label, if the button is a div with a click handler, and if the list is unreadable to anyone using a screen reader. And because it knows the class names, it fails the moment I rename one while restyling, which I was about to do.

Testing Library has had the better way for years and I had imported it only for render. Let's fix that.

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

test('adds a todo', async () => {
  const user = userEvent.setup();
  render(<TodoList />);

  // find things the way a user would: by role and by label
  await user.type(screen.getByRole('textbox', { name: /new todo/i }), 'Buy milk');
  await user.click(screen.getByRole('button', { name: /add/i }));

  expect(screen.getByRole('listitem')).toHaveTextContent('Buy milk');
});

Three things changed. screen.getByRole instead of container.querySelector: the test asks for a textbox named "new todo", which only exists if the input has a label, so the first thing this test did was fail until I added one. userEvent instead of fireEvent: user.type focuses, presses keys one by one and fires the events a browser would, where fireEvent.change sets a value and lies about how it got there. And the assertion is about what the user sees in the list, not about how many nodes carry a class.

The queries have a pecking order and the docs spell it out: role first, then label, then placeholder and text, and getByTestId last, for the cases where nothing visible identifies the element. I didn't need a test id anywhere.

The async ones

The other half of the old tests waited for data with setTimeout and a promise, or with act wrapped around things that didn't need it. findBy queries wait on their own:

jsx
// src/pages/UserPage.test.jsx
import { render, screen } from '@testing-library/react';
import UserPage from './UserPage';

test('shows the user after loading', async () => {
  // restoreMocks in the config puts the real fetch back after the test
  vi.spyOn(globalThis, 'fetch').mockResolvedValue({
    ok: true,
    json: async () => ({ id: 1, name: 'Ada' }),
  });

  render(<UserPage id={1} />);

  // findBy retries until the heading appears or the timeout hits, no act warnings
  expect(await screen.findByRole('heading', { name: 'Ada' })).toBeInTheDocument();
});

Spying on fetch is fine for a side project with three endpoints. Past that, Mock Service Worker intercepts the requests at the network level and the components don't know they're being tested at all. I'd reach for it at work, not here.

Where I got stuck

  1. Tests passed alone and failed together. Testing Library unmounts the previous render in an afterEach it registers for you, but only if afterEach is a global. With globals: false nothing is cleaned up and the second test finds two lists. globals: true in the config, or cleanup() by hand.
  2. jsdom is not included. Vitest asks you to install the environment yourself, which is fair, but the error shows up as a failed import deep in Vitest and not as "install jsdom".
  3. toBeInTheDocument is not a function means the setup file didn't run. Check the setupFiles path, it's relative to the project root, not to the test.
  4. Fake timers hang userEvent. Version 14 of user-event waits between key presses, and with vi.useFakeTimers() that wait never ends. userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) tells it how to move the clock.

Was it worth it

The move was a config file and a search and replace, which is what it should have been in December too. The rewrite was the useful part. The tests I kept now describe what the page does for a person, and they survived the restyle I did the next day without a single edit. The ones I couldn't say that about, I deleted. That's a kind of coverage too.

Read more here: