Migrating from Jest to Vitest
Jest just isn't cutting it
For a long time our team have had various gripes with jest - our test suite was slow, timer mocks broke library code and ESM compatibility left a little be desired. Previously I found a lot of the test slowness could be attributed to type checking prior to running tests, but even after some optimisations (read: disabling the type check) I still wasn't satisfied with performance. Neither was my team - having a PR fall out of date with main is annoying, but having to wait 15 minutes after a rebase for CI to pass to find it was out of date already was agonising.
I'd generally heard Vitest praised for both performance and jest compatability, and after migrating from Mock Service Worker v1 to v2 in a large project I'd needed several workarounds for fetch compatability. Time for change
Spot the difference
Like Jest, Vitest's test framework is structured around the same directives - describe
, it
, test
, expect
, each
, skip
and so forth - which can all be used via the global scope if globals: true
is set in vitest.config.ts
. This meant we didn't need to update every test file with imports such as import { describe, it, beforeEach } from 'vitest'
.
Fixing mocks via find and replace
For the most part, Vitest and Jest have a very similar API for mocking modules. We have lots of cases where we mock out utility helpers or hooks, and around 80% of the updates to our tests could be done with a find and replace.
jest.mock('@/utils/hooks/useMediaQuery');
vi.mock('@/utils/hooks/useMediaQuery');
const jestMock = jest.fn(() => 'works fine')
const viMock = vi.fn(() => 'works the same!')
Updating library mocks
Vitest does differ to Jest in how it handles mocks for node_modules
. We have a few library mocks in our root __mocks__
directory and these were not loaded up automatically by Vitest. Now in our vitest.setup.ts
we manually call vi.mock('@thirdparty/library')
to load these mocks.
We also had to make some adjustments to module mocks where we had previously used requireActual
from Jest:
// Before, using requireActual from Jest
jest.mock('next/server', () => ({
...jest.requireActual('next/server'),
NextRequest: jest.fn((req) => ({
nextUrl: {
pathname: req.pathname
}
}))
}))
// After, using the first argument of the mock factory to
// import the original module
vi.mock('next/server', async (importOriginal) => {
const mod = await importOriginal<typeof import('next/server')>();
return {
...mod,
NextRequest: vi.fn((req) => ({
nextUrl: {
pathname: req.pathname
}
}))
}
})
Timer mocks
One final change required to turn our test suite green was updating timer mocks. With Jest's fake timers implementation, we had to selectively fake timer APIs to prevent breaking MSW response handlers. With Vitest's timer mocks, there's no problems:
const mockDate = new Date(2022, 6, 26);
// Before, with Jest 29
beforeEach(() => {
jest.useFakeTimers({
now: mockDate,
doNotFake: ['queueMicrotask']
});
});
afterEach(() => {
jest.useRealTimers();
});
// After, with Vitest
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
Configuration
In converting our jest.config.ts
to a vitest.config.ts
we stopped needing to configure about half of the options we did with Jest. extensionsToTreatAsEsm
, transform
, modulePaths
, moduleNameMapper
, testEnvironmentOptions
all disappeared leaving us with a much more streamlined configuration. Various Vite plugins worked OOTB to get us up and running:
vite-plugin-react
handles all the transformation of our source JSX to plain javascript.vite-plugin-magical-svg
replaced our own manual mocks of.svg
files and meant in test we could assert on<img href=".."/>
elements easilyvite-tsconfig-paths
replaced ourmoduleNameMapper
config (usingpathsToModuleNameMapper
fromts-jest
) so Vite could use our tsconfigpaths
to construct correct import URLs.
Breaking up with my favourite Jest extension
We had to say goodbye to jest-when
, a really handy jest extension that makes writing mocks in a given/when/then style very straightforward with jest. Several years ago I would have relied on it a lot more when mocking fetch
, but less so now we have tools like MSW
. Updating to use vi.mock
alone can get a little bit more verbose, but it's nothing unmanageable:
// Before, with jest-when
when(useMediaQuery)
.calledWith('(min-width: 768px)')
.mockReturnValue(true);
// After, with Vitest
vi.mocked(useMediaQuery)
.mockImplementation((query) => {
return query === '(min-width: 768px)'
});
One thing I do miss from jest-when
is the ability to compose multiple when()
statements, or use mockReturnValueOnce()
in mocks that change over the course of the test run. The alternatives are a bit more verbose with vi.mocked()
.
when(useMediaQuery)
.calledWith('(min-width: 768px)').mockReturnValue(true)
.calledWith('(min-width: 1280px)').mockReturnValue(false)
Forgetting about Jest's extended family
jest-when
was just collateral amongst an onslaught of npm uninstall
, as we also removed
jest-watch-typeahead
, since Vitest has watch mode built in (and enabled by default!)ts-jest
, as Vitest will transform typescript to javascript without anytransform
configurationts-node
, sincevitest.config
andvitest.setup
can be written with a.ts
extension- as well as
jest-cli
,jest-css-modules-transform
,jest-environment-jsdom
,babel-jest
and a few@types/
packages
I could also get rid of my jest.polyfills.js
I've previously needed for MSW as Jest meddles with Node.js globals.
One other Jest package we had to remove was jest-next-dynamic
, which we were using to handle Next.js's dynamic imports in tests. Instead, I was able to replace the dependency entirely with this manual approach.
Parallelism problems
Where we used to jest --runInBand
in CI, our tests were running sequentially and were (generally) starting and finishing one after the other. Vitest uses worker threads to isolate tests and run them in parallel, and this means that some tests can start, sit around for a while, and then be picked up again. Even more so it seems that this can influence the wait time for RTL waitFor
and findBy*
queries.
Not having to runInBand
was a massive improvement since now our CI runs would actually utilise 95% of the CPU of our CircleCI executor, so I really didn't want us to opt out of this. Instead, I increased our RTL timeout in vitest.setup.ts
from 1 to 5 seconds, which fixed these timeouts but didn't wait too long for actual failures.
// Configure RTL
configure({
// increase the timeout of waitFor and findBy queries to 5secs (default 1sec)
// vitest runs tests in parallel and this means they are more prone to individual timeouts
asyncUtilTimeout: 5000,
});
A more perplexing issue was that often Vitest would run all our suites and be unable to exit successfully. I ran using the hanging process reporter and found there were several file handles left open:
This led me to this github issue and ultimately the problem seemed to be with undici, which was solved by using pool: forks
in vitest.setup.ts
instead of the default threads
. From docs, there are similar except forks 'uses child_process instead of worker_threads via tinypool'
The results
To be brief:
- The time to run all our tests in CI dropped from 10 to 3 minutes, and the time to run tests locally reduced by a similar magnitude
- We've seen a significant reduction in how flakey our suites are in CI
- We've massively reduced how much config is required for our tests
- We've not had any discrepancies that could be attributed to us using Vitest for tests without Vite for build
- Watch mode is much faster than Jest's - this could be considered subjective, but I actually want to use watch mode now, rather than viewing it as a waste of CPU resource before