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/globals → vitest.
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:
--experimental-vm-modules — a Node flag that prints a warning and could change behavior across Node versions.
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.
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 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)
- Install
vitest, remove jest, ts-jest, @jest/globals.
- Replace
jest.config.js with vitest.config.ts.
- Update all imports:
@jest/globals → vitest, jest. → vi..
- Collapse duplicate
jest.mock/jest.unstable_mockModule calls in save-mock-fs.ts.
- Update
package.json test scripts.
- Delete
jest.config.js.
- Run full test suite, fix any failures.
Phase 2: Cleanup (optional, same or follow-up PR)
- Remove
moduleNameMapper — Vitest handles .js → .ts natively.
- Remove
--experimental-vm-modules from any documentation or CI scripts.
- 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.js → vitest.config.ts: replaced
package.json: 4 lines changed (deps + scripts)
- Total: ~50-70 lines changed across the codebase
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
ts-jestESM preset--experimental-vm-modulesNode flag for ESM supportjest.unstable_mockModulefor ESM module mockingmoduleNameMapperworkaround for.js→.tsresolutionjest,ts-jest,@jest/globalsJest 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,expectfrom@jest/globals. No mocking, no spying, no Jest-specific matchers.detect-resource-type.spec.tsdownload-streaming-resource.spec.tsprocess-html.spec.tsprocess-source-map.spec.tsskip-links.spec.tsredirect-html.spec.tssources.spec.tsresource.spec.tsutil.spec.tsFor these files, migration is a one-line import change:
@jest/globals→vitest.Tier 2: Mocking APIs (5 files — moderate migration effort)
save-mock-fs.tsjest.unstable_mockModule,jest.mock,jest.spyOn,jest.fn,jest.isMockFunctionsave-html-to-disk.spec.tsjest.spyOn(fs.promises, 'writeFile'),jest.isMockFunctionsave-resource-to-disk.spec.tsmockFs()/mockModules()fromsave-mock-fs.tsstatus-change.spec.tsjest.mock('log4js', ...),jest.fn<StatusChangeFunc>()options.spec.tsjest.mock('log4js', ...),jest.fn()worker-pool.spec.tsjest.fn()Motivation
The ESM friction is real but contained
Jest's ESM support has been experimental for years. The project currently relies on:
--experimental-vm-modules— a Node flag that prints a warning and could change behavior across Node versions.jest.unstable_mockModule— still carries the "unstable" prefix in Jest 30. This is used insave-mock-fs.tsto mockfsandmkdirpfor save/disk tests.ts-jestas a transformation layer — adds configuration complexity (createDefaultEsmPreset,moduleNameMapperfor.jsextensions).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-modulesflag.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:
vi.fn(),vi.spyOn(),vi.mock()mirror Jest's API with stable ESM support.ts-jestneeded — Vitest uses Vite's transform pipeline (esbuild) which handles.tsfiles natively.--experimental-vm-modules, nomoduleNameMapperfor.jsextensions. Vitest resolves.js→.tsautomatically in ESM mode.vi.mockis hoisted and stable: Unlikejest.unstable_mockModule, Vitest'svi.mockis 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.jswithvitest.config.ts:No
moduleNameMapperneeded. No transform preset needed. No experimental flags needed.Update
package.jsonscripts:{ "test": "npm run lint && vitest run", "test:watch": "vitest" }Migration by tier
Tier 1 files (9 files): import swap
No other changes.
Tier 2 files: mocking API migration
save-mock-fs.ts— the most complex migration:Key difference:
jest.unstable_mockModuleandjest.mockboth becomevi.mock. The duplicatemockModule/unstable_mockModulecalls formkdirpcollapse into onevi.mockcall. Vitest hoistsvi.mockcalls automatically.status-change.spec.tsandoptions.spec.ts:worker-pool.spec.ts:Matchers
All matchers used in the test suite are standard Jest-compatible matchers that Vitest supports identically:
toBe,toStrictEqual,toBeUndefined,toBeTruthy,toBeFalsytoHaveBeenCalledTimes,toHaveBeenCalledWith,not.toHaveBeenCalledtoBeGreaterThan,toBeGreaterThanOrEqual,toBeLessThantoBeInstanceOf,toBeNaNrejects.toThrowNo custom matchers or Jest-specific matcher extensions are used.
The worker-pool test
worker-pool.spec.tsspawns real worker threads with test worker scripts (delay-calc-worker.js,error-worker.js). This works identically in Vitest — Vitest does not interfere withworker_threads. The known timing sensitivity (200ms timeout for 2 worker errors) is unchanged.Risk Assessment
vi.mockhoisting behaves differently fromjest.unstable_mockModuleforfsmockModules()call is already at module top level. Vitest's hoisting is designed for this pattern. Test immediately after migration..jsimports differentlyvi.spyOn(promises, 'writeFile')behaves differentlyvi.spyOnis API-compatible withjest.spyOnfor method mocking.viteas a dependency, increasingnode_modulesnode_modulesis 94MB total. Test deps are dev-only.Non-Goals
globals: true). The explicit import style (import {test, expect} from 'vitest') matches the current@jest/globalspattern.vi.mockto dependency injection).Migration Plan
Phase 1: Mechanical migration (single PR)
vitest, removejest,ts-jest,@jest/globals.jest.config.jswithvitest.config.ts.@jest/globals→vitest,jest.→vi..jest.mock/jest.unstable_mockModulecalls insave-mock-fs.ts.package.jsontest scripts.jest.config.js.Phase 2: Cleanup (optional, same or follow-up PR)
moduleNameMapper— Vitest handles.js→.tsnatively.--experimental-vm-modulesfrom any documentation or CI scripts.Estimated diff size
save-mock-fs.ts: ~15 lines changedjest.config.js→vitest.config.ts: replacedpackage.json: 4 lines changed (deps + scripts)