•7 min read

Migrating from Jest to Vitest in a Turborepo Monorepo Architecture

Migrating from Jest to Vitest in a Turborepo Monorepo Architecture

As enterprise codebases scale into large TypeScript monorepos, test execution speed becomes the single biggest determinant of developer velocity. When a pull request requires running 500+ unit and integration test suites across dozens of interconnected packages, a slow test pipeline cripples continuous integration and stalls local development.

For nearly a decade, Jest has been the default JavaScript testing framework. However, when paired with TypeScript monorepos managed by Turborepo, Jest exposes severe architectural bottlenecks: redundant Babel/ts-jest compilation steps, separate transformer pipelines from your application bundler, and massive memory consumption during parallel test worker runs.

Vitest, built directly on top of Vite's lightning-fast dev server and ES module transform pipeline, provides a drop-in replacement that eliminates duplicate compilation and unlocks orders-of-magnitude faster execution.

In this guide, we walk through an end-to-end migration from Jest to Vitest inside a production Turborepo workspace, including workspace configurations, mocking translations, and CI caching strategies.


Audio Briefing
0:00 / 0:00

Why Jest Bottlenecks Turborepo Workspaces

In a TypeScript monorepo with 20 packages, Jest typically executes tests by invoking ts-jest or @babel/preset-typescript on every single imported file. Even if your build tool uses Vite, esbuild, or SWC to compile your production code, Jest maintains its own separate, isolated transpilation pipeline:

[Legacy Monorepo Pipeline]
  Build Tool (Vite / ESBuild) ──► Fast ESM Compilation ──► Production Bundle
                                       ▲
                                (Duplicated effort)
                                       ▼
  Testing Tool (Jest + ts-jest) ──► Slow CJS Compilation ──► Test Runner (High Memory)

This split architecture creates three major pain points:

  1. Redundant Compilation: Every test worker recompiles the same shared TypeScript libraries independently in memory.
  2. ESM/CommonJS Incompatibilities: Testing packages that ship pure ESM (such as modern versions of node-fetch, chalk, or nanoid) requires convoluted transformIgnorePatterns regex configurations in jest.config.js.
  3. Memory Leaks and Worker Overhead: Jest's isolated VM runner leaks memory on large test suites, frequently leading to JavaScript heap out of memory errors on GitHub Actions runners unless --runInBand is enforced (which destroys parallelism).

Advertisement

The Vitest Advantage in Monorepos

Vitest resolves these architectural issues by sharing the exact same transformation pipeline, plugin ecosystem, and configuration as Vite:

  • Single Pipeline: Vitest uses Vite's pre-configured transform cache. If Vite knows how to resolve path aliases (@/components/*) or compile TSX, Vitest handles it identically with zero configuration drift.
  • Worker Pools via tinypool: Vitest utilizes lightweight worker threads rather than heavy NodeJS child processes, slashing process spawn overhead.
  • Native ESM & Vite Workspaces: Vitest natively understands ECMAScript modules and provides native support for multi-project workspaces via vitest.workspace.ts.

Step-by-Step Migration Guide

Step 1: Clean Up Jest Dependencies

Remove Jest, ts-jest, Jest type definitions, and Babel transformers from your monorepo root:

# In your monorepo root
npm uninstall jest @types/jest ts-jest babel-jest
npm install -D vitest @vitest/ui @vitest/coverage-v8

Step 2: Configure the Root vitest.workspace.ts

Rather than maintaining isolated test runner binaries in every subpackage, define a unified root workspace file that discovers all apps and packages:

// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';

export default defineWorkspace([
  'apps/*/vite.config.ts',
  'apps/*/vitest.config.ts',
  'packages/*/vitest.config.ts',
]);

Step 3: Create Standardized Package Configuration

Inside each package (e.g., packages/utils/vitest.config.ts), create a focused configuration extending shared settings:

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

export default defineConfig({
  test: {
    globals: true,
    environment: 'node', // Use 'happy-dom' or 'jsdom' for UI packages
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/**', 'dist/**'],
    },
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

For frontend packages testing React or Next.js components, install happy-dom (which is significantly faster than jsdom) and specify:

// packages/ui/vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'happy-dom',
    setupFiles: ['./src/test/setup.ts'],
  },
});

Mocking API Translations: From jest to vi

Vitest provides a 1-to-1 compatible mocking utility via the vi object:

Jest APIVitest APINotes
jest.fn()vi.fn()Same signature and call tracking
jest.spyOn()vi.spyOn()Full type preservation
jest.mock()vi.mock()Vitest hoists vi.mock automatically
jest.useFakeTimers()vi.useFakeTimers()Uses @sinonjs/fake-timers internally
jest.clearAllMocks()vi.clearAllMocks()Clears mock history

Example: Mocking Asynchronous Services

// user-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUserProfile } from './user-service';

// Mock external HTTP client module
vi.mock('./api-client', () => ({
  apiClient: {
    get: vi.fn(),
  },
}));

import { apiClient } from './api-client';

describe('fetchUserProfile', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('fetches and transforms user details correctly', async () => {
    const mockUser = { id: 'usr_123', name: 'Alex Doe', email: 'alex@example.com' };
    vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockUser });

    const result = await fetchUserProfile('usr_123');

    expect(apiClient.get).toHaveBeenCalledWith('/users/usr_123');
    expect(result).toEqual({
      id: 'usr_123',
      displayName: 'Alex Doe',
    });
  });
});

Advertisement

Configuring Turborepo Pipeline Caching

To ensure test runs are cached when code is unchanged, update turbo.json:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "vitest.config.ts"],
      "outputs": ["coverage/**"]
    },
    "test:watch": {
      "cache": false,
      "persistent": true
    }
  }
}

Now, running npx turbo run test from the monorepo root will run all package tests concurrently with instant cache hits on unchanged packages:

# Run all tests across the monorepo
npx turbo run test

# Run tests only for a specific package and its dependents
npx turbo run test --filter=@repo/ui...

Real-World CI Benchmark: Jest vs Vitest

The following benchmarks were collected from a production monorepo containing 18 packages and 1,240 test cases running on GitHub Actions (ubuntu-latest, 2 vCPU):

Measurement MetricJest (ts-jest)Vitest (v8 provider)Speedup Factor
Cold CI Run (No Cache)4 min 18 sec34 sec7.6x faster
Warm CI Run (Turborepo Cache)2 min 05 sec4.2 sec30x faster
Peak Memory Consumption2.1 GB RAM480 MB RAM77% memory saved
Watch Mode Feedback Latency2,800 ms180 msNear instantaneous

Frequently Asked Questions

Can I run Vitest with globals: true to avoid importing describe and it?

Yes. Enabling test: { globals: true } in vitest.config.ts makes describe, it, expect, and vi globally available, matching Jest's default behavior. However, you should add "types": ["vitest/globals"] to your tsconfig.json compilerOptions so TypeScript recognizes the global identifiers.

How does Vitest handle CommonJS-only third-party libraries?

Vite handles CommonJS dependencies through automated pre-bundling using esbuild. If an esoteric CommonJS library causes resolution errors during test execution, you can explicitly add it to test: { server: { deps: { inline: ['legacy-package-name'] } } } in your vitest.config.ts.

How do I generate unified monorepo coverage reports?

Run vitest run --coverage from your root workspace. Vitest will gather coverage data across all packages into a unified V8 or Istanbul coverage directory that can be uploaded directly to Codecov or SonarQube.


You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement