Docs
Migrate to Rspack and Vitest

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, 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 (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-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 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:

rspack.config.js
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:

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",
+   "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-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.
  • cross-env is used to set TZ=UTC for the test scripts (Vitest does not set this for you, and time-sensitive tests will be flaky without it).

Update the test scripts

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

vitest.config.ts
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-core mixes both (some packages on happy-dom, pure-logic packages on 'node'), and openmrs-esm-template-app defaults 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 with identity-obj-proxy. Without the anchors, only the extension gets matched and Vitest fails to resolve the module.
  • fileURLToPath for path aliases. 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.
  • fakeTimers.toFake. This is a narrower, OpenMRS-tested list, not the Vitest default. Vitest's default fakes every timer-like API sinon detects (typically including requestIdleCallback, cancelIdleCallback, performance, and process.hrtime) minus nextTick and queueMicrotask. The list above sticks to the timers actual OpenMRS tests need, which keeps the surface area smaller. You can omit fakeTimers entirely if you're happy with the broader default — neither choice breaks React's scheduler, because both keep queueMicrotask and nextTick out.
  • 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-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 { 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:

  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 toFake already excludes them, so leaving fakeTimers unset is usually the right move; if you do supply an explicit list, mirror the one in the config snippet above (no queueMicrotask, no nextTick).
  • @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 (opens in a new tab). 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):

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

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