Skip to content

Migrating from Jest to Vitest in the Monorepo

This guide documents the process of migrating test suites from Jest to Vitest in our monorepo structure. It contains lessons learned and best practices for a smooth transition.

Why Vitest?

  • Speed: Vitest is significantly faster than Jest for both test execution and watch mode
  • ESM Compatible: Native ESM support without additional configuration
  • Vite Integration: Works seamlessly with our existing Vite-based build system
  • TypeScript Support: Excellent TypeScript integration with built-in typechecking
  • Jest Compatibility: Most Jest APIs are compatible with Vitest, minimizing code changes (Also look into bun:test if you only need light testing)
  • Workspace Support: Built-in workspace functionality for organizing different test suites

Migration Steps

1. Add Vitest Dependencies

yarn add -D vitest @vitest/ui @vitest/coverage-v8

2. Create a vitest.config.ts File

Use the consolidated workspace approach to handle both unit and integration tests:

import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import path from 'node:path';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  root: import.meta.dirname,
  plugins: [
    nxViteTsPaths(), // Must be first
    tsconfigPaths(),
  ],
  test: {
    name: 'your-app-name',
    isolate: false,
    environment: 'node',
    globals: true,
    coverage: {
      all: true,
      enabled: false,
      clean: true,
      reportsDirectory: path.resolve(import.meta.dirname, 'coverage'),
      exclude: ['dist', '.rollup.cache', 'tests', 'jest.{config,setup,integration}.ts', '*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
    typecheck: {
      enabled: true,
      tsconfig: path.resolve(import.meta.dirname, 'tsconfig.json'),
      ignoreSourceErrors: true,
    },
    // Key part: workspace configuration to handle both test types
    workspace: [
      {
        extends: true, // Inherit from parent config
        test: {
          name: 'your-app-name:unit',
          include: ['tests/unit/**/*.{test,spec}.{ts,js}'],
          setupFiles: [path.resolve(import.meta.dirname, 'vitest.setup.ts')],
          testTimeout: 60_000,
          hookTimeout: 60_000,
        },
      },
      {
        extends: true, // Inherit from parent config
        test: {
          name: 'your-app-name:integration',
          include: ['tests/it/**/*.{test,spec}.{ts,js}'],
          setupFiles: [path.resolve(import.meta.dirname, 'vitest.setup.ts'), path.resolve(import.meta.dirname, 'vitest.integration.setup.ts')],
          testTimeout: 120_000,
        },
      },
    ],
  },
});

3. Create Setup Files

vitest.setup.ts (Common Setup)

// Import globals if needed
import { afterEach, beforeEach, expect, vi } from 'vitest';

// Add custom matchers if needed
expect.extend({});

// Global setup
beforeEach(() => {
  // Add any global setup here
});

afterEach(() => {
  // Clean up after each test
  vi.restoreAllMocks();
});

// Mock environment variables
process.env.NODE_ENV = 'test';

vitest.integration.setup.ts (Integration Test Setup)

import dotenv from 'dotenv';
import path from 'node:path';
import { vi } from 'vitest';

// Load environment variables for integration tests
dotenv.config({
  path: path.resolve(import.meta.dirname, 'tests/it/.env.example'),
});

// Add additional mocks and setup for integration tests
// For example:
vi.mock('@premia/connections/src/redis', async () => {
  const actual = await vi.importActual('@premia/connections/src/redis');
  return {
    ...actual,
    getRedisClient: vi.fn().mockImplementation(() => ({})),
    waitForRedisReady: vi.fn().mockImplementation(() => Promise.resolve()),
  };
});

4. Update package.json

Add scripts to run tests using the workspace projects:

"scripts": {
  "test": "vitest run",
  "test:unit": "vitest run --project your-app-name:unit",
  "test:integration": "vitest run --project your-app-name:integration",
  "test:watch": "vitest --project your-app-name:unit",
  "test:integration:watch": "vitest --project your-app-name:integration"
}

5. Update project.json (for NX)

"test": {
  "executor": "nx:run-commands",
  "defaultConfiguration": "default",
  "configurations": {
    "default": {
      "commands": [
        "echo 'Begin Test! 🧪'",
        "vitest run"
      ]
    },
    "unit": {
      "command": "vitest run --project your-app-name:unit"
    },
    "integration": {
      "command": "vitest run --project your-app-name:integration"
    },
    "watch": {
      "command": "vitest"
    },
    "coverage": {
      "command": "vitest run --coverage"
    }
  },
  "options": {
    "cwd": "{projectRoot}"
  }
}

Test File Migration

API Compatibility

Most Jest APIs are compatible with Vitest. Common replacements:

  • Replace jest.mock() with vi.mock()
  • Replace jest.fn() with vi.fn()
  • Replace jest.spyOn() with vi.spyOn()
  • Replace timer mocks:
  • jest.useFakeTimers() → vi.useFakeTimers()
  • jest.useRealTimers() → vi.useRealTimers()

Test Files

Tests can remain largely unchanged. Ensure imports are properly updated:

// Before (Jest)
import { describe, it, expect, jest } from '@jest/globals';

// After (Vitest) - Option 1
import { describe, it, expect, vi } from 'vitest';

// After (Vitest) - Option 2 (with globals enabled)
// No imports needed as they're globally available

Path Resolution Tips

Path resolution is a common issue when migrating to Vitest. Here are some tips:

  1. Use the NX Vite Plugin: The nxViteTsPaths() plugin helps resolve paths in a NX monorepo
  2. Add tsconfigPaths Plugin: tsconfigPaths() resolves paths defined in tsconfig
  3. Check Alias Configuration: Ensure aliases in Vitest match those in tsconfig.json
  4. Explicitly Define Root: Always set the root property in your Vitest config

If path resolution issues persist, try:

resolve: {
  alias: {
    '@premia/commons': path.resolve(workspaceRoot, "libs/commons-ts/src/index.ts"),
    '@premia/connections': path.resolve(workspaceRoot, "libs/connections-ts/src/index.ts"),
    // Add other aliases as needed
  }
}

Troubleshooting

Tests Not Found

If tests aren't being found:

  1. Check the include patterns in workspace projects
  2. Verify file naming conventions (.spec.ts vs .test.ts)
  3. Run with --dir tests/unit to explicitly set the directory

Path Resolution Issues

For path resolution issues:

  1. Check import statements in test files
  2. Verify the plugins are included and in the correct order
  3. Consider adding explicit aliases

Mock Issues

If mocks aren't working:

  1. Ensure vi.mock() is called before imports
  2. Use async vi.mock() for partial mocking
  3. Check that globals are properly enabled

Best Practices

  1. Organize by Test Type: Use workspace projects to organize unit, integration and other test types
  2. Shared Setup: Extract common setup into a shared setup file
  3. Path Configuration: Ensure path resolution is set up correctly for monorepo structure
  4. Type Checking: Enable type checking in tests for better type safety
  5. Use Project Flag: Run tests with --project your-app-name:unit for specific test suites
  6. Coverage: Configure coverage properly with exclusions for better reports

Comparison with Jest

Advantages

  • Performance: Much faster execution, especially for watch mode
  • ESM Support: Native ESM support without additional configuration
  • Configuration: Simpler configuration with workspace support
  • TypeScript: Better TypeScript integration with built-in type checking

Limitations

  • Snapshots: Some minor differences in snapshot handling
  • Coverage: Slightly different coverage reporting
  • Global Tests: Requires different approach for global test context

Real-World Examples from Our Codebase

Complete Vitest Config with Workspace Functionality

// apps/datastream/vitest.config.ts
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import path from 'node:path';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  root: import.meta.dirname,
  plugins: [
    nxViteTsPaths(), // Must be first
    tsconfigPaths(),
  ],
  test: {
    name: 'datastream',
    isolate: false,
    environment: 'node',
    printConsoleTrace: false,
    globals: true,
    coverage: {
      all: true,
      enabled: false,
      clean: true,
      reportsDirectory: path.resolve(import.meta.dirname, 'coverage'),
      exclude: ['dist', '.rollup.cache', 'tests', 'jest.{config,setup,integration}.ts', '*.{test,spec,config}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
    typecheck: {
      enabled: true,
      tsconfig: path.resolve(import.meta.dirname, 'tsconfig.json'),
      ignoreSourceErrors: true,
    },
    sequence: {
      hooks: 'list',
    },
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: true, // Force single thread to prevent setup issues
        isolate: false,
      },
    },
    retry: 3,
    bail: 1,
    workspace: [
      {
        extends: true,
        test: {
          name: 'datastream:unit',
          include: ['tests/unit/**/*.{test,spec}.{ts,js}'],
          setupFiles: [path.resolve(import.meta.dirname, 'vitest.setup.ts')],
          testTimeout: 60_000,
          hookTimeout: 60_000,
        },
      },
      {
        extends: true,
        test: {
          name: 'datastream:integration',
          include: ['tests/it/**/*.{test,spec}.{ts,js}'],
          setupFiles: [path.resolve(import.meta.dirname, 'vitest.setup.ts'), path.resolve(import.meta.dirname, 'vitest.integration.setup.ts')],
          testTimeout: 120_000,
        },
      },
    ],
  },
});

Common Setup File (vitest.setup.ts)

// apps/datastream/vitest.setup.ts
import { afterEach, beforeEach, expect, vi } from 'vitest';

// Add custom matchers
expect.extend({});

// Global setup
beforeEach(() => {
  // Add any global setup here
});

afterEach(() => {
  // Clean up after each test
  vi.restoreAllMocks();
});

// Set global timeout
vi.setConfig({
  testTimeout: 60_000,
});

// Mock environment variables
process.env.NODE_ENV = 'test';

Integration Test Setup (vitest.integration.setup.ts)

// apps/datastream/vitest.integration.setup.ts
import { getRedisMock } from '@premia/connections/src/redis/mocks';
import axios from 'axios';
import dotenv from 'dotenv';
import { Server } from 'node:http';
import { createServer } from 'node:http';
import process from 'node:process';
import { afterAll, beforeAll, vi } from 'vitest';

import app from './src/app';

// Mock Redis functionality
vi.mock('@premia/connections/src/redis', async () => {
  // Import the actual module
  const actualModule = await vi.importActual('@premia/connections/src/redis');

  return {
    ...actualModule,
    getRedisClient: vi.fn().mockImplementation(() => getRedisMock()),
    waitForRedisReady: vi.fn().mockImplementation(() => Promise.resolve()),
  };
});

// Load the specific .env file for integration tests
dotenv.config({
  path: 'apps/datastream/tests/it/.env.example',
  override: true,
});

// Set default values if env variables are not present
process.env.INTEGRATION_TESTS_RUNTIME_PORT = process.env.INTEGRATION_TESTS_RUNTIME_PORT || '3001';
process.env.DATASTREAM_URL = process.env.DATASTREAM_URL || 'http://localhost:3001';

let server: Server;
export const DATASTREAM_URL = process.env.DATASTREAM_URL;

// Create an Axios client for API tests
export const apiClient = axios.create({
  baseURL: DATASTREAM_URL,
  timeout: 2000,
});

// Setup server for integration tests
beforeAll(async () => {
  try {
    const expressApp = await app();
    server = createServer(expressApp);
    server.listen(process.env.INTEGRATION_TESTS_RUNTIME_PORT);
  } catch (error) {
    console.error('Error starting server:', error);
    server = { close: vi.fn() } as unknown as Server;
  }
});

// Clean up after tests
afterAll(async () => {
  if (server && typeof server.close === 'function') {
    await new Promise<void>((resolve) => {
      server.close(() => resolve());
    });
  }
});

Project Configuration in package.json

// apps/datastream/package.json
"scripts": {
  "test": "vitest run",
  "test:unit": "vitest run tests/unit",
  "test:integration": "vitest run tests/it"
}

NX Configuration in project.json

// apps/datastream/project.json
"test": {
  "executor": "nx:run-commands",
  "defaultConfiguration": "default",
  "configurations": {
    "default": {
      "commands": [
        "echo 'Begin Test! 🧪'",
        "vitest run"
      ]
    },
    "unit": {
      "command": "vitest run --project datastream:unit"
    },
    "integration": {
      "command": "vitest run --project datastream:integration"
    },
    "watch": {
      "command": "vitest"
    },
    "coverage": {
      "command": "vitest run --coverage"
    }
  },
  "options": {
    "cwd": "{projectRoot}"
  }
}

Resources