正在加载,请稍候…

JavaScript Testing Guide: Jest vs Vitest, Unit Tests, and Integration Testing

Complete guide to JavaScript testing in 2026. Compare Jest and Vitest, learn unit testing patterns, mocking strategies, React component testing, and CI/CD integration.

JavaScript Testing Guide: Jest vs Vitest, Unit Tests, and Integration Testing

Writing tests is an investment that pays dividends. This guide covers everything from setting up your testing infrastructure to writing effective tests that catch real bugs.

Jest vs Vitest: Which to Choose?

Feature Jest Vitest
Startup speed ~2-5s ~200ms
Watch mode Good Excellent
Config required Moderate Minimal
Vite projects Complex setup Native
TypeScript via Babel/ts-jest Native
Snapshot testing
Coverage
Ecosystem Massive Growing fast

Rule of thumb: Vite project → Vitest. Create React App / legacy → Jest.

Setting Up Vitest

npm install --save-dev vitest @vitest/coverage-v8 happy-dom
// vite.config.ts (or vitest.config.ts)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'happy-dom',  // jsdom-like browser env
    globals: true,             // No need to import describe/it/expect
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
    },
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom';  // Adds .toBeInTheDocument(), .toHaveClass(), etc.
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

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

Setting Up Jest

npm install --save-dev jest @types/jest ts-jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterFramework: ['@testing-library/jest-dom'],
  moduleNameMapper: {
    '^@/(.*)
#39;: '<rootDir>/src/$1', // Path aliases '\.(css|scss)
#39;: 'identity-obj-proxy', // CSS modules mock '\.(jpg|png|svg)
#39;: '<rootDir>/src/__mocks__/fileMock.js', }, collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}'], };

Unit Testing Patterns

Testing Pure Functions

// utils/formatters.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}

export function truncateText(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength - 3) + '...';
}

export function parseJWT(token: string): { header: object; payload: object } | null {
  try {
    const parts = token.split('.');
    if (parts.length !== 3) return null;
    return {
      header: JSON.parse(atob(parts[0])),
      payload: JSON.parse(atob(parts[1])),
    };
  } catch {
    return null;
  }
}
// utils/formatters.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, truncateText, parseJWT } from './formatters';

describe('formatCurrency', () => {
  it('formats USD amounts correctly', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56');
    expect(formatCurrency(0)).toBe('$0.00');
    expect(formatCurrency(1000000)).toBe('$1,000,000.00');
  });
  
  it('supports different currencies', () => {
    expect(formatCurrency(100, 'EUR')).toBe('€100.00');
    expect(formatCurrency(100, 'GBP')).toBe('£100.00');
  });
});

describe('truncateText', () => {
  it('returns text unchanged if within limit', () => {
    expect(truncateText('Hello', 10)).toBe('Hello');
    expect(truncateText('Hello', 5)).toBe('Hello');
  });
  
  it('truncates long text with ellipsis', () => {
    expect(truncateText('Hello World', 8)).toBe('Hello...');
    expect(truncateText('JavaScript is awesome', 15)).toBe('JavaScript is...');
  });
});

describe('parseJWT', () => {
  const validToken = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature';
  
  it('parses valid JWT structure', () => {
    const result = parseJWT(validToken);
    expect(result).not.toBeNull();
    expect(result?.payload).toEqual({ sub: '123' });
  });
  
  it('returns null for invalid tokens', () => {
    expect(parseJWT('not-a-jwt')).toBeNull();
    expect(parseJWT('')).toBeNull();
    expect(parseJWT('only.two')).toBeNull();
  });
});

Mocking

Mocking Modules

// api/userApi.ts
export async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUserProfile } from './userService';
import * as userApi from '../api/userApi';

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

describe('getUserProfile', () => {
  beforeEach(() => {
    vi.clearAllMocks();  // Reset mocks between tests
  });
  
  it('returns formatted user profile', async () => {
    // Set up mock return value
    vi.mocked(userApi.fetchUser).mockResolvedValueOnce({
      id: '123',
      first_name: 'John',
      last_name: 'Doe',
      email: 'john@example.com',
    });
    
    const profile = await getUserProfile('123');
    
    expect(profile).toEqual({
      id: '123',
      fullName: 'John Doe',
      email: 'john@example.com',
    });
    
    expect(userApi.fetchUser).toHaveBeenCalledWith('123');
    expect(userApi.fetchUser).toHaveBeenCalledTimes(1);
  });
  
  it('handles API errors', async () => {
    vi.mocked(userApi.fetchUser).mockRejectedValueOnce(new Error('Network error'));
    
    await expect(getUserProfile('123')).rejects.toThrow('Failed to fetch user profile');
  });
});

Mocking fetch

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

describe('API calls', () => {
  beforeEach(() => {
    // Mock global fetch
    global.fetch = vi.fn();
  });
  
  it('calls the correct endpoint', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: '1', name: 'Alice' }),
    } as Response);
    
    const user = await fetchUser('1');
    
    expect(fetch).toHaveBeenCalledWith('/api/users/1');
    expect(user.name).toBe('Alice');
  });
  
  it('throws on HTTP error', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      status: 404,
    } as Response);
    
    await expect(fetchUser('999')).rejects.toThrow('HTTP 404');
  });
});

React Component Testing

npm install --save-dev @testing-library/react @testing-library/user-event
// components/LoginForm.tsx
export function LoginForm({ onSuccess }: { onSuccess: (user: User) => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    try {
      const user = await login(email, password);
      onSuccess(user);
    } catch (err) {
      setError('Invalid email or password');
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} 
             placeholder="Email" required />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} 
             placeholder="Password" required />
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={loading}>
        {loading ? 'Logging in...' : 'Log In'}
      </button>
    </form>
  );
}
// components/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
import * as authApi from '../api/auth';

vi.mock('../api/auth');

describe('LoginForm', () => {
  const user = userEvent.setup();
  const mockOnSuccess = vi.fn();
  
  beforeEach(() => {
    vi.clearAllMocks();
  });
  
  it('renders form elements', () => {
    render(<LoginForm onSuccess={mockOnSuccess} />);
    
    expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
    expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Log In' })).toBeInTheDocument();
  });
  
  it('submits with user credentials', async () => {
    const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
    vi.mocked(authApi.login).mockResolvedValueOnce(mockUser);
    
    render(<LoginForm onSuccess={mockOnSuccess} />);
    
    await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
    await user.type(screen.getByPlaceholderText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Log In' }));
    
    await waitFor(() => {
      expect(authApi.login).toHaveBeenCalledWith('alice@example.com', 'password123');
      expect(mockOnSuccess).toHaveBeenCalledWith(mockUser);
    });
  });
  
  it('shows error message on login failure', async () => {
    vi.mocked(authApi.login).mockRejectedValueOnce(new Error('Unauthorized'));
    
    render(<LoginForm onSuccess={mockOnSuccess} />);
    
    await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
    await user.type(screen.getByPlaceholderText('Password'), 'wrongpassword');
    await user.click(screen.getByRole('button', { name: 'Log In' }));
    
    expect(await screen.findByRole('alert')).toHaveTextContent('Invalid email or password');
    expect(mockOnSuccess).not.toHaveBeenCalled();
  });
  
  it('disables button while loading', async () => {
    vi.mocked(authApi.login).mockImplementationOnce(
      () => new Promise(resolve => setTimeout(resolve, 1000))
    );
    
    render(<LoginForm onSuccess={mockOnSuccess} />);
    
    await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
    await user.type(screen.getByPlaceholderText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Log In' }));
    
    expect(screen.getByRole('button', { name: 'Logging in...' })).toBeDisabled();
  });
});

Integration Testing with MSW

npm install --save-dev msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Alice Johnson',
      email: 'alice@example.com',
    });
  }),
  
  http.post('/api/auth/login', async ({ request }) => {
    const body = await request.json();
    if (body.email === 'alice@example.com' && body.password === 'correct') {
      return HttpResponse.json({ token: 'jwt-token-here' });
    }
    return new HttpResponse('Unauthorized', { status: 401 });
  }),
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/test/setup.ts
import { server } from '../mocks/server';
import { beforeAll, afterAll, afterEach } from 'vitest';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Coverage Goals

# Run tests with coverage
npx vitest run --coverage
npx jest --coverage

# Coverage thresholds in config
// vitest.config.ts
test: {
  coverage: {
    thresholds: {
      branches: 70,
      functions: 80,
      lines: 80,
      statements: 80,
    }
  }
}

Coverage targets:

  • 80%+ line/function coverage is a reasonable goal
  • 100% coverage ≠ bug-free code
  • Focus on critical paths: auth, payments, data mutations

Running Tests in CI

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci
      
      - name: Run tests
        run: npm test -- --coverage --reporter=verbose
      
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

Summary

Test Type Tool When to Write
Unit tests Vitest/Jest Pure functions, utils, hooks
Component tests RTL + Vitest UI components with interactions
Integration tests MSW + RTL Feature flows, API interactions
E2E tests Playwright/Cypress Critical user journeys

→ Measure performance of different implementations with Benchmark Builder.