close

Migrating from Vitest

If you are using the Rstack toolchain (Rsbuild / Rslib / Rspack, etc.), migrating to Rstest gives you a more consistent development experience.

Using Agent Skills

If you are using a Coding Agent that supports Skills, install the migrate-to-rstest skill to help with the upgrade process from Vitest to Rstest.

npx skills add rstackjs/agent-skills --skill migrate-to-rstest

After installation, let the Coding Agent guide you through the upgrade.

Installation and setup

First, you need to install Rstest as a development dependency.

npm
yarn
pnpm
bun
deno
npm add @rstest/core -D

Next, update the test script in your package.json to use rstest instead of vitest. For example:

"scripts": {
-  "test": "vitest run" // or "vitest --run"
+  "test": "rstest"
}

rstest does not have a --run flag. Running rstest already executes tests once and exits. If you want watch mode, use --watch:

"scripts": {
-  "test": "vitest"
+  "test": "rstest --watch"
}

CLI option mappings

Some Vitest CLI flags map directly to Rstest, while others change shape. Use this table for the common option differences you are likely to hit during migration:

Vitest CLI optionRstest equivalentNotes
vitest run / vitest --runrstestNo --run flag — rstest already runs once and exits.
vitest / vitest watch / vitest --watchrstest --watch or rstest watchRstest does not auto-enable watch mode — pass --watch explicitly.
vitest --coveragerstest --coverageAlso install @rstest/coverage-istanbul (see the coverage config row).
vitest --environment=jsdomrstest --testEnvironment jsdom
vitest --reporter=verboserstest --reporter verbose
vitest --globalsrstest --globals
vitest -t <pattern> / --testNamePatternrstest -t <pattern> / --testNamePattern
vitest -u / --updaterstest -u / --update
vitest -c <path> / --configrstest -c <path> / --config
vitest --project <name>rstest --project <name>

Configuration migration

Update your Vitest config file (e.g., vite.config.ts or vitest.config.ts) to a rstest.config.ts file:

rstest.config.ts
import { defineConfig } from '@rstest/core';

export default defineConfig({
  // Fill in by mapping fields from your vitest.config.ts — see the table below.
});

Helper imports

Vitest's config-file helpers map to equivalents in @rstest/core:

Vitest (vitest/config)Rstest (@rstest/core)Notes
defineConfigdefineConfig
defineProjectdefineProjectFor items inside a projects array, prefer defineInlineProject — it requires an explicit name.
defineWorkspaceRemoveNo workspace helper. Declare projects inline via the projects field on defineConfig — see Projects.
mergeConfigmergeRstestConfigDeep-merges and preserves function-valued fields. For composing configs inside a projects array, use mergeProjectConfig.

Vitest configuration mappings

When migrating config, keep two changes in mind:

  • Remove the test field and move its nested properties to the top level.
  • Rename keys when required (for example, test.environmenttestEnvironment).

Walk through every option under test and match it against the table below — move it to the top level, rename it, or drop it. Fields not listed here may not map 1:1; verify against the Rstest config reference before dropping them silently.

Vitest (under test)Rstest (top-level)Notes
environmenttestEnvironmentFold test.environmentOptions into the object form: testEnvironment: { name: 'jsdom', options: { ... } }. Custom environment packages are not supported.
include / excludeinclude / exclude
includeSourceincludeSource
setupFilessetupFiles
globalSetupglobalSetupRstest calls setup with no arguments — if your Vitest setup reads the TestProject argument (e.g. provide, onTestsRerun), rewrite without it. Vitest's provide / inject has no direct Rstest equivalent — mutate process.env in the setup (Rstest snapshots it post-setup into every worker) or set static values via the env config field.
globalsglobals
namename
rootroot
envenv
aliasresolve.aliasNot a test.* field in Rstest — move aliases to top-level resolve.alias.
passWithNoTestspassWithNoTests
isolateisolate
testTimeout / hookTimeouttestTimeout / hookTimeout
teardownTimeoutRemoveNo equivalent — Vitest's teardownTimeout is a shutdown-wait timeout, unrelated to Rstest's hookTimeout (which covers lifecycle hooks).
slowTestThresholdslowTestThreshold
maxConcurrencymaxConcurrency
retryretry
bailbail
clearMocksclearMocks
mockResetresetMocks
restoreMocksrestoreMocks
poolOptions.forks.maxForkspool.maxWorkersRstest flattens poolOptions into top-level pool: poolOptions.forks.maxForkspool.maxWorkers, minForkspool.minWorkers, execArgvpool.execArgv. Only the forks pool is supported — pool: 'threads' | 'vmThreads' | 'vmForks' settings are dropped (behavior reverts to forks). Vitest 4's top-level test.maxWorkers / test.minWorkers map to pool.maxWorkers / pool.minWorkers.
coveragecoverageOnly provider: 'istanbul' is supported — swap the dev dep from @vitest/coverage-v8 to @rstest/coverage-istanbul (drop 'v8' / 'custom'). Rename coverage.reportercoverage.reporters (singular is silently ignored). These sub-keys map 1:1: include, exclude, reportsDirectory, thresholds. V8-only keys (all, skipFull, thresholdAutoUpdate, processingConcurrency, customProviderModule, watermarks, ignoreClassMethods, etc.) have no equivalent.
reportersreportersVitest-only (drop or replace): tap, tap-flat, html, tree, hanging-process. String values must be built-in names — for third-party reporters, import the class and pass the instance.
outputFilereporter optionsNo top-level field. For junit / json, pass outputPath via the reporter tuple: ['junit', { outputPath: '...' }]. For blob, use { outputDir: '...' }. Other reporters accept no output path. The object form { junit: 'a.xml', json: 'a.json' } expands to one tuple per reporter.
snapshotFormatsnapshotFormat
resolveSnapshotPathresolveSnapshotPathRstest's signature is (testPath, snapExtension) => string — no third context argument from Vitest 3+.
snapshotSerializersexpect.addSnapshotSerializerNo config field. In a setupFiles module, import each serializer and call expect.addSnapshotSerializer(serializer).
projectsprojects
logHeapUsagelogHeapUsage
includeTaskLocationincludeTaskLocation
printConsoleTraceprintConsoleTrace
unstubGlobalsunstubGlobals
unstubEnvsunstubEnvs
chaiConfigchaiConfig

Build configuration

Rstest uses Rsbuild as the default test build tool instead of Vite. You can view all available build configuration options in Build Configurations.

In most projects, these are the key build-side changes:

  • Use source.define instead of define.
  • Use output.externals instead of ssr.external.
  • Use Rsbuild plugins instead of Vite plugins.
import { defineConfig } from '@rstest/core';
- import react from '@vitejs/plugin-react'
+ import { pluginReact } from '@rsbuild/plugin-react';

export default defineConfig({
-  plugins: [react()],
-  define: {
-    __DEV__: true,
-  },
+  plugins: [pluginReact()],
+  source: {
+    define: {
+      __DEV__: true,
+    },
+  },
});

If you are using Rslib or Rsbuild, you can directly use the corresponding adapter:

  • For Rslib projects (with rslib.config.*), use @rstest/adapter-rslib with extends: withRslibConfig() (see Rslib integration reference).
  • For Rsbuild projects (with rsbuild.config.*), use @rstest/adapter-rsbuild with extends: withRsbuildConfig() (see Rsbuild integration reference).

Update test API

Test API

Your existing Vitest test files should work with minimal changes since Rstest provides Vitest-compatible APIs. Update your imports from vitest to @rstest/core, and replace vi / vitest utilities with their rs / rstest equivalents:

- import { describe, expect, it, test, vi, type Mock } from 'vitest';
+ import { describe, expect, it, test, rs, type Mock } from '@rstest/core';
- vi.fn()
+ rs.fn()

- vi.mock('./foo')
+ rs.mock('./foo')

- vi.spyOn(console, 'error')
+ rs.spyOn(console, 'error')
- vitest.fn()
+ rs.fn()

For the full utility API list, see Rstest APIs.

Global APIs

When globals: true is enabled, vi and vitest are available as globals in Vitest. In Rstest, use this mapping order:

  • vi.<api>rs.<api>
  • vitest.<api>rstest.<api>

rs and rstest are equivalent global aliases, but keeping this one-to-one mapping is easier to read during migration.

- vi.fn()
+ rs.fn()

- vitest.spyOn(console, 'error')
+ rstest.spyOn(console, 'error')

If your tests import APIs from @rstest/core, prefer rs.<api> in import style and avoid mixing import style and global style in the same file.

Setup adapters

Some setup adapters are Vitest-specific. For example, @testing-library/jest-dom/vitest is designed for Vitest; in Rstest, register the matchers directly via expect.extend.

- import '@testing-library/jest-dom/vitest';
+ import * as jestDomMatchers from '@testing-library/jest-dom/matchers';
+ import { expect } from '@rstest/core';
+
+ expect.extend(jestDomMatchers);

Path resolution

Depending on your transform/runtime mode, new URL(..., import.meta.url) may fail in setup or helper files.

If you see path errors such as Cannot find module './' or Cannot find module '..', prefer Node-style path resolution with __dirname:

- const root = fileURLToPath(new URL('../..', import.meta.url));
+ import { resolve } from 'node:path';
+ const root = resolve(__dirname, '../..');

Auto-mocking modules

In Vitest, calling vi.mock() with just the module path first attempts to load a manual mock from the corresponding __mocks__ directory. If no manual mock is found, it automatically mocks the module, replacing all its exports with empty mock functions.

// Vitest
import { vi, test, expect } from 'vitest';
import { someFunction } from './module';

// Looks for __mocks__/module.js first, then auto-mocks.
vi.mock('./module');

test('should be mocked', () => {
  expect(vi.isMockFunction(someFunction)).toBe(true);
  someFunction(); // returns undefined
});

Rstest handles this differently. Calling rs.mock() with only the module path will only look for a mock in the __mocks__ directory and will throw an error if one isn't found. Auto-mocking requires explicitly passing the { mock: true } option.

// Rstest
import { rs, test, expect } from '@rstest/core';
import { someFunction } from './module';

- // Looks for __mocks__/module.js first, then auto-mocks.
- vi.mock('./module');
+ // Auto-mocks the module because { mock: true } is passed.
+ rs.mock('./module', { mock: true });

test('should be mocked', () => {
  expect(rs.isMockFunction(someFunction)).toBe(true);
  someFunction(); // returns undefined
});

Mock async modules

When you need to mock a module's return value, Rstest does not support returning an async function.

As an alternative, Rstest provides synchronous importActual capability, allowing you to import the unmocked module implementation through static import statements:

import * as apiActual from './api' with { rstest: 'importActual' };

// Partially mock the './api' module
rs.mock('./api', () => ({
  ...apiActual,
  fetchUser: rs.fn().mockResolvedValue({ id: 'mocked' }),
}));

Because mock factories are hoisted, avoid relying on values initialized later in the same module. If needed, move shared values to a hoisted initializer (for example rs.hoisted(...)) to avoid initialization-order errors.

Snapshots

Vitest and Rstest use the same snapshot key format and body serializer. Existing __snapshots__/*.snap files from a Vitest suite are read verbatim by Rstest, and tests that passed under Vitest continue to pass under Rstest without re-recording. Only the file header line differs:

- // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+ // Rstest Snapshot v1

Running rstest -u normalizes this header to the Rstest form; the snapshot bodies stay byte-identical.