rstest

SKILL.md

Rstest

Rstest is a testing framework powered by Rspack. It provides Jest-compatible APIs with native TypeScript and ESM support, and integrates directly into the Rstack toolchain (Rspack, Rsbuild, Rslib, Rspress).

Key things that distinguish Rstest from Jest/Vitest:

  • Build tooling is Rspack, not Babel or Vite — it reuses your existing Rsbuild/Rspack config
  • Two separate utility namespaces: rs for module-level operations (mocking modules), and rstest for runtime utilities (fn, spyOn, timers). Both are imported from @rstest/core
  • rs.mock() without a factory is NOT auto-mock — it looks for __mocks__/ directory only. For auto-mocking, pass { mock: true }. This is the biggest gotcha when migrating from Vitest
  • Mock factories cannot be async — use import ... with { rstest: 'importActual' } for partial mocks instead
  • No --run flagrstest already runs once and exits. Use rstest --watch or rstest watch for watch mode
  • Node.js ≥ 20.19.0 required

Setup

Install

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

package.json scripts

{
  "scripts": {
    "test": "rstest",
    "test:watch": "rstest watch"
  }
}

Configuration file

Rstest auto-discovers config files in the project root in this order: rstest.config.mjs, .ts, .js, .cjs, .mts, .cts.

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

export default defineConfig({
  // Test config is at the top level — NOT nested under a `test` key (unlike Vitest)
  include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'],
  exclude: ['**/node_modules/**', '**/dist/**'],
  testEnvironment: 'node', // 'node' | 'jsdom' | 'happy-dom'
  globals: false,          // set true to skip imports in test files
  setupFiles: [],          // runs before each test file
  testTimeout: 5000,
  retry: 0,
});

If your project already uses Rsbuild or Rslib, use the official adapters to avoid config duplication — see "Rsbuild / Rslib integration" below.


Writing tests

Imports

import { describe, test, it, expect, beforeAll, afterAll, beforeEach, afterEach } from '@rstest/core';
import { rs } from '@rstest/core';     // module-level mocking
import { rstest } from '@rstest/core'; // runtime utilities (fn, spyOn, timers)

When globals: true is set, all of these are available globally without imports.

Basic test

import { expect, test } from '@rstest/core';

test('adds numbers', () => {
  expect(1 + 2).toBe(3);
});

Grouping

import { describe, expect, test } from '@rstest/core';

describe('math utils', () => {
  test('adds', () => expect(add(1, 2)).toBe(3));
  test('subtracts', () => expect(sub(3, 1)).toBe(2));
});

Async tests

test('fetches user', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

Parameterized tests

// .each (printf-style)
test.each([
  [1, 2, 3],
  [0, 0, 0],
])('add(%i, %i) = %i', (a, b, expected) => {
  expect(add(a, b)).toBe(expected);
});

// .for (type-safe, modern)
test.for([
  { a: 1, b: 2, expected: 3 },
])('add($a, $b) = $expected', ({ a, b, expected }) => {
  expect(add(a, b)).toBe(expected);
});

Modifiers

test.skip('disabled', () => { /* ... */ });
test.only('focus', () => { /* ... */ });
test.todo('implement later');
test.fails('expected to throw', () => { throw new Error(); });
test.concurrent('runs in parallel', async () => { /* ... */ });
test.sequential('forced serial', async () => { /* ... */ });

Lifecycle hooks

beforeAll(() => { /* once before suite */ });
afterAll(() => { /* once after suite */ });
beforeEach(() => { /* before each test */ });
afterEach(() => { /* after each test */ });

The return value of beforeEach/beforeAll is used as a cleanup function (runs after) — make sure to wrap void calls in braces: beforeEach(() => { doSomething() }).


Mocking modules — the rs object

rs is the module-level mocking utility. rs.mock() is hoisted to the top of the file automatically.

Factory mock

import { rs } from '@rstest/core';
import { fetchUser } from './api';

rs.mock('./api', () => ({
  fetchUser: rs.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}));

test('mocked', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
  expect(fetchUser).toHaveBeenCalledWith(1);
});

Auto-mock with { mock: true }

All exports become mock functions returning undefined. Original implementations are NOT preserved.

rs.mock('./math', { mock: true });

Spy mock with { spy: true }

All exports are wrapped in spy functions but the original implementation is preserved. Ideal for asserting calls without replacing behavior.

rs.mock('./calculator', { spy: true });

__mocks__ directory

rs.mock('./module') without a second argument looks for a matching file in a __mocks__/ sibling directory. If none is found, it throws (unlike Vitest which falls back to auto-mock).

Partial mock with importActual

Use the with { rstest: 'importActual' } import attribute to synchronously load the real module, then spread it in the factory:

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

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

Or use the async version inside tests:

rs.mock('./sum');
test('partial', async () => {
  const actual = await rs.importActual('./sum');
  expect(actual.sum(1, 2)).toBe(3);
});

Hoisting shared values

Because rs.mock is hoisted, variables defined later in the file aren't available in the factory. Use rs.hoisted() to create values accessible from mock factories:

const mocks = rs.hoisted(() => ({
  myFn: rs.fn(),
}));

rs.mock('./module', () => ({ default: mocks.myFn }));

test('works', () => {
  mocks.myFn.mockReturnValue(42);
  // ...
});

Other module mock utilities

API Description
rs.doMock(path, factory?) Like rs.mock but NOT hoisted — applies only to imports after this call
rs.unmock(path) Cancel a mock (hoisted) — useful to unmock setup file mocks
rs.doUnmock(path) Non-hoisted unmock
rs.importMock(path) Import a module with all properties as mock implementations
rs.resetModules() Clear module cache (does not clear mocks)

Gotcha: mocking re-exported modules

Rspack may resolve re-exports to the source module. If mocking react-router-dom doesn't work, mock react-router instead. Alternatively, disable optimization.providedExports in tools.rspack.


Mock functions — the rstest object

rstest is the runtime utility for creating mock functions, spies, timers and stubs.

Creating mocks

import { rstest } from '@rstest/core';

const fn = rstest.fn();
fn.mockReturnValue(42);
expect(fn()).toBe(42);
expect(fn).toHaveBeenCalledOnce();

// With implementation
const greet = rstest.fn((name: string) => `hi ${name}`);

Spying on methods

const spy = rstest.spyOn(console, 'log').mockImplementation(() => {});
// ...
expect(spy).toHaveBeenCalledWith('hello');
spy.mockRestore();

rstest.mockObject() — deep mock an object

const mocked = rstest.mockObject(originalObj);
// All methods return undefined and are mock functions
// Primitives are preserved

// With { spy: true }, original implementations are kept:
const spied = rstest.mockObject(originalObj, { spy: true });

rstest.mocked() — TypeScript type helper

Wraps a mocked module with proper MockInstance types (no runtime effect):

const mockedModule = rstest.mocked(myModule);
mockedModule.method.mockReturnValue('test');

Clearing / resetting

rstest.clearAllMocks();   // clear call history
rstest.resetAllMocks();   // reset implementations
rstest.restoreAllMocks(); // restore original (for spies)

Or configure auto-clearing per test:

// rstest.config.ts
export default defineConfig({
  clearMocks: true,
  resetMocks: true,
  restoreMocks: true,
});

Environment stubs

rstest.stubEnv('NODE_ENV', 'production');
rstest.stubGlobal('fetch', rstest.fn());

// Auto-restored with unstubEnvs/unstubGlobals config, or manually:
rstest.unstubAllEnvs();
rstest.unstubAllGlobals();

Fake timers

rstest.useFakeTimers();

const cb = rstest.fn();
setTimeout(cb, 1000);

rstest.advanceTimersByTime(1000);
expect(cb).toHaveBeenCalledOnce();

rstest.useRealTimers();

Other timer APIs: runAllTimers(), runAllTimersAsync(), runOnlyPendingTimers(), advanceTimersToNextTimer(), advanceTimersToNextFrame(), setSystemTime(date), getRealSystemTime(), getTimerCount(), clearAllTimers().


Assertions (expect)

Jest-compatible. Key matchers:

expect(val).toBe(42);                  // strict ===
expect(obj).toEqual({ a: 1 });         // deep equality
expect(obj).toStrictEqual({ a: 1 });   // deep + type equality
expect(val).toBeTruthy();
expect(val).toBeNull();
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect(arr).toContain(item);
expect(arr).toHaveLength(3);
expect(obj).toHaveProperty('key', val);
expect(() => fn()).toThrow('msg');
await expect(promise).resolves.toBe('ok');
await expect(promise).rejects.toThrow();
expect.soft(a).toBe(1); // non-failing, continues
expect(val).not.toBe(0);

Mock matchers

expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith(arg);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveReturnedWith(value);

Snapshots

expect(data).toMatchSnapshot();
expect(data).toMatchSnapshot('descriptive name');
expect(msg).toMatchInlineSnapshot('"Hello World"');
await expect(html).toMatchFileSnapshot('./basic.output.html');

Snapshots stored in __snapshots__/*.snap. Update with rstest -u.

Custom serializers for paths/sensitive data:

expect.addSnapshotSerializer({
  test: (val) => typeof val === 'string' && val.startsWith('/'),
  print: (val) => '"<PATH>"',
});

DOM testing

Set the test environment to simulate browser APIs in Node:

// rstest.config.ts
export default defineConfig({
  testEnvironment: 'jsdom', // or 'happy-dom' (faster)
});

React

pnpm add @rstest/core @rsbuild/plugin-react @testing-library/react @testing-library/jest-dom happy-dom -D
// rstest.config.ts
import { pluginReact } from '@rsbuild/plugin-react';
import { defineConfig } from '@rstest/core';

export default defineConfig({
  plugins: [pluginReact()],
  testEnvironment: 'happy-dom',
  setupFiles: ['./rstest.setup.ts'],
});
// rstest.setup.ts
import { afterEach, expect } from '@rstest/core';
import { cleanup } from '@testing-library/react';
import * as jestDomMatchers from '@testing-library/jest-dom/matchers';

expect.extend(jestDomMatchers);
afterEach(() => cleanup());
// App.test.tsx
import { expect, test } from '@rstest/core';
import { render, screen } from '@testing-library/react';
import App from './App';

test('renders greeting', () => {
  render(<App />);
  expect(screen.getByText('Hello World')).toBeInTheDocument();
});

Note: Do NOT import @testing-library/jest-dom/vitest — that's Vitest-specific. Use the matchers approach above.

Vue

pnpm add @rstest/core @rsbuild/plugin-vue @vue/test-utils happy-dom -D
// rstest.config.ts
import { pluginVue } from '@rsbuild/plugin-vue';
import { defineConfig } from '@rstest/core';

export default defineConfig({
  plugins: [pluginVue()],
  testEnvironment: 'happy-dom',
});
import { expect, test } from '@rstest/core';
import { mount } from '@vue/test-utils';
import App from '../src/App.vue';

test('renders', () => {
  const wrapper = mount(App);
  expect(wrapper.text()).toContain('Hello World');
});

Browser Mode (experimental)

For real browser testing with Playwright (when jsdom/happy-dom isn't enough):

pnpm add @rstest/core @rstest/browser -D
npx playwright install chromium
// rstest.config.ts (or rstest.browser.config.ts)
import { defineConfig } from '@rstest/core';

export default defineConfig({
  browser: {
    enabled: true,
    provider: 'playwright',
    // headless: true,  // auto in CI
  },
});
import { page } from '@rstest/browser';
import { expect, test } from '@rstest/core';

test('counter clicks', async () => {
  document.body.innerHTML = '<button id="btn">0</button>';
  // Use Playwright-style locator API
  await page.getByRole('button').click();
  await expect.element(page.getByText('1')).toBeVisible();
});

Quick setup: npx rstest init browser scaffolds config + sample tests automatically.


Code coverage

// rstest.config.ts
export default defineConfig({
  coverage: {
    enabled: true,     // or use CLI: rstest --coverage
    provider: 'istanbul',
    include: ['src/**'],
    exclude: ['**/*.test.ts'],
  },
});

In-source tests

Rust-style tests inside source files:

// src/utils.ts
export function add(a: number, b: number) { return a + b; }

if (import.meta.rstest) {
  const { test, expect } = await import('@rstest/core');
  test('add', () => expect(add(1, 2)).toBe(3));
}
// rstest.config.ts
export default defineConfig({ includeSource: ['src/**/*.ts'] });

In production builds, set source.define: { 'import.meta.rstest': false } to tree-shake test code.


Multi-project testing

// rstest.config.ts (root)
export default defineConfig({
  projects: [
    'packages/*',                  // each subdir with its own rstest.config.ts
    {
      name: 'node-tests',
      include: ['tests/node/**/*.test.ts'],
      testEnvironment: 'node',
    },
    {
      name: 'dom-tests',
      include: ['tests/dom/**/*.test.tsx'],
      testEnvironment: 'jsdom',
    },
  ],
});

Sub-projects use defineProject instead of defineConfig. Shared config via mergeRstestConfig. Filter projects with rstest --project 'name'.


Rsbuild / Rslib integration

Rsbuild adapter

pnpm add @rstest/adapter-rsbuild -D
import { defineConfig } from '@rstest/core';
import { withRsbuildConfig } from '@rstest/adapter-rsbuild';

export default defineConfig({
  extends: withRsbuildConfig(), // reads rsbuild.config.ts automatically
  testEnvironment: 'happy-dom',
  setupFiles: ['./rstest.setup.ts'],
});

Options: cwd, configPath, environmentName, modifyRsbuildConfig.

Rslib adapter

pnpm add @rstest/adapter-rslib -D
import { defineConfig } from '@rstest/core';
import { withRslibConfig } from '@rstest/adapter-rslib';

export default defineConfig({
  extends: withRslibConfig(), // reads rslib.config.ts automatically
});

Options: cwd, configPath, libId, modifyLibConfig.

Detect test environment in source

if (process.env.RSTEST) { /* only during tests */ }

In production builds: source.define: { 'process.env.RSTEST': false } for tree-shaking.


CLI reference

Command Purpose
rstest Run all tests (exits after, no watch)
rstest watch / rstest --watch Watch mode
rstest run Single run (explicit, for CI)
rstest list List matching tests
rstest init browser Scaffold Browser Mode
rstest -u Update snapshots
rstest --coverage Enable coverage
rstest -t "pattern" Filter by test name (regex)
rstest path/to/file Run specific file
rstest --project "name" Filter by project
rstest --testEnvironment jsdom Override env via CLI
DEBUG=rstest rstest Debug mode (writes configs to dist)

Watch shortcuts: f rerun failed · a rerun all · u update snapshots · t filter name · p filter path · q quit


Migration cheat sheet

From Vitest

Vitest Rstest
import { vi } from 'vitest' import { rs } from '@rstest/core'
vi.fn() rs.fn()
vi.mock('./foo') (auto-mocks) rs.mock('./foo', { mock: true })
vi.mock('./foo', factory) rs.mock('./foo', factory)
vi.spyOn(obj, 'method') rs.spyOn(obj, 'method')
vi.hoisted(() => ...) rs.hoisted(() => ...)
test.environment = 'jsdom' in config testEnvironment: 'jsdom' (top-level)
@testing-library/jest-dom/vitest expect.extend(jestDomMatchers) manually
vitest.config.ts with test: { ... } rstest.config.ts (flat, no test wrapper)
Vite plugins (@vitejs/plugin-react) Rsbuild plugins (@rsbuild/plugin-react)
Factory can be async Factory must be sync — use with { rstest: 'importActual' }

From Jest

Jest Rstest
jest.fn() rstest.fn() or rs.fn()
jest.mock('./foo') rs.mock('./foo', { mock: true })
jest.spyOn(obj, 'method') rstest.spyOn(obj, 'method')
jest.setTimeout(5000) rstest.setConfig({ testTimeout: 5000 })
done callback Return a Promise or use async/await
jest.config.js rstest.config.ts with defineConfig()
setupFilesAfterEnv setupFiles
moduleNameMapper resolve.alias
transformIgnorePatterns output.externals / source.exclude
@swc/jest transform Built-in SWC (configure via tools.swc)

Debugging

# Debug mode — writes resolved config to dist/
DEBUG=rstest pnpm test

VS Code launch config:

{
  "name": "Debug Current Test File",
  "type": "node",
  "request": "launch",
  "program": "${workspaceFolder}/node_modules/@rstest/core/bin/rstest.js",
  "args": ["run", "${file}"],
  "console": "integratedTerminal",
  "skipFiles": ["<node_internals>/**"]
}

References

For deeper detail on specific topics, consult the official documentation:

Weekly Installs
1
GitHub Stars
10
First Seen
3 days ago
Installed on
windsurf1
amp1
cline1
opencode1
cursor1
kimi-cli1