Skip to Content
DocsMigration guidesMigrate to Rspack and Vitest

Migrating to Rspack and Vitest

This guide walks you through swapping a frontend module’s bundler from Webpack to Rspack  and its test runner from Jest to Vitest . 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 and (chore) Migrate from jest to vitest 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, custom transform rules, and dual-mock files (mock.tsx vs mock-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-framework itself was migrated to Vitest in openmrs-esm-core#1591. 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-v8 without 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 default Rspack config lives in @openmrs/rspack-config and mirrors the default Webpack config. Most apps only need to update the bundler dependencies, rename the config file, and update their scripts.

Update the bundler dependencies

Add the Rspack config package and CLI packages. If your module already has @openmrs/webpack-config or a direct webpack dependency, remove those at the same time:

package.json
- "@openmrs/webpack-config": "next", + "@openmrs/rspack-config": "next", - "webpack": "^5.99.9", + "@rspack/cli": "^1.7.10", + "@rspack/core": "^1.7.10",

Keep the openmrs dev dependency. yarn start still runs through openmrs develop.

Rename the config file

Rename webpack.config.js to rspack.config.js and require the new default. The current npm create @openmrs/o3-app@latest scaffold also disables production asset-size hints, because the generic 244 KiB budget is noisy for O3 modules that share framework and Carbon dependencies at runtime:

rspack.config.js
const config = require("@openmrs/rspack-config"); const base = config.default ?? config; const disablePerformanceHints = (cfg) => ({ ...cfg, performance: { ...(cfg.performance ?? {}), hints: false }, }); module.exports = typeof base === "function" ? (...args) => disablePerformanceHints(base(...args)) : disablePerformanceHints(base);

If your app passes overrides to the default config, apply those overrides before exporting the final config. The override shape is unchanged because Rspack accepts Webpack’s configuration format.

Older modules may still use openmrs/default-rspack-config, which is a compatibility export from the openmrs tooling package. Prefer @openmrs/rspack-config for new migrations so the dependency matches the published @openmrs/create-o3-app scaffold.

Update package.json scripts

Replace webpack with rspack in build, serve, and analyze:

package.json
"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:

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 start

yarn 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:

package.json
- "@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", - "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-dom must be on ^6.x. Earlier versions don’t expose the /vitest subpath import you’ll need in setup-tests.ts.
  • @testing-library/dom jumps from 8.x to 10.x as a general modernisation alongside the jest-dom@6 upgrade. The two don’t have a strict peer-dependency relationship — jest-dom@6 declares no peers — but bumping both at once is the path most OpenMRS apps have taken.
  • Set TZ=UTC for deterministic date and time assertions. The current scaffold does this in vitest.config.ts with process.env.TZ = 'UTC'. If your repository already uses cross-env for script-level environment variables, setting TZ=UTC in the test scripts is also fine.

Update the test scripts

package.json
- "test": "jest --config jest.config.js --passWithNoTests --color", - "coverage": "yarn test -- --coverage", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest watch", + "coverage": "vitest run --coverage --passWithNoTests",

Add vitest.config.ts

Create vitest.config.ts at the repo root:

vitest.config.ts
import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; process.env.TZ = "UTC"; 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: [r("./tools/setup-tests.ts")], exclude: ["**/node_modules/**", "**/e2e/**", "**/dist/**"], server: { deps: { inline: [/@openmrs/], }, }, alias: [ { find: /^@openmrs\/esm-framework$/, replacement: "@openmrs/esm-framework/mock" }, { find: "react-i18next", replacement: 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-core mixes both (some packages on happy-dom, pure-logic packages on 'node'). The published @openmrs/create-o3-app scaffold uses jsdom, while the older openmrs-esm-template-app fallback still defaults to happy-dom. 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.
  • process.env.TZ = 'UTC'. Keeps date-sensitive tests deterministic without requiring every test script to wrap Vitest in cross-env TZ=UTC.
  • CSS aliasing. The regex ^.*\.s?css$/ anchors both ends so the whole import path is replaced with identity-obj-proxy. Without the anchors, only the extension gets matched and Vitest fails to resolve the module.
  • fileURLToPath for filesystem paths. Use it for setupFiles and aliases that resolve to local files. Using new URL(...).pathname directly works on macOS and Linux, but on Windows it yields /C:/..., which Vite’s resolver rejects. fileURLToPath normalises 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.
  • Fake timers. Omit fakeTimers by default. Vitest’s default fake timers already avoid nextTick and queueMicrotask, which are the two APIs that would break React’s scheduler. If your existing Jest tests depend on a narrower timer list, add fakeTimers.toFake explicitly, but do not include queueMicrotask or nextTick.
  • Framework mock alias. The public framework entry point maps to @openmrs/esm-framework/mock. If your tests still import from the internal path (@openmrs/esm-framework/src/internal), add a second alias for that path during the migration.
  • react-i18next alias. Jest auto-discovered __mocks__/react-i18next.js via 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.
  • exclude and e2e. Playwright specs typically live under e2e/ and are run by a separate test-e2e script. The '**/e2e/**' entry in exclude keeps Vitest from trying to evaluate them.

Update setup-tests.ts

tools/setup-tests.ts
- import "@testing-library/jest-dom"; + import "@testing-library/jest-dom/vitest"; + import { vi } from "vitest"; // Mock ResizeObserver which is not available in jsdom global.ResizeObserver = class ResizeObserver { // ... };

The /vitest subpath import wires the DOM matchers into Vitest’s expect. React Testing Library auto-registers cleanup whenever a global afterEach is available, which is true under Vitest with globals: true, so the current scaffold does not add a manual afterEach(cleanup) hook. Add one only if a package disables globals or sets RTL_SKIP_AUTO_CLEANUP=true.

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:

  1. Add an explicit import from vitest. Even though globals: true is 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.

  2. Replace the Jest API calls:

    JestVitest
    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. requireActual is the one to watch — it’s synchronous in Jest but async in Vitest, so the surrounding factory function needs to become async and the call needs to be awaited.

    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 to MockedFunction<typeof fn> from vitest. Bare jest.Mock<R, A> does not translate directly: Vitest’s Mock takes a single function-type parameter (Mock<T extends Procedure | Constructable>), so the equivalent is Mock<(...args: A) => R> — or, again, just vi.mocked(fn).

  3. 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 lint

All 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 fileURLToPath for any alias that resolves to a filesystem path. See the vitest.config.ts snippet above.
  • expect(...).toBeInTheDocument is not a function. You forgot the /vitest subpath on the jest-dom import in setup-tests.ts, or @testing-library/jest-dom is still on 5.x.
  • Cannot find module '@openmrs/esm-framework/mock'. Either your @openmrs/esm-framework version predates the dual-mock layout (see Migrating to Core v6) or your vitest.config.ts alias is misspelled.
  • React tests fail with cryptic scheduler errors after enabling fake timers. You likely added queueMicrotask or nextTick to fakeTimers.toFake by hand — both break React’s scheduler. Vitest’s default fake timers already exclude them, so leaving fakeTimers unset is usually the right move. If you do supply an explicit list, keep both APIs out.
  • @openmrs/* imports resolve to the built CJS rather than ESM. Add server.deps.inline: [/@openmrs/] to the Vitest config.
  • TypeScript complains that require.context doesn’t exist after dropping @types/webpack-env. Rspack supports require.context at 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 the RequireContext declaration from Part 1 to src/declarations.d.ts to 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 own vitest.config.ts, but a few (e.g. packages/framework/esm-context) declare a vitest run script and run on pure defaults with no local config. The root just dispatches via Turbo. packages/apps/esm-login-app/vitest.config.ts is a minimal example of the per-package shape.
    • Single root config (openmrs-esm-patient-chart, openmrs-esm-patient-management). One vitest.config.ts at the repo root collects tests across all packages, with shared coverage thresholds and aliases. Package package.json files 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).

  • Turbo task inputs. Update the test task’s inputs in turbo.json so the cache invalidates on Vitest config or setup file changes. The pattern in openmrs-esm-core/turbo.json lists vitest.config.ts, setup-tests.ts, __mocks__/**, mock.ts, and mock-jest.ts* — mirror that, and drop any leftover jest.config.js entry.

  • Verify across the workspace. After migrating, run yarn turbo verify (or yarn turbo lint typescript test) from the repo root to confirm nothing else regressed.

  • Workspace-internal @openmrs/* packages. The server.deps.inline: [/@openmrs/] entry already covers workspace packages — Yarn’s workspace protocol resolves them under node_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 legacy transformIgnorePatterns: ["/node_modules/(?!@openmrs|.+\\.pnp\\.[^\\/]+$)"] in old Jest configs can be deleted outright — the .pnp carve-out is dead weight on a node-modules-linked workspace.

  • Shared mocks across packages. If a per-package vitest.config.ts needs to alias to a mock that lives at the repo root or in a sibling package, use fileURLToPath(new URL('../../__mocks__/...', import.meta.url)) rather than a bare relative path. import.meta.url resolves 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-reports from the root — see Vitest’s merge-reports docs . With a single root config, coverage is already global and you get unified thresholds for free (see patient-chart’s and patient-management’s root configs for examples that set statements/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:

Monorepos

openmrs-esm-core (per-package configs):

  • #1417 — Migrate to using Rspack everywhere
  • #1591 — Move everything over to Vitest

openmrs-esm-patient-chart (single root vitest.config.ts):

  • #3104 — Migrate to using Rspack
  • #3301 — Migrate from jest to vitest

openmrs-esm-patient-management (single root vitest.config.ts):

  • #2176 — bundled migration: also moved all packages to Rspack (note: the PR title is about patient-common-lib removal — the Rspack move is described in the body)
  • #2273 — follow-up cleanup: remove direct rspack and webpack dependencies
  • #2532 — Migrate from jest to vitest
Last updated on