Migrating a Jest Suite to Vitest with TypeScript Strict Mode

The real reason Jest struggles with strict TypeScript

Jest’s type-checking story has not aged well. If you run a Jest 29 suite against a tsconfig.json with "strict": true and ts-jest in the loop, you are paying for two separate TypeScript compilations — one from your editor, one from ts-jest‘s transformer — and any jest.mock() call that rewires a module instantly loses its generic signatures unless you manually cast it through jest.MockedFunction<typeof fn>. Vitest sidesteps both problems because it runs your tests through the same Vite pipeline that already builds your app, which means one esbuild pass, native ESM, and mock helpers that keep their types.

That’s the core motivation to migrate Jest to Vitest in a TypeScript codebase: you stop fighting the toolchain. What follows is a concrete walkthrough of the migration — config, mocks, timers, module resolution, and the specific strict-mode errors you will hit on day one — using Vitest 1.6 and TypeScript 5.4 as the baseline versions.

Auditing the Jest config before you touch anything

Open jest.config.ts and write down every non-default field. The ones that matter most for a Vitest port are testEnvironment, setupFilesAfterEach, moduleNameMapper, transform, globalSetup, and any custom testRegex. For a typical React or Node monorepo you will see something like this:

// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest/presets/default-esm',
  testEnvironment: 'jsdom',
  extensionsToTreatAsEsm: ['.ts', '.tsx'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss)$': 'identity-obj-proxy',
  },
  setupFilesAfterEach: ['<rootDir>/test/setup.ts'],
  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
  coverageProvider: 'v8',
};

export default config;

Every one of those fields has a direct Vitest equivalent, which is why the official Vitest migration guide recommends a mechanical field-by-field port rather than a rewrite. The two fields that trip people up are moduleNameMapper (becomes resolve.alias in Vite config, not Vitest config) and testEnvironment (stays in Vitest but the value is environment instead).

Replacing the toolchain

Remove the Jest stack and install Vitest. On a clean install with pnpm it looks like this:

pnpm remove jest ts-jest @types/jest jest-environment-jsdom babel-jest
pnpm add -D vitest@^1.6 @vitest/coverage-v8 jsdom @vitest/ui

Keep @testing-library/react, @testing-library/jest-dom, and msw — all three work with Vitest unchanged. The only caveat is that @testing-library/jest-dom needs to be extended through Vitest’s matchers, which I show below in the setup file.

Now create vitest.config.ts at the repo root. You can put Vitest options inside your existing vite.config.ts using a triple-slash reference, but for a Node/Express project that has no Vite build of its own I prefer a standalone config:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'node:path';

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./test/setup.ts'],
    css: true,
    coverage: {
      provider: 'v8',
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/**/*.d.ts'],
    },
    typecheck: {
      tsconfig: './tsconfig.test.json',
    },
  },
});

The globals: true flag is the single biggest time-saver for a large migration. With it, describe, it, expect, beforeEach, and friends remain globally available, so you don’t have to add import { describe, it, expect } from 'vitest' to every test file. Strict TypeScript will complain about the missing globals unless you also add "types": ["vitest/globals"] to the compilerOptions of whichever tsconfig your tests extend.

The setup file and jest-dom matchers

Jest-dom attaches custom matchers like toBeInTheDocument. Under Jest you imported '@testing-library/jest-dom' once and it monkey-patched Jest’s expect. Under Vitest you have to extend the right expect and register a cleanup after each test:

// test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});

The /vitest subpath import exists specifically for this case and is documented in the testing-library/jest-dom README. Without it you will see “Property ‘toBeInTheDocument’ does not exist on type ‘Assertion<HTMLElement>'” on the very first test you open in your editor.

Official documentation for migrate jest to vitest typescript
Official documentation — the primary source for this topic.

Rewriting mocks without losing types

This is where strict mode bites hardest. In Jest a typical module mock looks like this:

jest.mock('../src/api/users');
import { fetchUser } from '../src/api/users';

const mockedFetch = fetchUser as jest.MockedFunction<typeof fetchUser>;
mockedFetch.mockResolvedValue({ id: 1, name: 'Ada' });

Under strict mode, the cast through jest.MockedFunction is mandatory — otherwise TypeScript treats fetchUser.mockResolvedValue as a property access on a function type that doesn’t have it. Vitest replaces this with vi.mocked(), which is a typed helper that returns the function with mock methods attached. The migrated version:

import { vi, describe, it, expect } from 'vitest';
import { fetchUser } from '../src/api/users';

vi.mock('../src/api/users');

const mockedFetch = vi.mocked(fetchUser);
mockedFetch.mockResolvedValue({ id: 1, name: 'Ada' });

Two things to notice. First, vi.mock is still hoisted to the top of the file by Vitest’s transformer, same as Jest, so order of imports doesn’t matter. Second, vi.mocked(fetchUser, true) with the deep argument set to true will recursively type every method on an object — useful when you mock an entire module namespace like vi.mocked(userRepository, true). The Vitest source for this helper lives at packages/vitest/src/integrations/vi.ts and is worth reading if you run into edge cases with class instance mocks.

Global find-and-replace gets you 80% of the way: swap jest.fn for vi.fn, jest.spyOn for vi.spyOn, jest.mock for vi.mock, jest.useFakeTimers for vi.useFakeTimers, and jest.clearAllMocks for vi.clearAllMocks. The remaining 20% — the parts that actually need thought — are timers, module factories, and jest.requireActual.

Timers behave differently, and strict mode makes it obvious

Jest’s legacy fake timers were on by default until Jest 27. Many older suites still pass { legacyFakeTimers: true } somewhere. Vitest only has modern timers (backed by @sinonjs/fake-timers), and it mocks a slightly different default set: setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate, Date, and queueMicrotask. If your Jest test relied on process.nextTick being real while setTimeout was fake, that assumption breaks.

A concrete example. This Jest test passes:

jest.useFakeTimers();
test('debounce waits 300ms', () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 300);
  debounced();
  jest.advanceTimersByTime(299);
  expect(fn).not.toHaveBeenCalled();
  jest.advanceTimersByTime(1);
  expect(fn).toHaveBeenCalledOnce();
});

Ported to Vitest, the only change is the vi prefix — but if the implementation of debounce uses queueMicrotask internally, you now need await vi.runAllTimersAsync() instead of advanceTimersByTime, because the async variant also drains the microtask queue. Strict mode catches the related problem where debounced() returns a promise that you weren’t awaiting — Vitest’s types flag it as a floating promise if your ESLint config has @typescript-eslint/no-floating-promises enabled.

Path aliases, moduleNameMapper, and CSS stubs

This is the silent killer of a half-ported migration. Your Jest moduleNameMapper handled two jobs: resolving @/components/Button to src/components/Button, and stubbing out CSS imports so that import './Button.module.css' didn’t blow up the transformer. Vitest handles these in two different places.

Path aliases belong in resolve.alias at the top level of vitest.config.ts, which I already showed above. CSS imports are handled by the test.css option. Setting css: true tells Vitest to actually process CSS through Vite’s pipeline, which works for most projects; setting css: false (the default in older Vitest versions) stubs CSS imports as empty objects, matching what identity-obj-proxy used to do. For CSS Modules specifically, the pattern that mirrors identity-obj-proxy is:

test: {
  css: {
    modules: {
      classNameStrategy: 'non-scoped',
    },
  },
},

With classNameStrategy: 'non-scoped', styles.primary in a test returns the literal string 'primary', which is exactly what most snapshot tests expect. This behavior is covered in the Vitest config reference and is one of the options worth knowing about before you start reporting “test passes locally, fails in CI” bugs.

Strict-mode fallout in assertions

A surprising category of breakage comes from expect().toEqual() with partial objects. Jest lets you write expect(user).toEqual({ id: 1 }) and silently ignores extra fields when the expected value is a subset — but only in some versions, and never reliably. Vitest is stricter: toEqual is deep-equal, not subset-equal. For partial matching you need expect.objectContaining({ id: 1 }) or the new expect(user).toMatchObject({ id: 1 }) form, which is also available in Jest but under-used in older suites.

The other strict-mode foot-gun is expect.any(String) inside a deeply nested structure. Vitest’s types for expect.any return any, which collides with noImplicitAny if you assign the expectation to an intermediate variable. The fix is to cast the expectation literal or inline it directly inside the toEqual call — never hoist the asymmetric matcher into a typed variable.

Benchmark: Test Suite Execution Time: Jest vs Vitest
Performance comparison — Test Suite Execution Time: Jest vs Vitest.

Running the suite and interpreting the first failures

The first vitest run on a real-world codebase will produce a three-digit failure count. Don’t panic — 90% of them cluster into four categories, in roughly this order of frequency:

  1. Missing vi imports when globals: true is off. Fix with the global flag or add explicit imports.
  2. Module mocks returning undefined because the mock factory didn’t export a default. Vitest is stricter about ESM default-vs-named exports than Jest was.
  3. Timer tests hanging because the code under test uses setImmediate or process.nextTick, which are fake by default now.
  4. Snapshot mismatches because Vitest’s serializer is marginally different — component names wrap differently, and undefined props are sometimes omitted where Jest preserved them.

Run vitest --update once you’ve verified a batch of snapshot diffs are cosmetic, not behavioral. Do it per-file with vitest run src/components/Button.test.tsx --update, not repo-wide, or you will update a real regression by accident.

Type-only tests with expectTypeOf

One feature that has no Jest equivalent is Vitest’s built-in type-testing API. If you maintain a library with generic utility types, you can assert them inside the same test file the runtime code lives in:

import { expectTypeOf, test } from 'vitest';
import type { PickByValue } from '../src/types';

test('PickByValue extracts string-valued keys', () => {
  type Source = { id: number; name: string; active: boolean };
  expectTypeOf<PickByValue<Source, string>>().toEqualTypeOf<{ name: string }>();
});

Enable it by setting test.typecheck.enabled = true in the config. The documentation for expectTypeOf lives inside the broader Vitest type-testing guide and is the feature I’d say most justifies the migration on its own for library authors, since the Jest equivalent requires bolting on tsd or dtslint as a separate pipeline.

CI runtime and the shape of the speedup

The speedup from switching runners depends heavily on three things: whether your Jest setup used ts-jest or @swc/jest, how much of your suite is I/O bound versus CPU bound, and whether you use isolate: false in Vitest (which reuses worker contexts across files). A pure-unit suite running on ts-jest will see the biggest win — often 2-4x — because you’re dropping the synchronous TypeScript compilation entirely. A suite that already used @swc/jest will see a smaller win of maybe 30-50%, mostly coming from Vitest’s worker model and shared Vite dep cache. An integration suite that spends most of its time waiting on a database or a mock HTTP server won’t see much change at all.

If the suite runs slower under Vitest than Jest, the usual cause is isolate: true (the default) creating a fresh VM context per test file on a project with hundreds of tiny files. Flipping to isolate: false in test config reuses the worker, at the cost of not catching tests that leak global state between files. I’d only do it after the port is green, not before.

The one change that matters most

If you take a single step from this guide, make it the vi.mocked() rewrite. Every other fix can wait until a test actually fails, but mock typing is load-bearing under strict mode and produces the most cryptic error messages when it goes wrong. Run the codemod (or a careful find-and-replace) across your suite, verify your editor stops complaining, and then start running Vitest against individual files. The migration from Jest to Vitest in a TypeScript project is a day of mechanical work when you attack it in that order, and a week of whack-a-mole when you don’t.

Elara Vance

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *