Skip to content

Migrate remaining jest tests to vitest - #1608

Open
zetter-rpf wants to merge 16 commits into
mainfrom
migrate-even-more-jest-tests
Open

Migrate remaining jest tests to vitest#1608
zetter-rpf wants to merge 16 commits into
mainfrom
migrate-even-more-jest-tests

Conversation

@zetter-rpf

@zetter-rpf zetter-rpf commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

This follows on from #1570 and #1607 and migrates the remaining tests to vitest.

  • These tests required setting up a more complex mock for the worker.
  • This also removes jest from the repo

See commits for more

As doing this I think there are a few things we should do after:

  • Install and configure babel vitest
  • Understand if we need babel-eslint and look at removing it
  • Fix any warnings coming from vitest

Changes made with Claude

Previously ScratchContainer.test.jsx only ran under Jest, blocked by
the window.localStorage gap fixed in an earlier commit.

This change swaps jest.fn/jest.mock/jest.clearAllMocks/
jest.useFakeTimers/jest.useRealTimers/jest.advanceTimersByTime for
their vi equivalents and jest.requireActual for the async
vi.importActual pattern.

The test used require("./ScratchContainer") instead of a static
import specifically so the module loaded after the overlayscrollbars
mocks were set up - ScratchContainer.jsx calls
OverlayScrollbars.plugin() at module scope, not just inside the
component. Vitest does not resolve a bare require() the way Jest
does, so this switched to a static import, which surfaced the same
ordering problem in ESM form: vi.mock is hoisted above the import,
but the mock factories referenced mockOverlayScrollbarsComponent and
mockPlugin, plain consts declared below the (also hoisted) mocks,
so ScratchContainer.jsx ran into them before initialization. Moved
those declarations into a vi.hoisted() block, which Vitest hoists to
the same position as vi.mock, resolving the ordering.

Confirmed via full Jest and Vitest runs: 176 Jest + 875 Vitest tests
pass, the same total as before the move.
Previously HtmlRunner.test.jsx only ran under Jest, blocked by the
window.localStorage gap fixed in an earlier commit (HtmlRunner.jsx
calls localStorage.setItem).

This change swaps jest.spyOn for vi.spyOn, and jest.requireActual for
the async vi.importActual pattern for both the react-responsive mock
and the node-html-parser mock. The latter also called
jest.requireActual("node-html-parser") a second time, synchronously,
inside a later beforeEachs mockImplementation callback - not possible
with vi.importActuals async API, so that beforeEach now reuses a
single actualParseHtml reference resolved once via a top-level await
vi.importActual(...) instead of re-requiring the real module on every
render.

Confirmed via full Jest and Vitest runs: 158 Jest + 893 Vitest tests
pass, the same total as before the move.
Previously PythonRunner.test.jsx only ran under Jest. It has no
Jest-only APIs itself, but rendering it crashed under Vitest because
PyodideRunner.jsx constructs `new Worker(...)` on mount and Vitests
jsdom environment, unlike jest-environment-jsdom, does not define a
global Worker. setupTests.vitest.js now defines a minimal stub
Worker class, matching the window.Worker = PyodideWorker stub
setupTests.js already installs for Jest via
PyodideWorker.mock.js, which fixes this for any test that mounts a
component constructing a Worker, not just this one.

Confirmed via full Jest and Vitest runs: 144 Jest + 907 Vitest tests
pass, the same total as before the move.
Previously PyodideRunner.test.jsx only ran under Jest. Rendering it
needs window.Worker, since PyodideRunner.jsx constructs one on mount,
and it also imports the shared PyodideWorker.mock.js directly to
track and drive worker instances via getLastInstance() -
that mock file uses jest.fn(), so it does not work under Vitest.
Added PyodideWorker.mock.vitest.js as its vi.fn()-based counterpart,
used by this test.

Rather than installing window.Worker globally in
setupTests.vitest.js as first attempted, both this file and
PythonRunner.test.jsx (migrated previously) now set it themselves,
right where its needed - Vitest gives each test file its own fresh
window, so a module-scope assignment cant leak into other test
files, and nothing here reassigns it between tests, so no
before/after hook is needed either.

The "When friendly errors are enabled" tests used
require("@raspberrypifoundation/python-friendly-error-messages")
inside a beforeEach specifically to read back the (Jest-)global-mocked
functions without going through ESM import hoisting. Vitest cant
resolve that packages real dist build via require any better than via
import, so this switched to a static import of the same three
functions, which Vitests hoisted vi.mock in setupTests.vitest.js
already intercepts.

Confirmed via full Jest and Vitest runs: 101 Jest + 950 Vitest tests
pass, the same total as before the move.
Previously SkulptRunner.test.jsx only ran under Jest.

This change swaps jest.fn/jest.spyOn for vi equivalents and
jest.requireActual for the async vi.importActual pattern for the
react-responsive mock. As with PyodideRunner, the "When friendly
errors are enabled" tests used
require("@raspberrypifoundation/python-friendly-error-messages")
inside a beforeEach to read back the global-mocked functions; that
require call is replaced with a static import of the same three
functions, resolved via the hoisted vi.mock in setupTests.vitest.js.

Confirmed via full Jest and Vitest runs: 42 Jest + 1009 Vitest tests
pass, the same total as before the move.
Previously VisualOutputPane.test.jsx only ran under Jest.

This change swaps jest.mock for vi.mock, and adds a local vi.mock for
plotly.js (also wrapped with a default key, since the component
imports it as a default and Vitest does not apply Jests CJS
default-export interop to mock factories) rather than relying on
setupTests.js global plotly.js mock, which has no Vitest
counterpart - this component is the only place plotly.js is imported,
so the mock belongs with the test that needs it rather than in
shared setup.

"it applies the attributes within turtle" asserted a style value of
color: "red" (with literal quotes) applied via node.setAttribute in
elementFromProps, then re-parsed through innerHTML. Vitests newer
jsdom/cssstyle rejects that malformed color value as invalid CSS and
drops the declaration entirely, where Jests older jsdom apparently
tolerated it - fixed the fixture to the valid color: red, and updated
the assertion to color: rgb(255, 0, 0), which is what jest-dom
normalizes a parsed color attribute to.

Confirmed via full Jest and Vitest runs: 30 Jest + 1021 Vitest tests
pass, the same total as before the move.
Previously PyodideWorker.test.jsx only ran under Jest, per the
earlier note that it dynamically re-imports the worker script per
test via resetModules but TextEncoder broke post-reset.

This change swaps jest.fn for vi.fn and jest.resetModules() for
vi.resetModules(), and switches the per-test require() of
PyodideWorker.js (needed to get a fresh module each test, since the
script assigns globalThis.PyodideWorker as a side effect and closes
over per-worker state) to an awaited dynamic import() in an async
beforeEach, since Vitest does not support a bare require() the way
Jest does.

The actual TextEncoder break was unrelated to resetModules: the test
imported TextEncoder from "util" and assigned it to
global.TextEncoder, but that named import resolves to undefined under
Vitest, so the file was really assigning global.TextEncoder =
undefined. Removed the import and the reassignment entirely -
TextEncoder is already a real global under Vitests jsdom environment
(Node has provided it as a global since v11), so the tests remaining
`new TextEncoder()` calls resolve correctly on their own.

Confirmed via full Jest and Vitest runs: 8 Jest + 1043 Vitest tests
pass, the same total as before the move.
Previously Project.test.jsx only ran under Jest, per the earlier note
that its module-level jest.useFakeTimers() produced an empty render
under Vitest.

This change swaps jest.fn/jest.mock for vi equivalents and
jest.requireActual for the async vi.importActual pattern for the
react-router-dom mock. The useFakeTimers() call itself is dropped
entirely: nothing in the file ever advances or asserts against fake
time, and the one test using waitFor() (for a project-saved message
dispatched from a plain useEffect, not a timer) hung indefinitely
under Vitest with fake timers active, since testing-library dom
waitFor only knows how to drive Jests fake timers forward - it
detects them by checking for a global `jest`, so under Vitest it
falls back to real timers for its own polling, which fake timers had
already intercepted.

This is the last file on the Jest list: test-runner-migration.js
JEST_ONLY_TEST_FILES is now empty, so every test in the repo runs
under Vitest. An empty array made jest.config.js testMatch fall back
to Jests own defaults and match all 267 test files instead of none,
so testMatch now matches an always-empty path once the list is
empty. yarn test now exits 1 with "no tests found" rather than
running the whole suite under the wrong runner; whether to add
--passWithNoTests or retire the Jest CI step and config entirely is
still an open follow-up.

Confirmed via full Vitest run: 1051 tests pass, the same total as
before the move.
@zetter-rpf
zetter-rpf temporarily deployed to previews/1608/merge August 24, 2026 07:53 — with GitHub Actions Inactive
Previously the repo ran tests under Jest via jest.config.js,
config/jest/babelTransform.js, scripts/test.js and
src/utils/setupTests.js, kept alive alongside Vitest by
test-runner-migration.js listing any files not yet ported. That list
is now empty, so nothing exercises the Jest path any more, and
.babelrc (only read by the Jest babel transform) and
PyodideWorker.mock.js (only imported by the removed Jest setup file)
were dead weight too.

This change deletes those Jest-only files and drops
vite.config.js/setupTests.vitest.js references to the now-removed
test-runner-migration.js, leaving Vitest as the sole test runner.
Previously "yarn test" shelled out to Jest (scripts/test.js, now
removed) and CI ran both a Jest pass and a separate Vitest pass, with
Jest's "--coverage" output feeding a "Record coverage" step that
posts the clover coverage percentage as a PR comment. Vitest's CI
step never passed "--coverage" and no @vitest/coverage-* provider is
installed, so that comment was already effectively dead code, only
still fed by the Jest run. Nothing in CI enforces a coverage
threshold or otherwise checks this output; it was purely informational
and nobody currently relies on it, so dropping it removes no active
guarantee.

This change makes "yarn test" run Vitest directly (watch mode
locally, single run in CI, matching the previous Jest behaviour),
drops the now-redundant "test:vitest"/"test:all" scripts, and removes
the separate Jest CI step, the "Record coverage" step and its script,
and the JEST_JUNIT_OUTPUT_DIR env var. The VS Code task and
dev container extension/setting for running the current test file are
updated to invoke Vitest instead of Jest.

Alternatively we could have kept the coverage PR comment by adding
@vitest/coverage-v8 and clover reporter config, but that is a new
feature rather than a like-for-like removal, so it is left for a
separate change if wanted.
Previously .eslintrc.json extended "react-app/jest", which pulled in
eslint-plugin-jest for the "jest" globals env (describe/it/expect/etc)
and a subset of eslint-plugin-testing-library rules on test files. With
Jest removed there is no reason to keep eslint-plugin-jest installed,
but dropping it blindly would leave every Vitest test file failing
"no-undef" on those globals and lose the testing-library lint
coverage.

This change removes the "react-app/jest" extend and the "jest" eslint
plugin, declares the Vitest globals (describe/it/test/expect/
beforeEach/afterEach/beforeAll/afterAll) explicitly on the existing
test-file overrides, and swaps in "plugin:testing-library/react" on
those same overrides so the equivalent testing-library rules still
apply. "yarn lint" is clean with no new warnings.
Previously Jest and its supporting packages (jest, jest-circus,
jest-environment-jsdom, jest-css-modules-transform,
jest-scss-transform, jest-transform-stub, jest-transformer-svg,
jest-watch-typeahead, jest-canvas-mock, jest-junit,
jest-github-actions-reporter, babel-jest, eslint-plugin-jest) stayed
installed to run the Jest half of the test suite. All tests now run
on Vitest and the Jest config/scripts were removed in earlier
commits, so these are unused.

This change removes them via "yarn remove" and updates yarn.lock.
"jest-axe" and "@testing-library/jest-dom" are kept: despite the
name, they are matcher libraries used directly in test files and work
independently of which test runner executes them.

"yarn lint", "yarn test --run" and "yarn build" all still pass.
Previously README.md and AGENTS.md (CLAUDE.md) described the project
as testing on Jest, documented the old combined Jest+Vitest CI
command with Jest-specific coverage/reporter flags, and listed
config/ as holding Jest config. Jest has now been fully removed and
config/ only holds the nginx deploy template.

This change updates both docs to describe Vitest as the test runner,
simplifies the documented CI test command to "yarn test --run", and
corrects the config/ directory description.
Previously a comment in scratchblocksLocales.js attributed the
trimmed-locale-list approach to avoiding "jest module import
errors", and a comment in InstructionsPanel.test.jsx referenced
jest's `resetMocks: true` clearing a mocked implementation
per-test. Both files run under Vitest now, and vite.config.js sets
the equivalent `mockReset: true` option.

This change updates both comments to name Vitest and its
`mockReset` option instead of Jest.
Tried dropping all Babel-related dependencies, since @babel/core,
babel-eslint and babel-preset-react-app looked like they might only
be needed to support Jest's babel-jest transform (now removed).

@babel/core and babel-preset-react-app check out: nothing besides the
deleted .babelrc referenced them, and lint/tests/build all stay green
without them.

babel-eslint does not: eslint-config-react-app's base config hard-
codes "parser: babel-eslint", and our own top-level .eslintrc.json
parser must override that to have any effect. Swapping in the
built-in espree parser fails outright on the ES2021/2022 syntax
already in use (numeric separators, uninitialized class fields,
top-level await), throwing parse errors. Swapping in a newer
standalone espree (9.6.1) parses that syntax fine, but ESLint 7's
bundled eslint-scope/no-undef predate the PropertyDefinition AST node
for class fields, so it wrongly flags every class field as
"not defined" - a new dependency added to replace one still being
removed, while also not being clean. babel-eslint stays.
Previously the only editor task ran every test in the current file.
Vitest supports filtering to a single test by appending ":<line>" to
the file path, so a test under the cursor can be run without pasting
its name into -t or waiting on the whole file.

This change adds a "Test Current Line (Vitest)" task using VS Code's
built-in "${lineNumber}" variable.
@zetter-rpf
zetter-rpf marked this pull request as ready for review August 24, 2026 08:56
@zetter-rpf zetter-rpf changed the title Migrate even more jest tests Migrate remaining more jest tests Aug 24, 2026
@zetter-rpf zetter-rpf changed the title Migrate remaining more jest tests Migrate remaining jest tests to vitest Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant