Skip to content

test: run the UI suite on Vitest instead of Jest - #914

Merged
cigamit merged 1 commit into
ctrliq:mainfrom
blaipr:build/move-ui-tests-to-vitest
Sep 10, 2026
Merged

test: run the UI suite on Vitest instead of Jest#914
cigamit merged 1 commit into
ctrliq:mainfrom
blaipr:build/move-ui-tests-to-vitest

Conversation

@blaipr

@blaipr blaipr commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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.json ports across to vitest.config.mjs as a block, as the thread expected. Two keys have no counterpart: modulePaths and moduleNameMapper, because Vite has no module search path, so every absolute import out of src becomes a resolve alias.

The thread predicted the work would be in the shims. That part was easy: 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 somewhere else entirely.

The transform, which is the part worth reviewing

@vitejs/plugin-react is 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 every t macro in the 486 files that import one would have gone through untransformed. So the babel pass that lived in config/jest/babelTransform.js is ported by hand instead.

@babel/preset-env is deliberately not carried over. It only ever targeted the running node, and under babel-jest it 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:

what where why jest was fine with it
require() inside a mock factory 108 calls, 45 files ESM has no synchronous equivalent, so each becomes await vi.importActual and the factory becomes async
a factory returning a bare component 45 sites jest treats the return as module.exports and interop hands it back as the default; vitest wants a module namespace
vi.mock nested inside beforeEach 2 files vitest refuses it, because mocks are hoisted and the apparent position would misrepresent when it runs
tests that assume the tree has settled 4 sites the input existing is not the same as the step having finished mounting, and jest's scheduling happened to settle first

That 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 as isolate: 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:

  • A test that navigates left the shared window on its own URL, so the next file's router started from there. This is why the failures were always redirect tests, and a different one on each run.
  • Login.test.js replaced window.localStorage with a two-method mock and never put it back, so every later file in that worker would have inherited a getItem that always returns '42'.

setupTests.js now resets url, storage and cookies after every file, and Login.test.js restores what it replaces. Measured on one machine, full suite, 8 cores:

jest, warm (its own transform cache)   8m45s
jest, cold (what CI always is)         ~16m
vitest                                 3m36s to 4m52s

Measured and rejected

So nobody repeats them, the numbers are in the roadmap:

  • happy-dom, the usual advice, is slower here and fails 19 tests, in form submission, dropdowns and menu navigation. The public benchmarks measure environment construction on small components; this suite is deep PatternFly trees and heavy querying, which is where jsdom's nwsapi selector engine wins.
  • deps.optimizer.web costs 7%. vmThreads already amortises imports across a worker's files.
  • maxThreads at 4 and at 12 are both worse than the default 8.
  • fsModuleCache helps 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

  • Full suite green on this exact tree, rebased onto 7a0293b928: 553 files, 2985 tests.
  • Repeated to prove it is deterministic rather than lucky, since the pre-fix failures moved between runs. Four consecutive clean full runs.
  • npm run prettier-check and npx eslint . both clean.
  • make ui-test, ui-test-screens and ui-test-general keep their names and their split.
  • The built application was loaded in a browser against the running stack, signed in, and the dashboard rendered with no console errors, no failed requests and no CSP violations. That is a check on the suite's premise rather than on the suite: it confirms the source these tests exercise still runs.

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 cigamit self-assigned this Sep 10, 2026
@cigamit cigamit added the enhancement New feature or request label Sep 10, 2026
@cigamit
cigamit merged commit ab5300e into ctrliq:main Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Development

Successfully merging this pull request may close these issues.

2 participants