test: run the UI suite on Vitest instead of Jest - #914
Merged
Conversation
Step one of the sequence in the Path to Vitest thread, and the whole of it:
the test runner moves, the bundler does not. 553 files, 2985 tests, all
passing, with no assertion changed.
The jest block in package.json ports across to vitest.config.mjs as the thread
predicted. modulePaths and moduleNameMapper are the two keys that do not:
Vite has no module search path, so every absolute import out of src becomes a
resolve alias.
What the thread expected to be the work, the shims, was the easy part.
jest-websocket-mock has a vitest counterpart, jest-watch-typeahead is
replaced by vitest's own watch filtering, and jest-dom has a /vitest entry.
The real costs were elsewhere.
The transform. @vitejs/plugin-react v6 dropped babel in favour of oxc, and
oxc has no lingui macro, so useLingui() and every t`` in the 486 files that
import one would go through untransformed. The babel pass config/jest
carried is ported by hand instead, minus @babel/preset-env: it only ever
targeted the running node, and under babel-jest it was also rewriting the
modules to CommonJS, which must not happen now that Vite handles ESM.
Four things jest tolerated that vitest does not, each fixed rather than
worked around:
require() inside a mock factory, 108 times across 45 files. ESM has no
synchronous equivalent, so each becomes await vi.importActual and its
factory becomes async.
A factory returning a bare component. jest treats the return value as
module.exports and hands it back as the default; vitest wants a module
namespace. 45 sites.
vi.mock nested inside beforeEach, 2 files. vitest refuses it because
mocks are hoisted and the apparent position would misrepresent when it
runs. jest hoisted it silently.
Tests that assume the tree has settled. The input existing is not the
same as the step having finished mounting, and jest's scheduling happened
to settle first where vitest's does not, so the first interaction after a
render could land on a tree about to be replaced and be lost. Four sites,
each given an explicit await act(async () => {}).
pool: vmThreads is what makes it fast: one jsdom per worker rather than one
per file, which takes the environment share of the run from 26% to 2%.
Module state stays isolated per file, unlike isolate: false, which is faster
still and fails 102 tests.
Sharing the environment exposed two real cross-file leaks that a fresh jsdom
per file had been hiding. A test that navigates left the shared window on its
own URL, so the next file's router started from there, which is why the
failures were always redirect tests and a different one each run.
Login.test.js replaced window.localStorage with a two-method mock and never
put it back. setupTests.js now resets url, storage and cookies after every
file, and Login.test.js restores what it replaced.
Measured on one machine, full suite, 8 cores: jest 8m45s with its transform
cache warm and about 16m cold, which is what CI always is, against 3m36s to
4m52s here.
Also measured and rejected: happy-dom is slower and fails 19 tests,
deps.optimizer.web costs 7%, maxThreads 4 and 12 are both worse than the
default 8, and fsModuleCache helps natively but loses in the container. The
numbers are in the roadmap so nobody has to repeat them.
cigamit
approved these changes
Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Path to Vitest, all of it. The test runner moves and the bundler does not, which is what makes this reviewable on its own: 553 files, 2985 tests, all passing, and not one assertion changed.
The port
The jest block in
package.jsonports across tovitest.config.mjsas a block, as the thread expected. Two keys have no counterpart:modulePathsandmoduleNameMapper, because Vite has no module search path, so every absolute import out ofsrcbecomes a resolve alias.The thread predicted the work would be in the shims. That part was easy:
jest-websocket-mockhas a vitest counterpart,jest-watch-typeaheadis replaced by vitest's own watch filtering, andjest-domhas a/vitestentry. The real costs were somewhere else entirely.The transform, which is the part worth reviewing
@vitejs/plugin-reactis the obvious way to handle JSX and it is the wrong one here: version 6 dropped babel in favour of oxc, and oxc has no lingui macro.useLingui()and everytmacro in the 486 files that import one would have gone through untransformed. So the babel pass that lived inconfig/jest/babelTransform.jsis ported by hand instead.@babel/preset-envis deliberately not carried over. It only ever targeted the running node, and underbabel-jestit was also rewriting the modules to CommonJS, which is the one thing that must not happen now that Vite is handling ESM.Four things jest tolerated
Each of these is a real defect that jest's behaviour hid, fixed rather than worked around:
require()inside a mock factoryawait vi.importActualand the factory becomesasyncmodule.exportsand interop hands it back as the default; vitest wants a module namespacevi.mocknested insidebeforeEachThat last one is the interesting one. The first interaction after a render could land on a tree that was about to be replaced, and simply be lost. In one test four fields are filled and only the first stayed empty, which is what gave it away. Each site now has an explicit
await act(async () => {})where it was previously relying on luck.Speed, and the two leaks that finding it exposed
pool: 'vmThreads'builds one jsdom per worker rather than one per file, which takes the environment share of the run from 26% to 2%. Module state stays isolated per file, so this is not the same trade asisolate: false, which is faster still and fails 102 tests.Sharing an environment exposed two cross-file leaks that a fresh jsdom per file had been hiding, and both are worth having fixed regardless of runner:
Login.test.jsreplacedwindow.localStoragewith a two-method mock and never put it back, so every later file in that worker would have inherited agetItemthat always returns'42'.setupTests.jsnow resets url, storage and cookies after every file, andLogin.test.jsrestores what it replaces. Measured on one machine, full suite, 8 cores:Measured and rejected
So nobody repeats them, the numbers are in the roadmap:
nwsapiselector engine wins.deps.optimizer.webcosts 7%. vmThreads already amortises imports across a worker's files.maxThreadsat 4 and at 12 are both worse than the default 8.fsModuleCachehelps natively and loses in the container, where it writes through a bind mount, so it is left at vitest's default with the measurement recorded in a comment.Checks
7a0293b928: 553 files, 2985 tests.npm run prettier-checkandnpx eslint .both clean.make ui-test,ui-test-screensandui-test-generalkeep their names and their split.