Hi all,
Companion to Path to Vite, split out because the two moves are independent: the test runner can change without touching how anything is built. This one is Jest to Vitest.
What Jest is
Jest is the test runner the UI suite is written against, supplying the globals, the assertions, module mocking and the jsdom environment the component tests render into. Ascender has 551 test files on Jest 30.4.2, configured inline in package.json rather than in a config file of its own. Jest is maintained and current, so this is about running one toolchain instead of two, not about rescuing an abandoned one.
What Vitest is
Vitest is the test runner built on Vite, reusing the config, plugins and transforms of the application build instead of a parallel babel and jest pipeline. Its API is close enough to Jest's that most spec bodies are untouched.
The move
One runner replaces another, and nothing else in the testing stack moves:
jest 30.4.2 -> Vitest
Five devDependencies are jest's alone: jest, jest-environment-jsdom, @testing-library/jest-dom, jest-watch-typeahead and jest-websocket-mock, with babel-jest bridging to the build.
Worth being straight about what arrives in their place, because it is not a smaller set. Vitest needs Vite, and Vitest 5 needs Vite 8, which replaced esbuild with rolldown. That puts oxc in the tree as native binaries: 16 optional @rolldown/binding-* entries in the lockfile, of which one platform's installs. The 36 devDependencies this whole sequence is meant to remove only go in step three, so step one adds weight before anything is deleted.
The port
551 test files. The jest config lives inline in package.json with roots, setupFiles, setupFilesAfterEnv, testMatch, moduleNameMapper, transform and watchPlugins, and most of it ports across as a block into vitest.config.mjs. Two keys do not: modulePaths and moduleNameMapper have no counterpart, because Vite has no module search path, so every absolute import out of src becomes a resolve alias.
The transform is the one to look at before the shims. @vitejs/plugin-react is the obvious vehicle and is the wrong one: version 6 dropped babel in favour of oxc, and oxc has no lingui macro, so useLingui() and every t macro in the 486 files that import one would go through untransformed. The babel pass has to be ported by hand instead, and @babel/preset-env has to be dropped from it, since under babel-jest it was also rewriting the modules to CommonJS.
The other thing the config cannot cover is require() inside a mock factory, which jest allows and ESM has no equivalent for. It appears 108 times across 45 files.
Then the shims:
jest-websocket-mock needs a Vitest equivalent, or the globals shim to keep the jest API available.
jest-watch-typeahead is a jest watch plugin, and Vitest does its own watch filtering.
@testing-library/jest-dom moves from setupFilesAfterEnv to Vitest's setup files.
@testing-library/react and @testing-library/user-event are runner agnostic and do not move.
Why it goes first
Tests are independent of the bundler, so this lands and proves itself without changing how anything is built. It also de-risks the bundler swap: once the suite runs on Vitest, the Vite move arrives to a test suite that already speaks the same config.
What has to keep working
ui-test, ui-test-screens and ui-test-general in the Makefile shell out to the npm scripts. Those targets keep their names and change what they invoke.
Pull requests
Thanks for reading.