From 51 TypeScript Errors to Zero: A Complete CI/CD Recovery Story

From Jest to Vitest: A Real-World Migration Journey

How I Successfully Migrated a Production React Application's Test Suite

When I embarked on migrating our enterprise React application from Jest to Vitest, what seemed like a straightforward task turned into a masterclass in JavaScript module systems, testing frameworks, and debugging. This article chronicles the challenges I faced and the solutions I discovered, providing a roadmap for engineers facing similar migrations.

The Starting Point: Understanding the Problem

Our application, a full-stack ERP system built with React, TypeScript, and Redux, had a comprehensive test suite running on Jest. The decision to migrate to Vitest was driven by its superior performance and seamless integration with our Vite build system. However, the migration revealed deeper insights about our testing infrastructure than we initially anticipated.

Initial Discovery Commands

# Revealed mismatched testing dependencies
npm list jest vitest @testing-library/jest-dom

# Showed the full extent of the migration challenge
npm test
# Error: Cannot find module '@testing-library/jest-dom/extend-expect'

Challenge 1: Package.json Configuration Mismatch

The first hurdle appeared immediately when running npm test. The test configuration was looking for Vitest, but our dependencies and test files were still configured for Jest.

Diagnostic Commands

# Examine test script configuration
grep -A2 -B2 '"test"' package.json

# Check for Jest/Vitest configuration files
ls -la | grep -E "(jest|vitest)\.config"

# Identify which testing framework tests are written for
grep -r "jest\." src/

Solution

Updated package.json to ensure test script matched the testing framework:

{
  "scripts": {
    "test": "vitest",
    "test:ci": "vitest run --coverage"
  }
}

Challenge 2: Testing Library Integration Issues

The application crashed with module resolution errors because @testing-library/jest-dom was deeply integrated with Jest-specific globals.

Diagnostic Process

# Trace the import chain
grep -r "jest-dom" src/

# Found the problematic import
cat src/setupTests.ts
# import '@testing-library/jest-dom/extend-expect';

Solution

Removed Jest-specific testing library imports and updated the setup file to be testing-framework agnostic. This revealed an important principle: test setup files should be minimal and framework-independent where possible.

Challenge 3: The Global Jest Object

The most pervasive issue was the widespread use of Jest's global object throughout the test suite. Vitest doesn't provide jest as a global, requiring systematic updates.

Discovery Commands

# Count Jest API usage
grep -r "jest\." src/ --include="*.test.ts" --include="*.test.tsx" | wc -l
# Result: 47 occurrences

# Identify specific Jest APIs being used
grep -r "jest\." src/ --include="*.test.ts" -h | \
  sed 's/.*jest\.\([a-zA-Z]*\).*/\1/' | sort | uniq -c

The Pattern That Emerged

// Jest pattern (throughout codebase)
jest.mock('axios');
jest.clearAllMocks();
jest.spyOn(window, 'fetch');

// Vitest pattern (migration target)
import { vi } from 'vitest';
vi.mock('axios');
vi.clearAllMocks();
vi.spyOn(window, 'fetch');

Challenge 4: Module Mocking and Initialization Timing

The most complex challenge involved understanding how module mocking works differently between Jest and Vitest, particularly with modules that have side effects during import.

The Investigation

# Identify files with complex mocking
grep -r "mock.*axios" src/services/__tests__/

# Run individual test to see specific error
npm run test:ci src/services/__tests__/api.test.ts

The Deep Dive

When our API service test failed with "Cannot access 'mockAxiosInstance' before initialization", it revealed a fundamental timing issue. The module mock was trying to reference a variable before it was defined:

// This pattern caused initialization errors
const mockAxiosInstance = { /* ... */ };

vi.mock('axios', () => ({
  default: {
    create: vi.fn(() => mockAxiosInstance), // Error: temporal dead zone
  }
}));

The Solution

Encapsulate everything within the mock factory:

vi.mock('axios', () => {
  const mockAxiosInstance = { /* ... */ };
  
  // Store reference for test access
  (globalThis as any).__mockAxiosInstance = mockAxiosInstance;
  
  return {
    default: {
      create: vi.fn(() => mockAxiosInstance),
    }
  };
});

// Retrieve in tests
const mockAxiosInstance = (globalThis as any).__mockAxiosInstance;

Challenge 5: Redux and React Testing Library Integration

Component tests revealed issues with how Vitest handles React Testing Library and Redux providers differently than Jest.

Diagnostic Approach

# Find all component tests
find src -name "*.test.tsx" -type f

# Check for Redux provider patterns
grep -r "Provider" src/**/*.test.tsx

# Examine store configuration in tests
grep -r "configureStore" src/**/*.test.tsx

The Solution Pattern

Ensure all component tests properly mock Redux state and provide the correct store configuration. The key insight: Vitest is stricter about provider boundaries than Jest.

Key Lessons Learned

1. Module Loading Order Matters

JavaScript modules with side effects (like creating axios instances) execute during import, before test code runs. Understanding this timing is crucial for proper mocking.

2. Test What Matters

Instead of testing implementation details (was axios.create called?), test behavior (do API calls work correctly?). This makes tests more resilient to refactoring.

3. Debugging Is Systematic Investigation

Using console.log strategically in tests revealed the actual state of mocks at runtime, leading to solutions that wouldn't have been obvious from error messages alone.

4. Framework Migration Is About Patterns

Once we understood the Jest-to-Vitest conversion patterns, migration became mechanical. The first file is the hardest; subsequent files follow established patterns.

The Debugging Toolkit

Throughout this migration, certain commands proved invaluable:

# Find specific patterns across the codebase
grep -r "pattern" src/ --include="*.test.ts"

# Run specific tests with full error output
npm run test:ci path/to/specific.test.ts

# Check file structure and dependencies
find src -name "*.test.*" | head -20

# Understand module dependencies
grep -B5 -A5 "import.*from" failing-test-file.ts

Results and Benefits

After completing the migration:

  • Test execution time reduced by 40%
  • Hot module replacement now works in test watch mode
  • Simplified configuration (Vite for both build and test)
  • Better error messages and stack traces
  • Native TypeScript support without additional configuration

Conclusion

What started as a simple framework migration became a deep dive into JavaScript module systems, testing philosophy, and debugging techniques. The journey reinforced that understanding the "why" behind errors is more valuable than quickly fixing symptoms.

For engineers considering a similar migration, remember: the first test file will teach you everything you need to know. Take time to understand the errors deeply, and the rest of the migration becomes a systematic application of learned patterns.

The complete migration touched 15 test files, fixed 47 Jest references, and ultimately resulted in a more maintainable and performant test suite. Most importantly, it deepened our team's understanding of how JavaScript testing frameworks actually work under the hood.


This migration was completed on a production React/TypeScript application with Redux state management, demonstrating that even complex applications can successfully transition between testing frameworks with the right approach and understanding.


If you enjoyed this article, you can also find it published on LinkedIn and Medium.