Jest 测试框架

全面的Jest测试示例,包括单元测试、集成测试、模拟、异步测试和JavaScript/TypeScript应用的高级模式

💻 Jest 基础设置和配置 javascript

🟢 simple

完整的Jest项目设置,包括配置文件、基本测试结构和常用工具

⏱️ 15 min 🏷️ jest, configuration, setup, unit testing
Prerequisites: JavaScript ES6+, npm/yarn
// Jest Configuration Examples

// 1. jest.config.js - Basic configuration
module.exports = {
  // Test environment
  testEnvironment: 'jsdom',

  // Test file patterns
  testMatch: [
    '**/__tests__/**/*.+(ts|tsx|js)',
    '**/*.(test|spec).+(ts|tsx|js)'
  ],

  // Coverage configuration
  collectCoverage: true,
  collectCoverageFrom: [
    'src/**/*.(ts|tsx|js)',
    '!src/**/*.d.ts',
    '!src/index.ts'
  ],
  coverageDirectory: 'coverage',
  coverageReporters: ['text', 'lcov', 'html'],

  // Setup files
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],

  // Module path mapping
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\.(css|less|scss|sass)$': 'identity-obj-proxy'
  },

  // Transform configuration
  transform: {
    '^.+\.(ts|tsx)$': 'ts-jest',
    '^.+\.(js|jsx)$': 'babel-jest'
  },

  // Test timeout
  testTimeout: 10000,

  // Verbose output
  verbose: true
};

// 2. package.json scripts
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage --watchAll=false"
  },
  "devDependencies": {
    "jest": "^29.7.0",
    "@types/jest": "^29.5.8",
    "ts-jest": "^29.1.1",
    "babel-jest": "^29.7.0",
    "@babel/preset-env": "^7.23.6"
  }
}

// 3. src/setupTests.js - Global test setup
import '@testing-library/jest-dom';

// Mock global objects
global.fetch = jest.fn();

// Setup custom matchers
expect.extend({
  toBeWithinRange(received, floor, ceiling) {
    const pass = received >= floor && received <= ceiling;
    if (pass) {
      return {
        message: () =>
          `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      };
    } else {
      return {
        message: () =>
          `expected ${received} to be within range ${floor} - ${ceiling}`,
        pass: false,
      };
    }
  },
});

// 4. Basic test file structure
// src/utils/math.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
};

// src/utils/__tests__/math.test.js
import { add, subtract, multiply, divide } from '../math';

describe('Math Utilities', () => {
  describe('add', () => {
    test('should add two positive numbers', () => {
      expect(add(2, 3)).toBe(5);
    });

    test('should add negative numbers', () => {
      expect(add(-2, -3)).toBe(-5);
    });

    test('should handle zero', () => {
      expect(add(0, 5)).toBe(5);
      expect(add(5, 0)).toBe(5);
    });
  });

  describe('subtract', () => {
    test('should subtract two numbers', () => {
      expect(subtract(10, 4)).toBe(6);
    });
  });

  describe('multiply', () => {
    test('should multiply two numbers', () => {
      expect(multiply(3, 4)).toBe(12);
    });

    test('should handle multiplication by zero', () => {
      expect(multiply(5, 0)).toBe(0);
    });
  });

  describe('divide', () => {
    test('should divide two numbers', () => {
      expect(divide(10, 2)).toBe(5);
    });

    test('should throw error when dividing by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero');
    });
  });
});

💻 异步和Promise测试 javascript

🟡 intermediate ⭐⭐⭐

测试异步代码、promise、async/await、定时器和API调用的全面示例

⏱️ 25 min 🏷️ jest, async, promises, api testing
Prerequisites: JavaScript async/await, Promise basics
// Async and Promise Testing with Jest

// 1. Testing Promises
// src/api/userService.js
export const userService = {
  async getUser(id) {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error('User not found');
    }
    return response.json();
  },

  async createUser(userData) {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(userData),
    });
    return response.json();
  },

  getUsersWithDelay() {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve([{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }]);
      }, 1000);
    });
  }
};

// src/api/__tests__/userService.test.js
import { userService } from '../userService';

// Mock fetch globally
global.fetch = jest.fn();

describe('UserService - Async Tests', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  describe('getUser', () => {
    test('should return user data when user exists', async () => {
      const mockUser = { id: 1, name: 'John Doe', email: '[email protected]' };

      fetch.mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser,
      });

      const result = await userService.getUser(1);

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

    test('should throw error when user not found', async () => {
      fetch.mockResolvedValueOnce({
        ok: false,
      });

      await expect(userService.getUser(999)).rejects.toThrow('User not found');
    });

    // Using resolves matcher
    test('should work with resolves matcher', async () => {
      const mockUser = { id: 1, name: 'John' };
      fetch.mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser,
      });

      await expect(userService.getUser(1)).resolves.toEqual(mockUser);
    });

    // Using rejects matcher
    test('should work with rejects matcher', async () => {
      fetch.mockResolvedValueOnce({
        ok: false,
      });

      await expect(userService.getUser(999)).rejects.toThrow('User not found');
    });
  });

  describe('createUser', () => {
    test('should create user successfully', async () => {
      const newUser = { name: 'New User', email: '[email protected]' };
      const createdUser = { id: 2, ...newUser };

      fetch.mockResolvedValueOnce({
        ok: true,
        json: async () => createdUser,
      });

      const result = await userService.createUser(newUser);

      expect(fetch).toHaveBeenCalledWith('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(newUser),
      });
      expect(result).toEqual(createdUser);
    });
  });
});

// 2. Testing with Timers
// src/utils/timer.js
export const timerUtils = {
  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  },

  async waitForCondition(conditionFn, timeout = 5000) {
    const startTime = Date.now();

    while (Date.now() - startTime < timeout) {
      if (await conditionFn()) {
        return true;
      }
      await new Promise(resolve => setTimeout(resolve, 100));
    }

    throw new Error('Condition not met within timeout');
  }
};

// src/utils/__tests__/timer.test.js
import { timerUtils } from '../timer';

describe('Timer Utils', () => {
  // Use fake timers to control setTimeout
  beforeEach(() => {
    jest.useFakeTimers();
  });

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

  test('delay should resolve after specified time', async () => {
    const delayPromise = timerUtils.delay(1000);

    // Promise should not be resolved yet
    let isResolved = false;
    delayPromise.then(() => {
      isResolved = true;
    });

    expect(isResolved).toBe(false);

    // Fast-forward time
    jest.advanceTimersByTime(1000);

    // Wait for the promise to resolve
    await Promise.resolve();
    expect(isResolved).toBe(true);
  });

  test('waitForCondition should work with fake timers', async () => {
    let conditionMet = false;

    // Mock condition that becomes true after 2 seconds
    const conditionFn = jest.fn(async () => {
      await timerUtils.delay(100);
      return conditionMet;
    });

    const waitForPromise = timerUtils.waitForCondition(conditionFn, 5000);

    // Fast-forward 1 second - condition should still be false
    jest.advanceTimersByTime(1100);
    await Promise.resolve();

    // Set condition to true and fast-forward more
    conditionMet = true;
    jest.advanceTimersByTime(200);
    await Promise.resolve();

    // Promise should now be resolved
    await expect(waitForPromise).resolves.toBe(true);
  });
});

// 3. Testing async callbacks and event handlers
// src/events/eventEmitter.js
export class EventEmitter {
  constructor() {
    this.events = {};
  }

  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }

  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(callback => {
        // Simulate async callback
        setTimeout(() => callback(data), 0);
      });
    }
  }
}

// src/events/__tests__/eventEmitter.test.js
import { EventEmitter } from '../eventEmitter';

describe('EventEmitter - Async Callbacks', () => {
  test('should execute async callbacks', async () => {
    const emitter = new EventEmitter();
    const callback = jest.fn();

    emitter.on('test-event', callback);
    emitter.emit('test-event', { message: 'hello' });

    // Callback should not be called immediately due to setTimeout
    expect(callback).not.toHaveBeenCalled();

    // Wait for async callbacks to execute
    await new Promise(resolve => setTimeout(resolve, 0));

    expect(callback).toHaveBeenCalledWith({ message: 'hello' });
    expect(callback).toHaveBeenCalledTimes(1);
  });

  test('should handle multiple async callbacks', async () => {
    const emitter = new EventEmitter();
    const callback1 = jest.fn();
    const callback2 = jest.fn();

    emitter.on('test-event', callback1);
    emitter.on('test-event', callback2);
    emitter.emit('test-event', { data: 'test' });

    // Wait for all async callbacks
    await new Promise(resolve => setTimeout(resolve, 0));

    expect(callback1).toHaveBeenCalledWith({ data: 'test' });
    expect(callback2).toHaveBeenCalledWith({ data: 'test' });
  });
});

💻 模拟和存根策略 javascript

🟡 intermediate ⭐⭐⭐

高级模拟技术,包括模块模拟、函数模拟、API模拟和自定义模拟实现

⏱️ 30 min 🏷️ jest, mocking, testing patterns
Prerequisites: JavaScript modules, ES6 classes, async/await
// Advanced Mocking Strategies with Jest

// 1. Function Mocking
// src/utils/payment.js
export const paymentService = {
  processPayment: async (amount, cardNumber) => {
    // External API call
    const response = await fetch('/api/payments', {
      method: 'POST',
      body: JSON.stringify({ amount, cardNumber })
    });
    return response.json();
  },

  validateCard: (cardNumber) => {
    // Complex validation logic
    return /^[0-9]{16}$/.test(cardNumber.replace(/\s/g, ''));
  }
};

// src/utils/__tests__/payment.test.js
import { paymentService } from '../payment';

describe('Payment Service - Mocking Examples', () => {
  beforeEach(() => {
    // Reset all mocks before each test
    jest.clearAllMocks();
  });

  test('should mock processPayment function', async () => {
    // Mock the entire function
    const mockProcessPayment = jest.spyOn(paymentService, 'processPayment');
    mockProcessPayment.mockResolvedValue({
      success: true,
      transactionId: '12345'
    });

    const result = await paymentService.processPayment(100, '4111111111111111');

    expect(mockProcessPayment).toHaveBeenCalledWith(100, '4111111111111111');
    expect(result).toEqual({ success: true, transactionId: '12345' });
  });

  test('should mock card validation', () => {
    const mockValidateCard = jest.spyOn(paymentService, 'validateCard');
    mockValidateCard.mockReturnValue(true);

    const isValid = paymentService.validateCard('invalid-card');

    expect(mockValidateCard).toHaveBeenCalledWith('invalid-card');
    expect(isValid).toBe(true);
  });
});

// 2. Module Mocking
// src/services/api.js
import axios from 'axios';

export const apiService = {
  async getUsers() {
    const response = await axios.get('/api/users');
    return response.data;
  },

  async createUser(userData) {
    const response = await axios.post('/api/users', userData);
    return response.data;
  }
};

// src/services/__tests__/api.test.js
import axios from 'axios';
import { apiService } from '../api';

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

describe('API Service - Module Mocking', () => {
  test('should fetch users successfully', async () => {
    const mockUsers = [
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' }
    ];

    // Mock axios.get implementation
    axios.get.mockResolvedValue({
      data: mockUsers
    });

    const users = await apiService.getUsers();

    expect(axios.get).toHaveBeenCalledWith('/api/users');
    expect(users).toEqual(mockUsers);
  });

  test('should handle API errors', async () => {
    const errorMessage = 'Network Error';
    axios.get.mockRejectedValue(new Error(errorMessage));

    await expect(apiService.getUsers()).rejects.toThrow(errorMessage);
  });

  test('should create user successfully', async () => {
    const newUser = { name: 'New User', email: '[email protected]' };
    const createdUser = { id: 3, ...newUser };

    axios.post.mockResolvedValue({
      data: createdUser
    });

    const result = await apiService.createUser(newUser);

    expect(axios.post).toHaveBeenCalledWith('/api/users', newUser);
    expect(result).toEqual(createdUser);
  });
});

// 3. Mock Implementations
// src/utils/database.js
export const database = {
  async connect() {
    // Simulate database connection
    await new Promise(resolve => setTimeout(resolve, 100));
    return { connected: true };
  },

  async query(sql, params = []) {
    // Simulate database query
    console.log(`Executing: ${sql}`, params);
    await new Promise(resolve => setTimeout(resolve, 50));
    return { rows: [], rowCount: 0 };
  }
};

// src/utils/__tests__/database.test.js
import { database } from '../database';

describe('Database - Mock Implementations', () => {
  test('should mock database connection', async () => {
    // Create a mock implementation
    const mockConnect = jest.spyOn(database, 'connect');
    mockConnect.mockImplementation(async () => {
      console.log('Mock connection established');
      return { connected: true, mock: true };
    });

    const result = await database.connect();

    expect(mockConnect).toHaveBeenCalled();
    expect(result).toEqual({ connected: true, mock: true });
  });

  test('should mock database query with custom implementation', async () => {
    const mockQuery = jest.spyOn(database, 'query');
    mockQuery.mockImplementation(async (sql, params) => {
      console.log(`Mock query: ${sql}`, params);

      // Return different results based on SQL
      if (sql.includes('SELECT')) {
        return {
          rows: [{ id: 1, name: 'Mock User' }],
          rowCount: 1
        };
      }

      return { rows: [], rowCount: 0 };
    });

    const selectResult = await database.query('SELECT * FROM users');
    const insertResult = await database.query('INSERT INTO users (name) VALUES (?)', ['Test']);

    expect(mockQuery).toHaveBeenCalledTimes(2);
    expect(selectResult.rows).toHaveLength(1);
    expect(insertResult.rows).toHaveLength(0);
  });
});

// 4. Manual Mocks
// __mocks__/localStorage.js
export const localStorageMock = {
  getItem: jest.fn(),
  setItem: jest.fn(),
  removeItem: jest.fn(),
  clear: jest.fn(),
  length: 0,
  key: jest.fn(),
};

export default localStorageMock;

// src/utils/storage.js
export const storageService = {
  saveUser(user) {
    localStorage.setItem('user', JSON.stringify(user));
  },

  getUser() {
    const userStr = localStorage.getItem('user');
    return userStr ? JSON.parse(userStr) : null;
  },

  removeUser() {
    localStorage.removeItem('user');
  }
};

// src/utils/__tests__/storage.test.js
import localStorageMock from '../../__mocks__/localStorage';

// Mock the localStorage module
jest.mock('../localStorage', () => localStorageMock);

import { storageService } from '../storage';

describe('Storage Service - Manual Mocks', () => {
  beforeEach(() => {
    // Clear all mock calls before each test
    localStorageMock.getItem.mockClear();
    localStorageMock.setItem.mockClear();
    localStorageMock.removeItem.mockClear();
  });

  test('should save user to localStorage', () => {
    const user = { id: 1, name: 'John' };

    storageService.saveUser(user);

    expect(localStorageMock.setItem).toHaveBeenCalledWith(
      'user',
      JSON.stringify(user)
    );
  });

  test('should get user from localStorage', () => {
    const user = { id: 1, name: 'John' };

    // Mock the return value
    localStorageMock.getItem.mockReturnValue(JSON.stringify(user));

    const result = storageService.getUser();

    expect(localStorageMock.getItem).toHaveBeenCalledWith('user');
    expect(result).toEqual(user);
  });

  test('should return null when no user in localStorage', () => {
    localStorageMock.getItem.mockReturnValue(null);

    const result = storageService.getUser();

    expect(result).toBeNull();
  });

  test('should remove user from localStorage', () => {
    storageService.removeUser();

    expect(localStorageMock.removeItem).toHaveBeenCalledWith('user');
  });
});

💻 React组件测试 javascript

🟡 intermediate ⭐⭐⭐⭐

使用Jest和React Testing Library的完整React测试示例

⏱️ 35 min 🏷️ jest, react, testing-library, component testing
Prerequisites: React, JavaScript ES6+, JSX
// React Component Testing with Jest and React Testing Library

// 1. Basic Component Testing
// src/components/Button.jsx
import React from 'react';
import PropTypes from 'prop-types';

const Button = ({ children, onClick, disabled = false, variant = 'primary' }) => {
  const buttonClass = `button button--${variant}`;

  return (
    <button
      className={buttonClass}
      onClick={onClick}
      disabled={disabled}
      data-testid="button"
    >
      {children}
    </button>
  );
};

Button.propTypes = {
  children: PropTypes.node.isRequired,
  onClick: PropTypes.func,
  disabled: PropTypes.bool,
  variant: PropTypes.oneOf(['primary', 'secondary', 'danger'])
};

export default Button;

// src/components/__tests__/Button.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from '../Button';

describe('Button Component', () => {
  test('renders with text content', () => {
    render(<Button>Click me</Button>);

    const button = screen.getByTestId('button');
    expect(button).toBeInTheDocument();
    expect(button).toHaveTextContent('Click me');
  });

  test('applies correct variant class', () => {
    render(<Button variant="secondary">Secondary Button</Button>);

    const button = screen.getByTestId('button');
    expect(button).toHaveClass('button', 'button--secondary');
  });

  test('calls onClick handler when clicked', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick}>Click me</Button>);

    const button = screen.getByTestId('button');
    await user.click(button);

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

  test('does not call onClick when disabled', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick} disabled>Disabled Button</Button>);

    const button = screen.getByTestId('button');
    expect(button).toBeDisabled();

    await user.click(button);
    expect(handleClick).not.toHaveBeenCalled();
  });

  test('has correct accessibility attributes', () => {
    render(<Button disabled>Submit</Button>);

    const button = screen.getByTestId('button');
    expect(button).toHaveAttribute('disabled');
  });
});

// 2. Complex Component Testing
// src/components/UserProfile.jsx
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);

        if (!response.ok) {
          throw new Error('User not found');
        }

        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    if (userId) {
      fetchUser();
    }
  }, [userId]);

  if (loading) {
    return <div data-testid="loading">Loading user profile...</div>;
  }

  if (error) {
    return <div data-testid="error">Error: {error}</div>;
  }

  if (!user) {
    return <div data-testid="no-user">No user selected</div>;
  }

  return (
    <div data-testid="user-profile" className="user-profile">
      <div className="user-avatar">
        <img src={user.avatar} alt={`${user.name}'s avatar`} />
      </div>
      <div className="user-info">
        <h2>{user.name}</h2>
        <p>{user.email}</p>
        <p>{user.bio}</p>
      </div>
    </div>
  );
};

UserProfile.propTypes = {
  userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
};

export default UserProfile;

// src/components/__tests__/UserProfile.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserProfile from '../UserProfile';

// Mock fetch globally
global.fetch = jest.fn();

describe('UserProfile Component', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('shows loading state initially', () => {
    fetch.mockImplementation(() => new Promise(() => {})); // Never resolves

    render(<UserProfile userId="1" />);

    expect(screen.getByTestId('loading')).toBeInTheDocument();
    expect(screen.getByTestId('loading')).toHaveTextContent('Loading user profile...');
  });

  test('displays user data after successful fetch', async () => {
    const mockUser = {
      id: 1,
      name: 'John Doe',
      email: '[email protected]',
      bio: 'Software developer',
      avatar: 'https://example.com/avatar.jpg'
    };

    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    });

    render(<UserProfile userId="1" />);

    // Wait for the user profile to appear
    await waitFor(() => {
      expect(screen.getByTestId('user-profile')).toBeInTheDocument();
    });

    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('[email protected]')).toBeInTheDocument();
    expect(screen.getByText('Software developer')).toBeInTheDocument();

    const avatar = screen.getByAltText("John Doe's avatar");
    expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg');
  });

  test('displays error message when fetch fails', async () => {
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 404,
    });

    render(<UserProfile userId="999" />);

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument();
    });

    expect(screen.getByTestId('error')).toHaveTextContent('Error: User not found');
  });

  test('displays no user message when no userId provided', () => {
    render(<UserProfile />);

    expect(screen.getByTestId('no-user')).toBeInTheDocument();
    expect(screen.getByTestId('no-user')).toHaveTextContent('No user selected');
    expect(fetch).not.toHaveBeenCalled();
  });

  test('refetches user when userId changes', async () => {
    const mockUser1 = { id: 1, name: 'User 1', email: '[email protected]' };
    const mockUser2 = { id: 2, name: 'User 2', email: '[email protected]' };

    fetch
      .mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser1,
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => mockUser2,
      });

    const { rerender } = render(<UserProfile userId="1" />);

    await waitFor(() => {
      expect(screen.getByText('User 1')).toBeInTheDocument();
    });

    rerender(<UserProfile userId="2" />);

    await waitFor(() => {
      expect(screen.getByText('User 2')).toBeInTheDocument();
    });

    expect(fetch).toHaveBeenCalledTimes(2);
    expect(fetch).toHaveBeenNthCalledWith(1, '/api/users/1');
    expect(fetch).toHaveBeenNthCalledWith(2, '/api/users/2');
  });
});

// 3. Custom Hook Testing
// src/hooks/useCounter.js
import { useState, useCallback } from 'react';

export const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  const decrement = useCallback(() => {
    setCount(prev => prev - 1);
  }, []);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  return {
    count,
    increment,
    decrement,
    reset
  };
};

// src/hooks/__tests__/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../useCounter';

describe('useCounter Hook', () => {
  test('should initialize with default value', () => {
    const { result } = renderHook(() => useCounter());

    expect(result.current.count).toBe(0);
    expect(typeof result.current.increment).toBe('function');
    expect(typeof result.current.decrement).toBe('function');
    expect(typeof result.current.reset).toBe('function');
  });

  test('should initialize with custom value', () => {
    const { result } = renderHook(() => useCounter(5));

    expect(result.current.count).toBe(5);
  });

  test('should increment count', () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  test('should decrement count', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(9);
  });

  test('should reset count to initial value', () => {
    const { result } = renderHook(() => useCounter(7));

    act(() => {
      result.current.increment();
      result.current.increment();
    });
    expect(result.current.count).toBe(9);

    act(() => {
      result.current.reset();
    });
    expect(result.current.count).toBe(7);
  });

  test('should handle multiple increments', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.increment();
    });

    expect(result.current.count).toBe(3);
  });
});

// 4. Integration Testing with Multiple Components
// src/components/UserForm.jsx
import React, { useState } from 'react';
import PropTypes from 'prop-types';

const UserForm = ({ onSubmit, initialUser = {} }) => {
  const [user, setUser] = useState({
    name: '',
    email: '',
    ...initialUser
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit(user);
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setUser(prev => ({ ...prev, [name]: value }));
  };

  return (
    <form data-testid="user-form" onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name:</label>
        <input
          id="name"
          name="name"
          type="text"
          value={user.name}
          onChange={handleChange}
          data-testid="name-input"
        />
      </div>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          name="email"
          type="email"
          value={user.email}
          onChange={handleChange}
          data-testid="email-input"
        />
      </div>
      <button type="submit" data-testid="submit-button">Submit</button>
    </form>
  );
};

UserForm.propTypes = {
  onSubmit: PropTypes.func.isRequired,
  initialUser: PropTypes.object
};

export default UserForm;

// src/components/__tests__/UserForm.integration.test.jsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserForm from '../UserForm';
import UserProfile from '../UserProfile';

// Mock fetch for UserProfile
global.fetch = jest.fn();

describe('User Management Integration', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('should create user and display in profile', async () => {
    const createdUser = {
      id: 1,
      name: 'John Doe',
      email: '[email protected]',
      bio: 'Software developer',
      avatar: 'https://example.com/avatar.jpg'
    };

    // Mock the API calls
    fetch
      .mockResolvedValueOnce({
        ok: true,
        json: async () => createdUser,
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => createdUser,
      });

    // Test component that combines form and profile
    const UserManagement = () => {
      const [currentUser, setCurrentUser] = React.useState(null);

      const handleUserSubmit = async (userData) => {
        // Simulate API call
        const response = await fetch('/api/users', {
          method: 'POST',
          body: JSON.stringify(userData)
        });
        const user = await response.json();
        setCurrentUser(user);
      };

      return (
        <div>
          <UserForm onSubmit={handleUserSubmit} />
          {currentUser && <UserProfile userId={currentUser.id} />}
        </div>
      );
    };

    render(<UserManagement />);

    // Fill out the form
    const nameInput = screen.getByTestId('name-input');
    const emailInput = screen.getByTestId('email-input');
    const submitButton = screen.getByTestId('submit-button');

    await userEvent.type(nameInput, 'John Doe');
    await userEvent.type(emailInput, '[email protected]');
    await userEvent.click(submitButton);

    // Wait for the user profile to appear
    await waitFor(() => {
      expect(screen.getByTestId('user-profile')).toBeInTheDocument();
    });

    // Verify the user data is displayed
    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('[email protected]')).toBeInTheDocument();
  });
});

💻 测试驱动开发模式 javascript

🔴 complex ⭐⭐⭐⭐

完整的TDD工作流程示例,包括红-绿-重构循环、测试组织和最佳实践

⏱️ 45 min 🏷️ jest, tdd, testing patterns, best practices
Prerequisites: JavaScript advanced, React basics, Testing concepts
// Test-Driven Development (TDD) with Jest

// 1. TDD Example: Building a String Calculator
// Test first, then implementation

// src/calculator/__tests__/stringCalculator.test.js
describe('String Calculator (TDD Example)', () => {
  let stringCalculator;

  beforeEach(() => {
    // Import the implementation we're building
    stringCalculator = require('../stringCalculator').default;
  });

  test('should return 0 for empty string', () => {
    expect(stringCalculator.add("")).toBe(0);
  });

  test('should return number for single number string', () => {
    expect(stringCalculator.add("1")).toBe(1);
    expect(stringCalculator.add("5")).toBe(5);
  });

  test('should return sum for two numbers separated by comma', () => {
    expect(stringCalculator.add("1,2")).toBe(3);
    expect(stringCalculator.add("10,20")).toBe(30);
  });

  test('should return sum for multiple numbers', () => {
    expect(stringCalculator.add("1,2,3,4,5")).toBe(15);
    expect(stringCalculator.add("10,20,30")).toBe(60);
  });

  test('should handle new lines as delimiters', () => {
    expect(stringCalculator.add("1\n2,3")).toBe(6);
    expect(stringCalculator.add("4\n5\n6")).toBe(15);
  });

  test('should support custom delimiters', () => {
    expect(stringCalculator.add("//;\n1;2;3")).toBe(6);
    expect(stringCalculator.add("//|\n4|5|6")).toBe(15);
  });

  test('should throw error for negative numbers', () => {
    expect(() => stringCalculator.add("1,-2,3")).toThrow("negative numbers not allowed: -2");
    expect(() => stringCalculator.add("-1,2,-3")).toThrow("negative numbers not allowed: -1,-3");
  });

  test('should ignore numbers greater than 1000', () => {
    expect(stringCalculator.add("2,1001,3")).toBe(5);
    expect(stringCalculator.add("1000,1001,1002")).toBe(1000);
  });

  test('should support delimiters of any length', () => {
    expect(stringCalculator.add("//[***]\n1***2***3")).toBe(6);
    expect(stringCalculator.add("//[delimiter]\n4delimiter5delimiter6")).toBe(15);
  });

  test('should support multiple delimiters', () => {
    expect(stringCalculator.add("//[*][%]\n1*2%3")).toBe(6);
    expect(stringCalculator.add("//[**][%%]\n4**5%%6")).toBe(15);
  });
});

// Implementation built through TDD
// src/calculator/stringCalculator.js
class StringCalculator {
  add(numbers) {
    if (!numbers) return 0;

    let delimiter = /,|\n/;
    let numbersString = numbers;

    // Check for custom delimiters
    if (numbers.startsWith("//")) {
      const delimiterMatch = numbers.match(/^//(.+)\n(.+)$/);
      if (delimiterMatch) {
        const [, delimiterPart, numberString] = delimiterMatch;

        // Handle multiple delimiters like [*][%]
        const delimiterMatches = delimiterPart.match(/\[([^\]]+)\]/g);
        if (delimiterMatches) {
          const delimiters = delimiterMatches.map(match =>
            match.slice(1, -1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
          );
          delimiter = new RegExp(delimiters.join('|'));
        } else {
          // Single delimiter
          const escapedDelimiter = delimiterPart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
          delimiter = new RegExp(escapedDelimiter);
        }

        numbersString = numberString;
      }
    }

    const numberArray = numbersString.split(delimiter);
    const validNumbers = numberArray
      .map(num => parseInt(num.trim()))
      .filter(num => !isNaN(num))
      .filter(num => num <= 1000);

    const negativeNumbers = validNumbers.filter(num => num < 0);
    if (negativeNumbers.length > 0) {
      throw new Error(`negative numbers not allowed: ${negativeNumbers.join(',')}`);
    }

    return validNumbers.reduce((sum, num) => sum + num, 0);
  }
}

export default StringCalculator;

// 2. TDD Example: Building a Todo List Application
// src/todo/__tests__/todoManager.test.js
describe('Todo Manager (TDD Example)', () => {
  let todoManager;

  beforeEach(() => {
    todoManager = require('../todoManager').default;
    todoManager.clearTodos(); // Reset state for each test
  });

  describe('Adding todos', () => {
    test('should add a new todo', () => {
      const todo = todoManager.addTodo('Buy milk');

      expect(todo).toMatchObject({
        id: expect.any(Number),
        text: 'Buy milk',
        completed: false,
        createdAt: expect.any(Date)
      });
    });

    test('should throw error for empty todo text', () => {
      expect(() => todoManager.addTodo('')).toThrow('Todo text cannot be empty');
      expect(() => todoManager.addTodo('   ')).toThrow('Todo text cannot be empty');
    });

    test('should generate unique IDs', () => {
      const todo1 = todoManager.addTodo('First task');
      const todo2 = todoManager.addTodo('Second task');

      expect(todo1.id).not.toBe(todo2.id);
    });
  });

  describe('Retrieving todos', () => {
    test('should return empty array initially', () => {
      const todos = todoManager.getAllTodos();
      expect(todos).toEqual([]);
    });

    test('should return all todos', () => {
      todoManager.addTodo('Task 1');
      todoManager.addTodo('Task 2');

      const todos = todoManager.getAllTodos();
      expect(todos).toHaveLength(2);
      expect(todos[0].text).toBe('Task 1');
      expect(todos[1].text).toBe('Task 2');
    });

    test('should return only active todos', () => {
      todoManager.addTodo('Active task');
      const completedTodo = todoManager.addTodo('Completed task');
      todoManager.toggleTodo(completedTodo.id);

      const activeTodos = todoManager.getActiveTodos();
      expect(activeTodos).toHaveLength(1);
      expect(activeTodos[0].text).toBe('Active task');
    });

    test('should return only completed todos', () => {
      const activeTodo = todoManager.addTodo('Active task');
      const completedTodo = todoManager.addTodo('Completed task');
      todoManager.toggleTodo(completedTodo.id);

      const completedTodos = todoManager.getCompletedTodos();
      expect(completedTodos).toHaveLength(1);
      expect(completedTodos[0].text).toBe('Completed task');
    });
  });

  describe('Updating todos', () => {
    test('should toggle todo completion', () => {
      const todo = todoManager.addTodo('Test task');
      expect(todo.completed).toBe(false);

      const updatedTodo = todoManager.toggleTodo(todo.id);
      expect(updatedTodo.completed).toBe(true);
      expect(updatedTodo.completedAt).toBeInstanceOf(Date);

      const toggledBack = todoManager.toggleTodo(todo.id);
      expect(toggledBack.completed).toBe(false);
      expect(toggledBack.completedAt).toBeNull();
    });

    test('should update todo text', () => {
      const todo = todoManager.addTodo('Original text');
      const updatedTodo = todoManager.updateTodoText(todo.id, 'Updated text');

      expect(updatedTodo.text).toBe('Updated text');
      expect(updatedTodo.updatedAt).toBeInstanceOf(Date);
    });

    test('should throw error when updating non-existent todo', () => {
      expect(() => todoManager.toggleTodo(999)).toThrow('Todo not found');
      expect(() => todoManager.updateTodoText(999, 'New text')).toThrow('Todo not found');
    });
  });

  describe('Deleting todos', () => {
    test('should delete todo', () => {
      const todo = todoManager.addTodo('Task to delete');
      expect(todoManager.getAllTodos()).toHaveLength(1);

      todoManager.deleteTodo(todo.id);
      expect(todoManager.getAllTodos()).toHaveLength(0);
    });

    test('should throw error when deleting non-existent todo', () => {
      expect(() => todoManager.deleteTodo(999)).toThrow('Todo not found');
    });
  });

  describe('Todo statistics', () => {
    test('should return correct statistics', () => {
      const todo1 = todoManager.addTodo('Task 1');
      const todo2 = todoManager.addTodo('Task 2');
      todoManager.toggleTodo(todo2.id);

      const stats = todoManager.getStatistics();

      expect(stats).toMatchObject({
        total: 2,
        active: 1,
        completed: 1,
        completionRate: 50
      });
    });

    test('should handle empty statistics', () => {
      const stats = todoManager.getStatistics();

      expect(stats).toMatchObject({
        total: 0,
        active: 0,
        completed: 0,
        completionRate: 0
      });
    });
  });
});

// Implementation built through TDD
// src/todo/todoManager.js
class TodoManager {
  constructor() {
    this.todos = [];
    this.nextId = 1;
  }

  addTodo(text) {
    if (!text || text.trim() === '') {
      throw new Error('Todo text cannot be empty');
    }

    const todo = {
      id: this.nextId++,
      text: text.trim(),
      completed: false,
      createdAt: new Date(),
      completedAt: null,
      updatedAt: null
    };

    this.todos.push(todo);
    return todo;
  }

  getAllTodos() {
    return [...this.todos];
  }

  getActiveTodos() {
    return this.todos.filter(todo => !todo.completed);
  }

  getCompletedTodos() {
    return this.todos.filter(todo => todo.completed);
  }

  toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id);
    if (!todo) {
      throw new Error('Todo not found');
    }

    todo.completed = !todo.completed;
    todo.completedAt = todo.completed ? new Date() : null;
    todo.updatedAt = new Date();

    return todo;
  }

  updateTodoText(id, newText) {
    if (!newText || newText.trim() === '') {
      throw new Error('Todo text cannot be empty');
    }

    const todo = this.todos.find(t => t.id === id);
    if (!todo) {
      throw new Error('Todo not found');
    }

    todo.text = newText.trim();
    todo.updatedAt = new Date();

    return todo;
  }

  deleteTodo(id) {
    const todoIndex = this.todos.findIndex(t => t.id === id);
    if (todoIndex === -1) {
      throw new Error('Todo not found');
    }

    this.todos.splice(todoIndex, 1);
  }

  getStatistics() {
    const total = this.todos.length;
    const completed = this.todos.filter(todo => todo.completed).length;
    const active = total - completed;
    const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;

    return {
      total,
      active,
      completed,
      completionRate
    };
  }

  clearTodos() {
    this.todos = [];
    this.nextId = 1;
  }
}

const todoManager = new TodoManager();
export default todoManager;

// 3. Test Organization and Structure
// tests/setup.js
import { configure } from '@testing-library/react';

// Configure Testing Library
configure({ testIdAttribute: 'data-testid' });

// Global test utilities
global.testUtils = {
  createMockUser: (overrides = {}) => ({
    id: 1,
    name: 'Test User',
    email: '[email protected]',
    ...overrides
  }),

  createMockApiResponse: (data, options = {}) => ({
    data,
    status: 200,
    statusText: 'OK',
    headers: {},
    config: {},
    ...options
  }),

  waitForAsync: () => new Promise(resolve => setTimeout(resolve, 0))
};

// Custom matchers
expect.extend({
  toBeValidEmail(received) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    const pass = emailRegex.test(received);

    if (pass) {
      return {
        message: () => `expected ${received} not to be a valid email`,
        pass: true,
      };
    } else {
      return {
        message: () => `expected ${received} to be a valid email`,
        pass: false,
      };
    }
  },

  toBeWithinRange(received, floor, ceiling) {
    const pass = received >= floor && received <= ceiling;
    return {
      message: () =>
        pass
          ? `expected ${received} not to be within range ${floor} - ${ceiling}`
          : `expected ${received} to be within range ${floor} - ${ceiling}`,
      pass,
    };
  }
});

// 4. Best Practices and Patterns
// tests/utils/testUtils.js
export class TestBuilder {
  constructor() {
    this.setupFns = [];
    this.teardownFns = [];
  }

  beforeEach(fn) {
    this.setupFns.push(fn);
    return this;
  }

  afterEach(fn) {
    this.teardownFns.push(fn);
    return this;
  }

  build(testFn) {
    return async () => {
      try {
        // Run setup functions
        for (const setupFn of this.setupFns) {
          await setupFn();
        }

        // Run the actual test
        await testFn();
      } finally {
        // Run teardown functions
        for (const teardownFn of this.teardownFns) {
          await teardownFn();
        }
      }
    };
  }
}

// Usage example:
const testBuilder = new TestBuilder()
  .beforeEach(() => {
    // Setup code
  })
  .afterEach(() => {
    // Cleanup code
  });

test('my test', testBuilder.build(() => {
  // Test implementation
}));

// 5. Continuous Integration Testing Script
// scripts/test-ci.js
const { execSync } = require('child_process');

function runCommand(command, description) {
  console.log(`🧪 ${description}...`);
  try {
    execSync(command, { stdio: 'inherit' });
    console.log(`✅ ${description} - PASSED`);
    return true;
  } catch (error) {
    console.log(`❌ ${description} - FAILED`);
    return false;
  }
}

async function runAllTests() {
  const tests = [
    {
      command: 'jest --passWithNoTests --testPathPattern=__tests__',
      description: 'Unit Tests'
    },
    {
      command: 'jest --passWithNoTests --testPathPattern=integration',
      description: 'Integration Tests'
    },
    {
      command: 'jest --coverage --passWithNoTests',
      description: 'Coverage Tests'
    },
    {
      command: 'eslint src/ --ext .js,.jsx,.ts,.tsx',
      description: 'Linting'
    }
  ];

  let allPassed = true;
  for (const test of tests) {
    const passed = runCommand(test.command, test.description);
    if (!passed) {
      allPassed = false;
      // Continue running other tests for full feedback
    }
  }

  if (allPassed) {
    console.log('🎉 All tests passed!');
    process.exit(0);
  } else {
    console.log('💥 Some tests failed!');
    process.exit(1);
  }
}

runAllTests().catch(console.error);