JavaScript

Unit Testing with Jest and Vitest in Modern JS Projects

Unit Testing With Jest And Vitest In Modern JS Projects 683x1024

Introduction

Reliable software depends on reliable tests. As JavaScript projects grow, manual testing quickly becomes slow and error-prone. Unit testing solves this by validating small pieces of logic in isolation, catching bugs before they reach production. Today, Jest and Vitest are two of the most popular testing frameworks in the JavaScript ecosystem. While Jest has been the industry standard for years, Vitest has emerged as a modern alternative built for Vite-based projects. In this comprehensive guide, you will learn how unit testing works, master both frameworks with production-ready examples, understand their key differences, and know when to use each tool in modern JavaScript projects.

Why Unit Testing Matters

Unit tests focus on small, predictable pieces of code. Because they test logic in isolation, they catch bugs early and make refactoring safer.

  • Detect bugs before production by validating behavior during development
  • Improve confidence during refactors knowing tests will catch regressions
  • Encourage cleaner code design since testable code is often better designed
  • Speed up development feedback loops with instant verification
  • Document expected behavior through executable specifications

Teams that test consistently ship faster with fewer production issues.

Unit Test Fundamentals

Before diving into frameworks, let’s understand what makes a good unit test:

// The AAA Pattern: Arrange, Act, Assert

// src/utils/calculator.ts
export function calculateDiscount(
  price: number,
  discountPercent: number
): number {
  if (price < 0) throw new Error('Price cannot be negative');
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error('Discount must be between 0 and 100');
  }
  return price * (1 - discountPercent / 100);
}

// src/utils/calculator.test.ts
import { describe, it, expect } from 'vitest'; // or 'jest'
import { calculateDiscount } from './calculator';

describe('calculateDiscount', () => {
  it('should calculate discount correctly', () => {
    // Arrange
    const price = 100;
    const discountPercent = 20;

    // Act
    const result = calculateDiscount(price, discountPercent);

    // Assert
    expect(result).toBe(80);
  });

  it('should return original price when discount is 0', () => {
    expect(calculateDiscount(50, 0)).toBe(50);
  });

  it('should return 0 when discount is 100%', () => {
    expect(calculateDiscount(100, 100)).toBe(0);
  });

  it('should throw error for negative price', () => {
    expect(() => calculateDiscount(-10, 20)).toThrow('Price cannot be negative');
  });

  it('should throw error for invalid discount', () => {
    expect(() => calculateDiscount(100, -5)).toThrow('Discount must be between 0 and 100');
    expect(() => calculateDiscount(100, 150)).toThrow('Discount must be between 0 and 100');
  });
});

Jest: The Industry Standard

Jest is a mature and widely adopted testing framework created by Meta. It works especially well with React and Node.js projects, providing an all-in-one solution.

Installation and Configuration

# Install Jest with TypeScript support
npm install --save-dev jest @types/jest ts-jest

# Or for JavaScript projects
npm install --save-dev jest
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['/src'],
  testMatch: ['**/*.test.ts', '**/*.spec.ts'],
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  moduleNameMapper: {
    '^@/(.*)$': '/src/$1',
  },
  setupFilesAfterEnv: ['/jest.setup.ts'],
};

// jest.config.js for React projects
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['/jest.setup.ts'],
  moduleNameMapper: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '^@/(.*)$': '/src/$1',
  },
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
};

Jest Matchers and Assertions

// Comprehensive matcher examples
describe('Jest Matchers', () => {
  // Equality matchers
  test('equality', () => {
    expect(2 + 2).toBe(4);              // Strict equality (===)
    expect({ a: 1 }).toEqual({ a: 1 }); // Deep equality
    expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }); // Partial match
  });

  // Truthiness matchers
  test('truthiness', () => {
    expect(null).toBeNull();
    expect(undefined).toBeUndefined();
    expect(1).toBeDefined();
    expect(true).toBeTruthy();
    expect(false).toBeFalsy();
  });

  // Number matchers
  test('numbers', () => {
    expect(10).toBeGreaterThan(5);
    expect(10).toBeGreaterThanOrEqual(10);
    expect(5).toBeLessThan(10);
    expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point
  });

  // String matchers
  test('strings', () => {
    expect('Hello World').toMatch(/World/);
    expect('Hello World').toContain('World');
    expect('Hello').toHaveLength(5);
  });

  // Array matchers
  test('arrays', () => {
    const arr = ['apple', 'banana', 'cherry'];
    expect(arr).toContain('banana');
    expect(arr).toHaveLength(3);
    expect(arr).toEqual(expect.arrayContaining(['apple', 'cherry']));
  });

  // Object matchers
  test('objects', () => {
    const user = { name: 'John', age: 30, email: 'john@example.com' };
    expect(user).toHaveProperty('name');
    expect(user).toHaveProperty('name', 'John');
    expect(user).toEqual(expect.objectContaining({ name: 'John' }));
  });

  // Exception matchers
  test('exceptions', () => {
    const throwError = () => { throw new Error('Oops!'); };
    expect(throwError).toThrow();
    expect(throwError).toThrow('Oops!');
    expect(throwError).toThrow(Error);
    expect(throwError).toThrow(/Oops/);
  });
});

Mocking with Jest

// src/services/userService.ts
import { api } from './api';

export interface User {
  id: number;
  name: string;
  email: string;
}

export async function getUser(id: number): Promise {
  const response = await api.get(`/users/${id}`);
  return response.data;
}

export async function createUser(data: Omit): Promise {
  const response = await api.post('/users', data);
  return response.data;
}

// src/services/userService.test.ts
import { getUser, createUser } from './userService';
import { api } from './api';

// Mock the entire api module
jest.mock('./api');

// Type the mocked module
const mockedApi = api as jest.Mocked;

describe('userService', () => {
  beforeEach(() => {
    // Clear all mocks before each test
    jest.clearAllMocks();
  });

  describe('getUser', () => {
    it('should fetch user by id', async () => {
      const mockUser = { id: 1, name: 'John', email: 'john@example.com' };
      mockedApi.get.mockResolvedValue({ data: mockUser });

      const result = await getUser(1);

      expect(result).toEqual(mockUser);
      expect(mockedApi.get).toHaveBeenCalledWith('/users/1');
      expect(mockedApi.get).toHaveBeenCalledTimes(1);
    });

    it('should throw on API error', async () => {
      mockedApi.get.mockRejectedValue(new Error('Network error'));

      await expect(getUser(1)).rejects.toThrow('Network error');
    });
  });

  describe('createUser', () => {
    it('should create a new user', async () => {
      const userData = { name: 'Jane', email: 'jane@example.com' };
      const createdUser = { id: 2, ...userData };
      mockedApi.post.mockResolvedValue({ data: createdUser });

      const result = await createUser(userData);

      expect(result).toEqual(createdUser);
      expect(mockedApi.post).toHaveBeenCalledWith('/users', userData);
    });
  });
});

// Manual mock example
jest.mock('./api', () => ({
  api: {
    get: jest.fn(),
    post: jest.fn(),
    put: jest.fn(),
    delete: jest.fn(),
  },
}));

// Spy on methods
describe('spies', () => {
  it('should spy on object methods', () => {
    const calculator = {
      add: (a: number, b: number) => a + b,
    };

    const spy = jest.spyOn(calculator, 'add');
    
    const result = calculator.add(2, 3);

    expect(spy).toHaveBeenCalledWith(2, 3);
    expect(result).toBe(5);

    spy.mockRestore(); // Clean up
  });
});

Testing Async Code with Jest

// Multiple ways to test async code

// Using async/await (recommended)
test('async/await style', async () => {
  const data = await fetchData();
  expect(data).toBe('data');
});

// Using promises
test('promise style', () => {
  return fetchData().then(data => {
    expect(data).toBe('data');
  });
});

// Using done callback (legacy)
test('callback style', done => {
  fetchDataWithCallback((data) => {
    expect(data).toBe('data');
    done();
  });
});

// Testing rejected promises
test('should reject with error', async () => {
  await expect(fetchBadData()).rejects.toThrow('Bad request');
});

// Testing with fake timers
describe('timers', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test('debounce function', () => {
    const callback = jest.fn();
    const debounced = debounce(callback, 1000);

    debounced();
    debounced();
    debounced();

    expect(callback).not.toHaveBeenCalled();

    jest.advanceTimersByTime(1000);

    expect(callback).toHaveBeenCalledTimes(1);
  });

  test('setTimeout', () => {
    const callback = jest.fn();
    
    setTimeout(callback, 5000);
    
    expect(callback).not.toHaveBeenCalled();
    
    jest.runAllTimers();
    
    expect(callback).toHaveBeenCalled();
  });
});

Vitest: The Modern Alternative

Vitest is a newer testing framework built for modern tooling. It integrates tightly with Vite and supports fast, native ESM workflows with a Jest-compatible API.

Installation and Configuration

# Install Vitest
npm install --save-dev vitest

# For React projects
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom

# For Vue projects
npm install --save-dev vitest @vue/test-utils jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,  // Use global APIs without imports
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
      ],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

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

// Cleanup after each test
afterEach(() => {
  cleanup();
});

Mocking with Vitest

// Vitest mocking with vi
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getUser, createUser } from './userService';
import * as api from './api';

// Mock module
vi.mock('./api', () => ({
  api: {
    get: vi.fn(),
    post: vi.fn(),
  },
}));

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

  it('should fetch user', async () => {
    const mockUser = { id: 1, name: 'John', email: 'john@example.com' };
    vi.mocked(api.api.get).mockResolvedValue({ data: mockUser });

    const result = await getUser(1);

    expect(result).toEqual(mockUser);
    expect(api.api.get).toHaveBeenCalledWith('/users/1');
  });
});

// Inline mock
vi.mock('./config', () => ({
  config: {
    apiUrl: 'http://test-api.com',
    timeout: 5000,
  },
}));

// Mock factory with original
vi.mock('./utils', async (importOriginal) => {
  const actual = await importOriginal();
  return {
    ...actual,
    formatDate: vi.fn(() => '2024-01-01'),
  };
});

// Spy example
describe('spies', () => {
  it('should spy on console.log', () => {
    const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

    console.log('test message');

    expect(consoleSpy).toHaveBeenCalledWith('test message');

    consoleSpy.mockRestore();
  });

  it('should spy on Date', () => {
    const mockDate = new Date('2024-01-15');
    vi.setSystemTime(mockDate);

    expect(new Date()).toEqual(mockDate);

    vi.useRealTimers();
  });
});

// Mock timers
describe('timers', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('should advance timers', async () => {
    const callback = vi.fn();
    
    setTimeout(callback, 1000);
    
    expect(callback).not.toHaveBeenCalled();
    
    await vi.advanceTimersByTimeAsync(1000);
    
    expect(callback).toHaveBeenCalled();
  });
});

Vitest-Specific Features

import { describe, it, expect, vi } from 'vitest';

// In-source testing (tests alongside code)
// src/utils/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest;
  describe('add', () => {
    it('adds two numbers', () => {
      expect(add(1, 2)).toBe(3);
    });
  });
}

// Snapshot testing
describe('snapshots', () => {
  it('should match snapshot', () => {
    const user = {
      id: 1,
      name: 'John',
      email: 'john@example.com',
      createdAt: new Date('2024-01-01'),
    };

    expect(user).toMatchSnapshot();
  });

  it('should match inline snapshot', () => {
    const result = { status: 'success', code: 200 };
    
    expect(result).toMatchInlineSnapshot(`
      {
        "code": 200,
        "status": "success",
      }
    `);
  });
});

// Type testing with expectTypeOf
import { expectTypeOf } from 'vitest';

test('type checking', () => {
  const result = add(1, 2);
  
  expectTypeOf(result).toBeNumber();
  expectTypeOf(add).toBeFunction();
  expectTypeOf(add).parameter(0).toBeNumber();
});

// Concurrent tests
describe.concurrent('concurrent tests', () => {
  it('test 1', async () => {
    await new Promise(r => setTimeout(r, 100));
    expect(true).toBe(true);
  });

  it('test 2', async () => {
    await new Promise(r => setTimeout(r, 100));
    expect(true).toBe(true);
  });
  // Both tests run at the same time
});

// Benchmark testing
import { bench, describe } from 'vitest';

describe('benchmarks', () => {
  bench('array map', () => {
    [1, 2, 3, 4, 5].map(x => x * 2);
  });

  bench('for loop', () => {
    const arr = [1, 2, 3, 4, 5];
    const result = [];
    for (let i = 0; i < arr.length; i++) {
      result.push(arr[i] * 2);
    }
  });
});

Testing React Components

// src/components/Counter.tsx
import { useState } from 'react';

interface CounterProps {
  initialValue?: number;
  onCountChange?: (count: number) => void;
}

export function Counter({ initialValue = 0, onCountChange }: CounterProps) {
  const [count, setCount] = useState(initialValue);

  const increment = () => {
    const newCount = count + 1;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  const decrement = () => {
    const newCount = count - 1;
    setCount(newCount);
    onCountChange?.(newCount);
  };

  return (
    
{count}
); } // src/components/Counter.test.tsx import { render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi } from 'vitest'; import { Counter } from './Counter'; describe('Counter', () => { it('renders with initial value', () => { render(); expect(screen.getByTestId('count')).toHaveTextContent('5'); }); it('increments count when + is clicked', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '+' })); expect(screen.getByTestId('count')).toHaveTextContent('1'); }); it('decrements count when - is clicked', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '-' })); expect(screen.getByTestId('count')).toHaveTextContent('4'); }); it('calls onCountChange when count changes', async () => { const user = userEvent.setup(); const handleChange = vi.fn(); render(); await user.click(screen.getByRole('button', { name: '+' })); expect(handleChange).toHaveBeenCalledWith(1); }); }); // Testing async components // src/components/UserProfile.tsx import { useEffect, useState } from 'react'; export function UserProfile({ userId }: { userId: number }) { const [user, setUser] = useState<{ name: string } | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => { setUser(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [userId]); if (loading) return
Loading...
; if (error) return
Error: {error}
; if (!user) return
User not found
; return
Welcome, {user.name}!
; } // src/components/UserProfile.test.tsx import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { UserProfile } from './UserProfile'; describe('UserProfile', () => { beforeEach(() => { vi.resetAllMocks(); }); it('shows loading state initially', () => { global.fetch = vi.fn(() => new Promise(() => {})); // Never resolves render(); expect(screen.getByText('Loading...')).toBeInTheDocument(); }); it('displays user name after loading', async () => { global.fetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ name: 'John Doe' }), }); render(); await waitFor(() => { expect(screen.getByText('Welcome, John Doe!')).toBeInTheDocument(); }); }); it('displays error on fetch failure', async () => { global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); render(); await waitFor(() => { expect(screen.getByText('Error: Network error')).toBeInTheDocument(); }); }); });

Jest vs Vitest: Detailed Comparison

Feature Jest Vitest
Startup Speed Slower (transform setup) Very fast (native ESM)
Watch Mode Good Excellent (HMR-like)
ESM Support Experimental Native
TypeScript Requires ts-jest Built-in
Vite Integration Manual config First-class
API Compatibility Standard Jest-compatible
Ecosystem Mature, extensive Growing rapidly
Snapshot Testing Built-in Built-in
Coverage Built-in (Istanbul) v8 or Istanbul
Concurrent Tests Per-file Per-test (optional)

Common Mistakes to Avoid

Mistake 1: Testing Implementation Details

// WRONG - Testing internal state
it('should set loading to true', () => {
  const { result } = renderHook(() => useData());
  expect(result.current.internalLoadingState).toBe(true); // Bad!
});

// CORRECT - Test behavior and outcomes
it('should show loading indicator', () => {
  render();
  expect(screen.getByRole('progressbar')).toBeInTheDocument();
});

Mistake 2: Not Clearing Mocks Between Tests

// WRONG - Mocks leak between tests
describe('api tests', () => {
  it('test 1', () => {
    api.get.mockResolvedValue({ data: 'a' });
    // ...
  });

  it('test 2', () => {
    // api.get still has mock from test 1!
  });
});

// CORRECT - Clear mocks in beforeEach
describe('api tests', () => {
  beforeEach(() => {
    jest.clearAllMocks(); // or vi.clearAllMocks()
  });

  it('test 1', () => {
    api.get.mockResolvedValue({ data: 'a' });
  });

  it('test 2', () => {
    // Fresh start!
  });
});

Mistake 3: Mixing Sync and Async Incorrectly

// WRONG - Missing await
it('should fetch data', () => {
  const result = fetchData(); // Returns promise!
  expect(result).toEqual({ data: 'test' }); // Fails!
});

// CORRECT - Await the promise
it('should fetch data', async () => {
  const result = await fetchData();
  expect(result).toEqual({ data: 'test' });
});

Mistake 4: Over-Mocking

// WRONG - Mocking everything
it('should process data', () => {
  const mockParse = jest.fn(() => ({}));
  const mockValidate = jest.fn(() => true);
  const mockFormat = jest.fn(() => 'result');
  // Testing mocks, not real code!
});

// CORRECT - Only mock external dependencies
it('should process data', () => {
  jest.mock('./api'); // Mock external API
  // Use real parse, validate, format functions
  const result = processData(testInput);
  expect(result).toEqual(expectedOutput);
});

When to Choose Each Framework

Choose Jest when:

  • Working on established React/Node.js projects
  • Need maximum ecosystem compatibility
  • Require extensive documentation and community support
  • Using Create React App or similar setups

Choose Vitest when:

  • Using Vite as your build tool
  • Starting a new project
  • Need fast feedback during development
  • Working with native ES modules
  • Want seamless TypeScript support

Conclusion

Unit testing is essential for building reliable JavaScript applications. Both Jest and Vitest provide powerful tools for writing fast, readable, and maintainable tests. Jest remains the safe choice for established projects with its mature ecosystem and extensive documentation. Vitest offers a modern alternative with faster performance and native ESM support, making it ideal for new Vite-based projects.

The good news is that their APIs are nearly identical, so skills transfer easily between them. Focus on writing good tests—testing behavior rather than implementation, keeping tests isolated, and maintaining fast feedback loops—and the framework choice becomes secondary.

For advanced TypeScript patterns, read Advanced TypeScript Types & Generics: Utility Types Explained. For project architecture decisions, see Monorepos with Nx or Turborepo: When and Why. Reference the official Jest documentation and Vitest documentation for the latest features and best practices.

Leave a Comment