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
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()
withvi.mock()
- Replace
jest.fn()
withvi.fn()
- Replace
jest.spyOn()
withvi.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:
- Use the NX Vite Plugin: The
nxViteTsPaths()
plugin helps resolve paths in a NX monorepo - Add tsconfigPaths Plugin:
tsconfigPaths()
resolves paths defined in tsconfig - Check Alias Configuration: Ensure aliases in Vitest match those in tsconfig.json
- 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:
- Check the
include
patterns in workspace projects - Verify file naming conventions (.spec.ts vs .test.ts)
- Run with
--dir tests/unit
to explicitly set the directory
Path Resolution Issues
For path resolution issues:
- Check import statements in test files
- Verify the plugins are included and in the correct order
- Consider adding explicit aliases
Mock Issues
If mocks aren't working:
- Ensure vi.mock() is called before imports
- Use async vi.mock() for partial mocking
- Check that globals are properly enabled
Best Practices
- Organize by Test Type: Use workspace projects to organize unit, integration and other test types
- Shared Setup: Extract common setup into a shared setup file
- Path Configuration: Ensure path resolution is set up correctly for monorepo structure
- Type Checking: Enable type checking in tests for better type safety
- Use Project Flag: Run tests with
--project your-app-name:unit
for specific test suites - 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}"
}
}