Jest 单元测试框架配置

Jest 是 Facebook 开发的现代 JavaScript 测试框架,具有零配置、内置断言、模拟功能和代码覆盖率等特性,是现代 JavaScript 项目的首选测试解决方案。

  • 零配置: 开箱即用,无需复杂配置
  • 快照测试: 自动生成和比较组件输出快照
  • 并行测试: 自动并行运行测试,提高效率
  • 代码覆盖率: 内置代码覆盖率报告
  • 模拟功能: 强大的 mock 和 spy 功能
  • 监听模式: 文件变化时自动重新运行测试
  • 单元测试
  • 集成测试
  • 快照测试
  • React/Vue 组件测试
  • Node.js 应用测试
# 安装 Jest
npm install --save-dev jest

# 安装类型定义(TypeScript 项目)
npm install --save-dev @types/jest

创建 jest.config.js 文件:

module.exports = {
  // 测试环境
  testEnvironment: 'node', // 'node' | 'jsdom' | 'jsdom-sixteen'
  
  // 测试文件匹配模式
  testMatch: [
    '**/tests/**/*.test.js',
    '**/__tests__/**/*.js',
    '**/?(*.)+(spec|test).js'
  ],
  
  // 覆盖率配置
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coveragePathIgnorePatterns: [
    '/node_modules/',
    '/dist/',
    '/coverage/'
  ],
  
  // 代码转换
  transform: {
    '^.+\\.js$': 'babel-jest',
  },
  
  // 测试设置文件
  setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
  
  // 模块路径映射
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

也可以在 package.json 中配置:

{
  "jest": {
    "testEnvironment": "node",
    "collectCoverage": true,
    "coverageDirectory": "coverage"
  }
}
module.exports = {
  // Node.js 环境(默认)
  testEnvironment: 'node',
  
  // 浏览器环境(需要 DOM API)
  testEnvironment: 'jsdom',
  
  // 自定义环境配置
  testEnvironmentOptions: {
    url: 'http://localhost',
    userAgent: 'Custom User Agent',
  },
  
  // 不同文件使用不同环境
  projects: [
    {
      displayName: 'Node',
      testEnvironment: 'node',
      testMatch: ['<rootDir>/tests/node/**/*.test.js'],
    },
    {
      displayName: 'Browser',
      testEnvironment: 'jsdom',
      testMatch: ['<rootDir>/tests/browser/**/*.test.js'],
    },
  ],
};
module.exports = {
  // 启用覆盖率收集
  collectCoverage: true,
  
  // 覆盖率输出目录
  coverageDirectory: 'coverage',
  
  // 覆盖率收集的文件模式
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
    '!src/index.js',
    '!**/node_modules/**',
  ],
  
  // 忽略覆盖率的路径
  coveragePathIgnorePatterns: [
    '/node_modules/',
    '/tests/',
    '/dist/',
  ],
  
  // 覆盖率报告格式
  coverageReporters: [
    'text',           // 控制台输出
    'lcov',           // 生成 lcov.info 文件
    'html',           // HTML 报告
    'json',           // JSON 格式
    'clover',         // Clover XML 格式
  ],
  
  // 覆盖率阈值
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    './src/utils/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
  },
};
module.exports = {
  // 模块路径映射
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^@tests/(.*)$': '<rootDir>/tests/$1',
    '^@utils/(.*)$': '<rootDir>/src/utils/$1',
  },
  
  // 模块搜索路径
  modulePaths: ['<rootDir>/src'],
  
  // 模块文件扩展名
  moduleFileExtensions: ['js', 'json', 'jsx', 'ts', 'tsx'],
  
  // 解析器选项
  resolver: undefined,
  
  // 模块目录
  moduleDirectories: ['node_modules', 'src'],
};

创建 tests/setup.js

/**
 * Jest 测试环境全局配置
 */

// 全局测试变量
global.testConfig = {
  apiUrl: 'http://localhost:3000/api',
  timeout: 5000,
};

// Mock 全局对象
global.console = {
  ...console,
  // 在测试中隐藏某些日志
  log: jest.fn(),
  debug: jest.fn(),
  info: jest.fn(),
  warn: jest.fn(),
  error: jest.fn(),
};

// 扩展 expect 匹配器
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,
      };
    }
  },
});

// 全局 beforeEach 和 afterEach
beforeEach(() => {
  // 每个测试前的清理工作
  jest.clearAllMocks();
});

afterEach(() => {
  // 每个测试后的清理工作
  jest.resetModules();
});

创建 tests/utils/helpers.test.js

import { greet, isEven, calculateSum } from '../../src/utils/helpers.js';

describe('Helper Functions', () => {
  describe('greet', () => {
    test('should return greeting with name', () => {
      const result = greet('Alice');
      expect(result).toBe('Hello, Alice!');
    });

    test('should handle empty name', () => {
      expect(() => greet('')).toThrow('Name must be a non-empty string');
    });

    test('should handle non-string input', () => {
      expect(() => greet(123)).toThrow('Name must be a non-empty string');
    });
  });

  describe('isEven', () => {
    test('should return true for even numbers', () => {
      expect(isEven(2)).toBe(true);
      expect(isEven(0)).toBe(true);
      expect(isEven(-4)).toBe(true);
    });

    test('should return false for odd numbers', () => {
      expect(isEven(1)).toBe(false);
      expect(isEven(3)).toBe(false);
      expect(isEven(-1)).toBe(false);
    });

    test('should throw error for non-number input', () => {
      expect(() => isEven('2')).toThrow('Input must be a number');
    });
  });

  describe('calculateSum', () => {
    test('should calculate sum of array', () => {
      expect(calculateSum([1, 2, 3, 4])).toBe(10);
    });

    test('should handle empty array', () => {
      expect(calculateSum([])).toBe(0);
    });

    test('should handle negative numbers', () => {
      expect(calculateSum([-1, -2, 3])).toBe(0);
    });
  });
});
// tests/async.test.js
import { fetchUserData, delayedFunction } from '../src/async-functions.js';

describe('Async Functions', () => {
  // Promise 测试
  test('should fetch user data', async () => {
    const userData = await fetchUserData(1);
    expect(userData).toHaveProperty('id', 1);
    expect(userData).toHaveProperty('name');
  });

  // 错误处理测试
  test('should handle fetch error', async () => {
    await expect(fetchUserData(-1)).rejects.toThrow('User not found');
  });

  // 超时测试
  test('should complete within timeout', async () => {
    const start = Date.now();
    await delayedFunction(100);
    const duration = Date.now() - start;
    expect(duration).toBeGreaterThanOrEqual(100);
    expect(duration).toBeLessThan(150);
  }, 200); // 设置测试超时时间

  // 回调函数测试
  test('should handle callback', (done) => {
    const callback = (data) => {
      try {
        expect(data).toBe('success');
        done();
      } catch (error) {
        done(error);
      }
    };
    
    callbackFunction(callback);
  });
});
describe('Mock Examples', () => {
  test('should mock function', () => {
    const mockFn = jest.fn();
    mockFn('arg1', 'arg2');
    
    expect(mockFn).toHaveBeenCalledTimes(1);
    expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
  });

  test('should mock with return value', () => {
    const mockFn = jest.fn(() => 'mocked result');
    const result = mockFn();
    
    expect(result).toBe('mocked result');
  });

  test('should mock with different return values', () => {
    const mockFn = jest.fn()
      .mockReturnValueOnce('first call')
      .mockReturnValueOnce('second call')
      .mockReturnValue('default');
    
    expect(mockFn()).toBe('first call');
    expect(mockFn()).toBe('second call');
    expect(mockFn()).toBe('default');
  });
});
// Mock 整个模块
jest.mock('../src/api-client.js', () => ({
  fetchData: jest.fn(() => Promise.resolve({ data: 'mocked' })),
  postData: jest.fn(() => Promise.resolve({ success: true })),
}));

// 部分 Mock
jest.mock('../src/utils.js', () => ({
  ...jest.requireActual('../src/utils.js'),
  expensiveFunction: jest.fn(() => 'mocked result'),
}));

// 动态 Mock
describe('API Tests', () => {
  beforeEach(() => {
    jest.resetModules();
  });

  test('should use mocked API', async () => {
    const { fetchData } = require('../src/api-client.js');
    const result = await fetchData();
    
    expect(result).toEqual({ data: 'mocked' });
    expect(fetchData).toHaveBeenCalledTimes(1);
  });
});
import * as utils from '../src/utils.js';

describe('Spy Examples', () => {
  test('should spy on module function', () => {
    const spy = jest.spyOn(utils, 'formatDate');
    spy.mockReturnValue('2023-12-22');
    
    const result = utils.formatDate(new Date());
    
    expect(spy).toHaveBeenCalledTimes(1);
    expect(result).toBe('2023-12-22');
    
    spy.mockRestore(); // 恢复原始函数
  });

  test('should spy on object method', () => {
    const obj = {
      method: jest.fn(() => 'original'),
    };
    
    const spy = jest.spyOn(obj, 'method');
    obj.method();
    
    expect(spy).toHaveBeenCalled();
  });
});

package.json 中添加测试脚本:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --coverage --watchAll=false --passWithNoTests",
    "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
    "test:update": "jest --updateSnapshot"
  }
}
  • test: 运行所有测试
  • test:watch: 监听模式,文件变化时自动运行
  • test:coverage: 生成覆盖率报告
  • test:ci: CI/CD 环境专用,不监听文件变化
  • test:debug: 调试模式
  • test:update: 更新快照测试
module.exports = {
  projects: [
    {
      displayName: 'Unit Tests',
      testMatch: ['<rootDir>/tests/unit/**/*.test.js'],
      testEnvironment: 'node',
    },
    {
      displayName: 'Integration Tests',
      testMatch: ['<rootDir>/tests/integration/**/*.test.js'],
      testEnvironment: 'jsdom',
      setupFilesAfterEnv: ['<rootDir>/tests/integration/setup.js'],
    },
    {
      displayName: 'E2E Tests',
      runner: '@jest-runner/electron',
      testMatch: ['<rootDir>/tests/e2e/**/*.test.js'],
    },
  ],
};
// tests/matchers.js
export const toBeValidEmail = (received) => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  const pass = emailRegex.test(received);
  
  return {
    message: () => `expected ${received} ${pass ? 'not ' : ''}to be a valid email`,
    pass,
  };
};

// jest.config.js
module.exports = {
  setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
};

// tests/setup.js
import { toBeValidEmail } from './matchers.js';

expect.extend({
  toBeValidEmail,
});
// custom-transformer.js
module.exports = {
  process(src, filename) {
    // 自定义文件转换逻辑
    return `module.exports = ${JSON.stringify(src)};`;
  },
};

// jest.config.js
module.exports = {
  transform: {
    '\\.txt$': '<rootDir>/custom-transformer.js',
    '\\.js$': 'babel-jest',
  },
};

问题: Jest 不支持 ES6 模块语法
解决: 配置 Babel 转换

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
  ],
};

// jest.config.js
module.exports = {
  transform: {
    '^.+\\.js$': 'babel-jest',
  },
};

问题: 异步测试超时失败
解决: 设置适当的超时时间

// 全局超时设置
// jest.config.js
module.exports = {
  testTimeout: 10000, // 10 秒
};

// 单个测试超时
test('long running test', async () => {
  // 测试代码
}, 15000); // 15 秒

问题: Mock 状态在测试间相互影响
解决: 正确清理 Mock

// jest.config.js
module.exports = {
  clearMocks: true,     // 自动清理 mock 调用
  resetMocks: true,     // 自动重置 mock 实现
  restoreMocks: true,   // 自动恢复 spy
};

// 或在测试中手动清理
beforeEach(() => {
  jest.clearAllMocks();
});
// jest.config.js
module.exports = {
  maxWorkers: '50%',        // 使用 50% 的 CPU 核心
  maxConcurrency: 5,        // 最大并发测试数
  workerIdleMemoryLimit: 0.2, // 内存限制
};
module.exports = {
  cache: true,
  cacheDirectory: '<rootDir>/.jest-cache',
};
module.exports = {
  testPathIgnorePatterns: [
    '/node_modules/',
    '/dist/',
    '/build/',
  ],
  watchPathIgnorePatterns: [
    '/node_modules/',
    '/.git/',
  ],
};
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run tests
      run: npm run test:ci
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage/lcov.info

完善的测试配置确保代码质量和可靠性,是现代 JavaScript 开发的重要组成部分。