Migrating to Rspack and Vitest
This guide walks you through swapping a frontend module's bundler from Webpack to Rspack (opens in a new tab) and its test runner from Jest to Vitest (opens in a new tab). In the single-package OpenMRS repos the two migrations have typically shipped as adjacent PRs, so this guide treats them as a single piece of work. (The monorepos took them on at different times — see the Monorepo considerations section.)
If you haven't yet upgraded your module to Core v6, do that first. See Migrating to Core v6. The Core v6 guide leaves your module on Jest and Webpack on purpose; this guide picks up where it leaves off.
This guide is based on the (chore) Migrate to rspack (opens in a new tab) and (chore) Migrate from jest to vitest (opens in a new tab) PRs in openmrs-esm-laboratory-app. If you'd rather work from a real diff than this checklist, those two PRs are a good template.
Why migrate
Why Rspack instead of Webpack
- Faster builds. Rspack is Rust-based. The Core v6 migration notes report roughly 3× faster app build times in practice and sub-one-minute builds where Webpack was several minutes. Watch-mode incremental rebuilds are also noticeably tighter.
- Reliable sequential builds. Core v5's Webpack pipeline ran into memory exhaustion on repeated builds — see the Core v6 migration guide for the original incident. Rspack doesn't have that pathology.
- Webpack-compatible config. Rspack accepts Webpack's plugin and configuration shape, so the migration is mechanical rather than a rewrite.
- Native ES module support. Rspack handles the framework's ESM exports without the custom transform plumbing Webpack needed.
Why Vitest instead of Jest
- Native ES module support. Core v6 ships the framework as ES modules; Jest has historically required
transformIgnorePatterns, customtransformrules, and dual-mock files (mock.tsxvsmock-jest.tsx) to cope. Vitest reads ESM natively and most of that scaffolding goes away. - Faster test execution. Vitest runs on Vite's transform pipeline and shares dependency-graph caches between runs, so watch mode in particular is dramatically faster than Jest's.
- Alignment with the framework.
openmrs-esm-frameworkitself was migrated to Vitest inopenmrs-esm-core#1591(opens in a new tab). Apps on Vitest share the same mocking, configuration, and CI behaviour as the framework they depend on. - Better developer ergonomics. Clearer stack traces, type-checked
vi.mocked(fn), sub-second watch turnaround, and built-in coverage via@vitest/coverage-v8without an extra Jest reporter.
Why do them together
The two migrations are independent in principle, but in practice they ride together because:
- Both are pure tooling swaps with no functional impact on the app.
- Reviewers tend to look at them as a single "modernise the toolchain" change.
- Rspack uses Webpack's plugin and config format, so the config diff is small; pairing it with the larger Vitest change keeps PR count down.
You can land them as one PR or two — openmrs-esm-laboratory-app shipped them as two adjacent PRs, which is easier to review.
Part 1: Webpack to Rspack
The OpenMRS CLI ships a default Rspack config (openmrs/default-rspack-config) that mirrors the default Webpack config. Most apps only need to rename the config file and update their scripts.
Rename the config file
Rename webpack.config.js to rspack.config.js and require the new default:
module.exports = require("openmrs/default-rspack-config");If your app passes overrides to the default config, the override shape is unchanged — Rspack accepts Webpack's configuration format.
Update package.json scripts
Replace webpack with rspack in build, serve, and analyze:
"scripts": {
"start": "openmrs develop",
- "serve": "webpack serve --mode=development",
- "build": "webpack --mode production",
- "analyze": "webpack --mode=production --env analyze=true",
+ "serve": "rspack serve --mode=development",
+ "build": "rspack --mode production",
+ "analyze": "rspack --mode=production --env analyze=true",
}start stays on openmrs develop. The OpenMRS CLI auto-detects which bundler to run by looking at the module's config files: if rspack.config.js is present (and webpack.config.js is gone) it starts an Rspack dev server, otherwise it falls back to Webpack. Renaming the config file is therefore what flips yarn start over to Rspack — no flag is needed. (You can force the choice explicitly with openmrs develop --use-rspack or --use-rspack=false.)
Drop the Webpack type dependency
Remove @types/webpack-env from devDependencies. If your code uses require.context (typically for dynamic translation imports), declare the types locally instead. Add this to src/declarations.d.ts:
declare interface RequireContext {
keys(): string[];
(id: string): unknown;
<T>(id: string): T;
resolve(id: string): string;
id: string;
}
declare namespace NodeJS {
interface Require {
context(directory: string, useSubdirectories?: boolean, regExp?: RegExp, mode?: string): RequireContext;
}
}Verify
yarn install
yarn build
yarn startyarn build should complete noticeably faster than the equivalent Webpack run, and yarn start should serve your module as before.
Part 2: Jest to Vitest
Vitest is API-compatible with Jest for most everyday usage, but the configuration shape and a few imports are different. Plan on touching package.json, vitest.config.ts, your setup-tests file, and each test file (mechanical jest.* → vi.* rewrites).
Swap dependencies
Remove the Jest packages and add the Vitest equivalents:
- "@swc/jest": "^0.2.26",
- "@testing-library/dom": "^8.20.0",
- "@testing-library/jest-dom": "^5.16.5",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.8.0",
- "@types/jest": "^28.1.8",
+ "@vitest/coverage-v8": "^4.1.2",
+ "cross-env": "^10.1.0",
- "jest": "^28.1.3",
- "jest-cli": "^28.1.3",
- "jest-environment-jsdom": "^28.1.3",
+ "jsdom": "^28.0.0",
+ "vitest": "^4.1.2",A few notes on the version pins:
@testing-library/jest-dommust be on^6.x. Earlier versions don't expose the/vitestsubpath import you'll need insetup-tests.ts.@testing-library/domjumps from 8.x to 10.x as a general modernisation alongside thejest-dom@6upgrade. The two don't have a strict peer-dependency relationship —jest-dom@6declares no peers — but bumping both at once is the path most OpenMRS apps have taken.cross-envis used to setTZ=UTCfor the test scripts (Vitest does not set this for you, and time-sensitive tests will be flaky without it).
Update the test scripts
- "test": "jest --config jest.config.js --passWithNoTests --color",
- "coverage": "yarn test -- --coverage",
+ "test": "cross-env TZ=UTC vitest run --passWithNoTests",
+ "test:watch": "cross-env TZ=UTC vitest watch",
+ "coverage": "cross-env TZ=UTC vitest run --coverage --passWithNoTests",Add vitest.config.ts
Create vitest.config.ts at the repo root:
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const r = (relativePath: string) => fileURLToPath(new URL(relativePath, import.meta.url));
export default defineConfig({
resolve: {
alias: [{ find: /^.*\.s?css$/, replacement: "identity-obj-proxy" }],
},
test: {
environment: "jsdom",
globals: true,
clearMocks: true,
setupFiles: ["./tools/setup-tests.ts"],
exclude: ["**/node_modules/**", "**/e2e/**", "**/dist/**"],
server: {
deps: {
inline: [/@openmrs/],
},
},
fakeTimers: {
toFake: [
"setTimeout",
"clearTimeout",
"setInterval",
"clearInterval",
"setImmediate",
"clearImmediate",
"requestAnimationFrame",
"cancelAnimationFrame",
"Date",
],
},
alias: {
"@openmrs/esm-framework/src/internal": "@openmrs/esm-framework/mock",
"@openmrs/esm-framework": "@openmrs/esm-framework/mock",
"react-i18next": r("./__mocks__/react-i18next.js"),
},
},
});A few things worth understanding rather than copy-pasting blindly:
environment: 'jsdom'. This is the dominant choice across the migrated O3 modules (laboratory-app, patient-chart, patient-management, form-builder, billing-app, and a handful of others all use it). The alternative is'happy-dom', which is faster but has narrower DOM API coverage (notably less complete layout, measurement, and CSSOM APIs).openmrs-esm-coremixes both (some packages on happy-dom, pure-logic packages on'node'), andopenmrs-esm-template-appdefaults to happy-dom — worth flagging because anyone scaffolding a new module from the template inherits happy-dom by default. When in doubt, start with jsdom (matches the bulk of the ecosystem); switch to happy-dom only if test runtime becomes a real bottleneck and you've confirmed your tests don't depend on a DOM API happy-dom doesn't implement.- CSS aliasing. The regex
^.*\.s?css$/anchors both ends so the whole import path is replaced withidentity-obj-proxy. Without the anchors, only the extension gets matched and Vitest fails to resolve the module. fileURLToPathfor path aliases. Usingnew URL(...).pathnamedirectly works on macOS and Linux, but on Windows it yields/C:/..., which Vite's resolver rejects.fileURLToPathnormalises this.server.deps.inline: [/@openmrs/]. Forces Vite to process@openmrs/*packages through its transform pipeline instead of treating them as external. Without this, ES module exports from the framework won't resolve correctly in tests.fakeTimers.toFake. This is a narrower, OpenMRS-tested list, not the Vitest default. Vitest's default fakes every timer-like API sinon detects (typically includingrequestIdleCallback,cancelIdleCallback,performance, andprocess.hrtime) minusnextTickandqueueMicrotask. The list above sticks to the timers actual OpenMRS tests need, which keeps the surface area smaller. You can omitfakeTimersentirely if you're happy with the broader default — neither choice breaks React's scheduler, because both keepqueueMicrotaskandnextTickout.- Framework mock alias. Both the public and the internal entry points map to
@openmrs/esm-framework/mock. Tests that import from the internal path (e.g.@openmrs/esm-framework/src/internal) get the same mock. react-i18nextalias. Jest auto-discovered__mocks__/react-i18next.jsvia convention; Vitest does not, so the alias is what reconnects it. If your module's i18n mock disappears after the migration, this is almost always why.excludeand e2e. Playwright specs typically live undere2e/and are run by a separatetest-e2escript. The'**/e2e/**'entry inexcludekeeps Vitest from trying to evaluate them.
Update setup-tests.ts
- import "@testing-library/jest-dom";
+ import "@testing-library/jest-dom/vitest";
+ import { afterEach } from "vitest";
+ import { cleanup } from "@testing-library/react";
+
+ afterEach(cleanup);
// Mock ResizeObserver which is not available in jsdom
global.ResizeObserver = class ResizeObserver {
// ...
};The /vitest subpath import wires the DOM matchers into Vitest's expect. The explicit afterEach(cleanup) is defensive: @testing-library/react auto-registers cleanup whenever a global afterEach is available (which is true under Vitest with globals: true), so the manual hook is redundant in practice. It's kept because it's the established pattern across the OpenMRS-org repos and protects you if a package later sets globals: false or if RTL_SKIP_AUTO_CLEANUP=true ever creeps into the environment.
Setup-file location varies across modules: tools/setup-tests.ts is the most common spot, but you'll also see src/setup-tests.ts and test/setup.ts. Whatever path your module already uses, keep it and just update the import line — only the setupFiles entry in vitest.config.ts needs to know where it lives.
Convert each test file
For each *.test.ts(x) file:
-
Add an explicit import from
vitest. Even thoughglobals: trueis set, explicit imports give you better autocomplete and stricter typing:+ import { beforeEach, describe, expect, it, vi } from "vitest";Only import the symbols the file actually uses.
-
Replace the Jest API calls:
Jest Vitest jest.fn()vi.fn()jest.mock(...)vi.mock(...)jest.mocked(...)vi.mocked(...)jest.spyOn(...)vi.spyOn(...)jest.requireActual(...)vi.importActual(...)(note: now async)jest.useFakeTimers()vi.useFakeTimers()The first four are pure search-and-replace.
requireActualis the one to watch — it's synchronous in Jest but async in Vitest, so the surrounding factory function needs to becomeasyncand the call needs to beawaited.For type assertions on mocks, prefer
vi.mocked(fn)over manual casts wherever you can — it preserves the signature of the original function automatically. If you do need a type by hand,jest.MockedFunction<typeof fn>maps toMockedFunction<typeof fn>fromvitest. Barejest.Mock<R, A>does not translate directly: Vitest'sMocktakes a single function-type parameter (Mock<T extends Procedure | Constructable>), so the equivalent isMock<(...args: A) => R>— or, again, justvi.mocked(fn). -
If the file uses
jest.mock("@openmrs/esm-framework", () => ({ ...jest.requireActual(...), ... }))to partially override the framework mock, the equivalent in Vitest is:vi.mock("@openmrs/esm-framework", async () => { const actual = await vi.importActual<typeof import("@openmrs/esm-framework")>("@openmrs/esm-framework"); return { ...actual, useConfig: vi.fn(), }; });
Delete jest.config.js
Once everything passes, delete jest.config.js. The __mocks__/ folder usually stays — most files in it are still referenced via the alias entries in vitest.config.ts (e.g. react-i18next.js). Only remove a __mocks__/ file if nothing in vitest.config.ts or your test files points at it any more.
Verify
yarn install
yarn test
yarn typescript
yarn lintAll four should be green. Run yarn test:watch to confirm the watch mode works too.
Common pitfalls
- Tests pass locally but fail in CI on Windows. Almost always a path-alias issue. Use
fileURLToPathfor any alias that resolves to a filesystem path. See thevitest.config.tssnippet above. expect(...).toBeInTheDocument is not a function. You forgot the/vitestsubpath on thejest-domimport insetup-tests.ts, or@testing-library/jest-domis still on 5.x.Cannot find module '@openmrs/esm-framework/mock'. Either your@openmrs/esm-frameworkversion predates the dual-mock layout (see Migrating to Core v6) or yourvitest.config.tsalias is misspelled.- React tests fail with cryptic scheduler errors after enabling fake timers. You likely added
queueMicrotaskornextTicktofakeTimers.toFakeby hand — both break React's scheduler. Vitest's defaulttoFakealready excludes them, so leavingfakeTimersunset is usually the right move; if you do supply an explicit list, mirror the one in the config snippet above (noqueueMicrotask, nonextTick). @openmrs/*imports resolve to the built CJS rather than ESM. Addserver.deps.inline: [/@openmrs/]to the Vitest config.- TypeScript complains that
require.contextdoesn't exist after dropping@types/webpack-env. Rspack supportsrequire.contextat runtime exactly the same way Webpack does, so the runtime call site is fine — what disappeared is the type definition that used to come from@types/webpack-env. Add theRequireContextdeclaration from Part 1 tosrc/declarations.d.tsto restore the types.
Monorepo considerations
If you're migrating a Turbo-based monorepo (e.g. openmrs-esm-core, openmrs-esm-patient-chart, openmrs-esm-patient-management), the steps above apply per package, with a handful of additional concerns:
-
Config layout varies — pick one pattern. Two patterns exist across the OpenMRS monorepos:
- Per-package configs (
openmrs-esm-core). Packages that need custom Vitest config keep it package-local — most test-bearing packages have their ownvitest.config.ts, but a few (e.g.packages/framework/esm-context) declare avitest runscript and run on pure defaults with no local config. The root just dispatches via Turbo.packages/apps/esm-login-app/vitest.config.tsis a minimal example of the per-package shape. - Single root config (
openmrs-esm-patient-chart,openmrs-esm-patient-management). Onevitest.config.tsat the repo root collects tests across all packages, with shared coverage thresholds and aliases. Packagepackage.jsonfiles just declare"test": "vitest run"and inherit everything else.
Both work — pick whichever matches the rest of your tooling (per-package if your packages already differ heavily, single root if you want one place to manage thresholds and shared mocks).
- Per-package configs (
-
Turbo task inputs. Update the
testtask'sinputsinturbo.jsonso the cache invalidates on Vitest config or setup file changes. The pattern inopenmrs-esm-core/turbo.jsonlistsvitest.config.ts,setup-tests.ts,__mocks__/**,mock.ts, andmock-jest.ts*— mirror that, and drop any leftoverjest.config.jsentry. -
Verify across the workspace. After migrating, run
yarn turbo verify(oryarn turbo lint typescript test) from the repo root to confirm nothing else regressed. -
Workspace-internal
@openmrs/*packages. Theserver.deps.inline: [/@openmrs/]entry already covers workspace packages — Yarn's workspace protocol resolves them undernode_modules/@openmrs/*, and the inline rule forces Vite to transform them rather than treat them as external. No extra config needed. -
Yarn linker. The OpenMRS monorepos use Yarn 4 with
nodeLinker: node-modules(not PnP), so the standard Node resolution rules apply and you don't need any PnP-specific workarounds. The legacytransformIgnorePatterns: ["/node_modules/(?!@openmrs|.+\\.pnp\\.[^\\/]+$)"]in old Jest configs can be deleted outright — the.pnpcarve-out is dead weight on anode-modules-linked workspace. -
Shared mocks across packages. If a per-package
vitest.config.tsneeds to alias to a mock that lives at the repo root or in a sibling package, usefileURLToPath(new URL('../../__mocks__/...', import.meta.url))rather than a bare relative path.import.meta.urlresolves relative to the consuming config, so the alias keeps working no matter where Vitest is invoked from. -
Coverage aggregation. With per-package configs, each package emits its own report and combining them requires Vitest 4's
--merge-reportsfrom the root — see Vitest's merge-reports docs (opens in a new tab). With a single root config, coverage is already global and you get unified thresholds for free (seepatient-chart's andpatient-management's root configs for examples that setstatements/branches/functions/lines: 80).
See the Reference PRs section below for canonical monorepo migrations from openmrs-esm-core, openmrs-esm-patient-chart, and openmrs-esm-patient-management.
Reference PRs
Single-package modules
Both PRs in each pair were reviewed and merged independently:
openmrs-esm-laboratory-app#759(opens in a new tab) — Webpack → Rspackopenmrs-esm-laboratory-app#757(opens in a new tab) — Jest → Vitestopenmrs-esm-form-builder#1199(opens in a new tab) and#1198(opens in a new tab) — same pair, slightly different app shape
Monorepos
openmrs-esm-core (per-package configs):
#1417(opens in a new tab) — Migrate to using Rspack everywhere#1591(opens in a new tab) — Move everything over to Vitest
openmrs-esm-patient-chart (single root vitest.config.ts):
#3104(opens in a new tab) — Migrate to using Rspack#3301(opens in a new tab) — Migrate from jest to vitest
openmrs-esm-patient-management (single root vitest.config.ts):
#2176(opens in a new tab) — bundled migration: also moved all packages to Rspack (note: the PR title is aboutpatient-common-libremoval — the Rspack move is described in the body)#2273(opens in a new tab) — follow-up cleanup: remove directrspackandwebpackdependencies#2532(opens in a new tab) — Migrate from jest to vitest