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.
npm uninstall jest babel-jest
npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
/// <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,
},
});
// src/setupTests.js
import '@testing-library/jest-dom';
{
"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:
# 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:
// 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.
// 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:
// 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
- Tests passed alone and failed together. Testing Library unmounts the previous render in an
afterEachit registers for you, but only ifafterEachis a global. Withglobals: falsenothing is cleaned up and the second test finds two lists.globals: truein the config, orcleanup()by hand. jsdomis 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".toBeInTheDocument is not a functionmeans the setup file didn't run. Check thesetupFilespath, it's relative to the project root, not to the test.- Fake timers hang
userEvent. Version 14 of user-event waits between key presses, and withvi.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:
- Migrating from Jest in the Vitest docs
- Common mistakes with React Testing Library by Kent C. Dodds
- About Queries, Priority in the Testing Library docs
- user-event v14 in the Testing Library docs
- Mock Service Worker