Skip to content

test: migrate from Jest to Vitest #1393

@myfreeer

Description

@myfreeer

test: migrate from Jest to Vitest

Summary

Replace Jest + ts-jest with Vitest to eliminate ESM compatibility workarounds and simplify the test toolchain, while preserving all existing test behavior.

Current State

  • 14 test files, ~1,556 lines total
  • Jest 30 with ts-jest ESM preset
  • Requires --experimental-vm-modules Node flag for ESM support
  • Uses jest.unstable_mockModule for ESM module mocking
  • moduleNameMapper workaround for .js.ts resolution
  • Dev dependencies: jest, ts-jest, @jest/globals

Jest API Surface Audit

The actual Jest-specific API usage falls into two tiers:

Tier 1: Basic test APIs (9 files — no migration effort)

These files import only describe, test, expect from @jest/globals. No mocking, no spying, no Jest-specific matchers.

File Lines
detect-resource-type.spec.ts 86
download-streaming-resource.spec.ts 58
process-html.spec.ts 205
process-source-map.spec.ts 358
skip-links.spec.ts 52
redirect-html.spec.ts 33
sources.spec.ts
resource.spec.ts
util.spec.ts

For these files, migration is a one-line import change: @jest/globalsvitest.

Tier 2: Mocking APIs (5 files — moderate migration effort)

File Jest APIs used
save-mock-fs.ts jest.unstable_mockModule, jest.mock, jest.spyOn, jest.fn, jest.isMockFunction
save-html-to-disk.spec.ts jest.spyOn(fs.promises, 'writeFile'), jest.isMockFunction
save-resource-to-disk.spec.ts uses mockFs() / mockModules() from save-mock-fs.ts
status-change.spec.ts jest.mock('log4js', ...), jest.fn<StatusChangeFunc>()
options.spec.ts jest.mock('log4js', ...), jest.fn()
worker-pool.spec.ts jest.fn()

Motivation

The ESM friction is real but contained

Jest's ESM support has been experimental for years. The project currently relies on:

  1. --experimental-vm-modules — a Node flag that prints a warning and could change behavior across Node versions.
  2. jest.unstable_mockModule — still carries the "unstable" prefix in Jest 30. This is used in save-mock-fs.ts to mock fs and mkdirp for save/disk tests.
  3. ts-jest as a transformation layer — adds configuration complexity (createDefaultEsmPreset, moduleNameMapper for .js extensions).

None of this is currently breaking. But it adds friction to test authoring — every new test file that needs module mocking must use the unstable API, and contributors must remember the --experimental-vm-modules flag.

What this is NOT

This is not a performance or bundle-size motivated migration. Jest is ~2% of node_modules (2.2MB out of 94MB), and the test suite runs fast at 14 files. The motivation is purely toolchain simplification and ESM stability.

Design

Target: Vitest

Vitest is the natural migration target because:

  • Jest-compatible API: vi.fn(), vi.spyOn(), vi.mock() mirror Jest's API with stable ESM support.
  • Native TypeScript: No ts-jest needed — Vitest uses Vite's transform pipeline (esbuild) which handles .ts files natively.
  • Native ESM: No --experimental-vm-modules, no moduleNameMapper for .js extensions. Vitest resolves .js.ts automatically in ESM mode.
  • vi.mock is hoisted and stable: Unlike jest.unstable_mockModule, Vitest's vi.mock is the primary API for ESM module mocking.

Dependency changes

Remove:

  • jest (^30.3.0)
  • ts-jest (^29.4.6)
  • @jest/globals (^30.1.1)

Add:

  • vitest (~3.x)

Vitest bundles its own expect, test runner, and mocking — no separate type/globals packages needed.

Configuration

Replace jest.config.js with vitest.config.ts:

import {defineConfig} from 'vitest/config';

export default defineConfig({
  test: {
    globals: false,  // keep explicit imports, matching current style
  },
});

No moduleNameMapper needed. No transform preset needed. No experimental flags needed.

Update package.json scripts:

{
  "test": "npm run lint && vitest run",
  "test:watch": "vitest"
}

Migration by tier

Tier 1 files (9 files): import swap

-import {describe, expect, test} from '@jest/globals';
+import {describe, expect, test} from 'vitest';

No other changes.

Tier 2 files: mocking API migration

save-mock-fs.ts — the most complex migration:

-import {expect, jest} from '@jest/globals';
+import {expect, vi} from 'vitest';

 export function mockFs() {
   const fakeFs: Record<string, string> = {};
-  jest.spyOn(promises, 'writeFile').mockClear()
+  vi.spyOn(promises, 'writeFile').mockClear()
     .mockImplementation(/* same */);
   // ...
-  expect(jest.isMockFunction(promises.writeFile)).toBe(true);
+  expect(vi.isMockFunction(promises.writeFile)).toBe(true);
 }

 export function mockModules(): void {
-  jest.unstable_mockModule('fs', () => {
+  vi.mock('fs', () => {
     // same factory
   });
-  jest.mock('mkdirp', () => (/* same */));
-  jest.unstable_mockModule('mkdirp', () => (/* same */));
-  jest.mock('log4js', () => (/* same */));
+  vi.mock('mkdirp', () => (/* same */));
+  vi.mock('log4js', () => (/* same */));
 }

Key difference: jest.unstable_mockModule and jest.mock both become vi.mock. The duplicate mockModule/unstable_mockModule calls for mkdirp collapse into one vi.mock call. Vitest hoists vi.mock calls automatically.

status-change.spec.ts and options.spec.ts:

-import {describe, expect, jest, test} from '@jest/globals';
+import {describe, expect, vi, test} from 'vitest';

-jest.mock('log4js', () => ({
+vi.mock('log4js', () => ({
-  configure: jest.fn(),
-  getLogger: jest.fn().mockReturnValue({
+  configure: vi.fn(),
+  getLogger: vi.fn().mockReturnValue({
     // same
   }),
 }));

worker-pool.spec.ts:

-import {describe, expect, jest, test} from '@jest/globals';
+import {describe, expect, vi, test} from 'vitest';

-const fn = jest.fn();
+const fn = vi.fn();

Matchers

All matchers used in the test suite are standard Jest-compatible matchers that Vitest supports identically:

  • toBe, toStrictEqual, toBeUndefined, toBeTruthy, toBeFalsy
  • toHaveBeenCalledTimes, toHaveBeenCalledWith, not.toHaveBeenCalled
  • toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan
  • toBeInstanceOf, toBeNaN
  • rejects.toThrow

No custom matchers or Jest-specific matcher extensions are used.

The worker-pool test

worker-pool.spec.ts spawns real worker threads with test worker scripts (delay-calc-worker.js, error-worker.js). This works identically in Vitest — Vitest does not interfere with worker_threads. The known timing sensitivity (200ms timeout for 2 worker errors) is unchanged.

Risk Assessment

Risk Likelihood Impact Mitigation
vi.mock hoisting behaves differently from jest.unstable_mockModule for fs Low High — breaks save tests The mockModules() call is already at module top level. Vitest's hoisting is designed for this pattern. Test immediately after migration.
Vitest resolves .js imports differently Very low Medium Vitest's ESM resolution follows the same Node16 conventions. This is a solved problem in Vitest.
vi.spyOn(promises, 'writeFile') behaves differently Very low Medium vi.spyOn is API-compatible with jest.spyOn for method mocking.
Worker pool tests break under Vitest's runner Low Low — isolated to 1 file Worker tests use real threads, not Vitest's test isolation. No interaction expected.
Vitest adds vite as a dependency, increasing node_modules Certain Negligible Vitest ~15MB vs Jest+ts-jest ~2.2MB, but node_modules is 94MB total. Test deps are dev-only.

Non-Goals

  • It is not a goal to change the test structure or add new tests as part of this migration.
  • It is not a goal to enable Vitest-specific features like in-source testing, browser mode, or snapshot testing.
  • It is not a goal to switch to global test APIs (globals: true). The explicit import style (import {test, expect} from 'vitest') matches the current @jest/globals pattern.
  • It is not a goal to change the mocking strategy (e.g., switching from vi.mock to dependency injection).

Migration Plan

Phase 1: Mechanical migration (single PR)

  1. Install vitest, remove jest, ts-jest, @jest/globals.
  2. Replace jest.config.js with vitest.config.ts.
  3. Update all imports: @jest/globalsvitest, jest.vi..
  4. Collapse duplicate jest.mock/jest.unstable_mockModule calls in save-mock-fs.ts.
  5. Update package.json test scripts.
  6. Delete jest.config.js.
  7. Run full test suite, fix any failures.

Phase 2: Cleanup (optional, same or follow-up PR)

  1. Remove moduleNameMapper — Vitest handles .js.ts natively.
  2. Remove --experimental-vm-modules from any documentation or CI scripts.
  3. Update CLAUDE.md test commands.

Estimated diff size

  • ~14 files with import changes (1-2 lines each)
  • save-mock-fs.ts: ~15 lines changed
  • jest.config.jsvitest.config.ts: replaced
  • package.json: 4 lines changed (deps + scripts)
  • Total: ~50-70 lines changed across the codebase

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions