Skip to content

测试覆盖率

概述

测试覆盖率是衡量代码测试完整性的重要指标,它帮助识别未测试的代码路径,提高代码质量。本文将全面介绍测试覆盖率的概念、指标、工具配置以及如何提升和维护高质量的测试覆盖率。

覆盖率指标

四大覆盖率指标

typescript
// 示例代码
function divide(a: number, b: number): number {
  if (b === 0) {                    // 分支1
    throw new Error('Division by zero');
  }
  return a / b;                     // 分支2
}

// 测试用例
describe('divide', () => {
  // 覆盖率分析:
  // ✅ 语句覆盖率(Statement Coverage): 100% - 所有语句都执行
  // ✅ 分支覆盖率(Branch Coverage): 100% - 两个分支都覆盖
  // ✅ 函数覆盖率(Function Coverage): 100% - divide函数被调用
  // ✅ 行覆盖率(Line Coverage): 100% - 所有行都执行
  
  it('should divide numbers', () => {
    expect(divide(6, 2)).toBe(3);    // 覆盖分支2
  });
  
  it('should throw error for zero', () => {
    expect(() => divide(6, 0)).toThrow(); // 覆盖分支1
  });
});

语句覆盖率

typescript
function processUser(user: User | null) {
  if (!user) {              // 语句1
    return null;            // 语句2
  }
  
  const name = user.name;   // 语句3
  const email = user.email; // 语句4
  
  return { name, email };   // 语句5
}

// 50%语句覆盖率
test('partial statement coverage', () => {
  const result = processUser({ name: 'John', email: 'john@example.com' });
  expect(result).toEqual({ name: 'John', email: 'john@example.com' });
  // 只覆盖语句3,4,5
});

// 100%语句覆盖率
test('full statement coverage', () => {
  expect(processUser(null)).toBeNull(); // 覆盖语句1,2
  const result = processUser({ name: 'John', email: 'john@example.com' });
  expect(result).toEqual({ name: 'John', email: 'john@example.com' }); // 覆盖语句3,4,5
});

分支覆盖率

typescript
function getDiscount(user: User): number {
  if (user.isPremium) {
    if (user.loyaltyPoints > 1000) {
      return 0.2;  // 分支A
    }
    return 0.1;    // 分支B
  }
  return 0;        // 分支C
}

// 33%分支覆盖率
test('partial branch coverage', () => {
  expect(getDiscount({ isPremium: false, loyaltyPoints: 0 })).toBe(0);
  // 只覆盖分支C
});

// 100%分支覆盖率
describe('full branch coverage', () => {
  it('premium with high loyalty', () => {
    expect(getDiscount({ isPremium: true, loyaltyPoints: 1500 })).toBe(0.2); // 分支A
  });
  
  it('premium with low loyalty', () => {
    expect(getDiscount({ isPremium: true, loyaltyPoints: 500 })).toBe(0.1); // 分支B
  });
  
  it('non-premium', () => {
    expect(getDiscount({ isPremium: false, loyaltyPoints: 0 })).toBe(0); // 分支C
  });
});

函数覆盖率

typescript
// utils.ts
export function add(a: number, b: number) {
  return a + b;
}

export function multiply(a: number, b: number) {
  return a * b;
}

export function subtract(a: number, b: number) {
  return a - b;
}

// 33%函数覆盖率
test('partial function coverage', () => {
  expect(add(1, 2)).toBe(3);
  // 只测试了add函数
});

// 100%函数覆盖率
describe('full function coverage', () => {
  test('add', () => expect(add(1, 2)).toBe(3));
  test('multiply', () => expect(multiply(2, 3)).toBe(6));
  test('subtract', () => expect(subtract(5, 2)).toBe(3));
});

行覆盖率

typescript
function complexCalculation(x: number, y: number) {
  const sum = x + y;        // 行1
  const product = x * y;    // 行2
  const result = 
    sum > 10 ? 
      product : 
      sum;                  // 行3-5
  return result;            // 行6
}

// 行覆盖率统计会计算实际执行的代码行数

Jest覆盖率配置

package.json配置

json
{
  "scripts": {
    "test": "jest",
    "test:coverage": "jest --coverage",
    "test:coverage:watch": "jest --coverage --watch"
  },
  "jest": {
    "collectCoverage": true,
    "collectCoverageFrom": [
      "src/**/*.{js,jsx,ts,tsx}",
      "!src/**/*.d.ts",
      "!src/**/*.stories.{js,jsx,ts,tsx}",
      "!src/**/__tests__/**",
      "!src/index.tsx"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coverageReporters": ["text", "lcov", "html"],
    "coverageDirectory": "coverage"
  }
}

jest.config.js配置

javascript
module.exports = {
  collectCoverage: true,
  
  // 收集覆盖率的文件
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
    '!src/index.tsx',
    '!src/serviceWorker.ts',
  ],
  
  // 覆盖率阈值
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    // 特定路径的阈值
    './src/components/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
    './src/utils/': {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100,
    },
  },
  
  // 报告格式
  coverageReporters: [
    'text',        // 终端输出
    'text-summary', // 简短摘要
    'html',        // HTML报告
    'lcov',        // LCOV格式(CI工具使用)
    'json',        // JSON格式
    'cobertura',   // Cobertura格式
  ],
  
  // 覆盖率报告目录
  coverageDirectory: 'coverage',
  
  // 覆盖率提供者
  coverageProvider: 'v8', // 或 'babel'
};

覆盖率报告

终端报告

bash
npm test -- --coverage

# 输出:
# ----------------------|---------|----------|---------|---------|-------------------
# File                  | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
# ----------------------|---------|----------|---------|---------|-------------------
# All files            |   85.71 |    83.33 |   88.89 |   85.71 |
#  components          |   90.00 |    87.50 |   92.31 |   90.00 |
#   Button.tsx         |   95.00 |    90.00 |   100   |   95.00 | 25-27
#   Input.tsx          |   85.00 |    85.00 |   84.62 |   85.00 | 18,34,56
#  utils               |   80.00 |    75.00 |   83.33 |   80.00 |
#   format.ts          |   100   |    100   |   100   |   100   |
#   validate.ts        |   60.00 |    50.00 |   66.67 |   60.00 | 12-15,28-30
# ----------------------|---------|----------|---------|---------|-------------------

HTML报告

bash
npm test -- --coverage

# 在浏览器中打开: coverage/lcov-report/index.html

HTML报告提供:

  • 📊 可视化覆盖率图表
  • 📁 文件夹和文件级别的覆盖率
  • 🔍 代码高亮显示覆盖/未覆盖的行
  • 📈 覆盖率趋势(配合CI)

JSON报告

json
{
  "total": {
    "lines": { "total": 100, "covered": 85, "skipped": 0, "pct": 85 },
    "statements": { "total": 120, "covered": 102, "skipped": 0, "pct": 85 },
    "functions": { "total": 30, "covered": 27, "skipped": 0, "pct": 90 },
    "branches": { "total": 40, "covered": 33, "skipped": 0, "pct": 82.5 }
  },
  "files": {
    "src/utils/format.ts": {
      "lines": { "total": 20, "covered": 20, "pct": 100 }
    }
  }
}

提升覆盖率

识别未覆盖代码

typescript
// 使用覆盖率报告识别未覆盖的分支
function processOrder(order: Order) {
  if (!order.items || order.items.length === 0) {
    throw new Error('Empty order');
  }
  
  let total = 0;
  for (const item of order.items) {
    total += item.price * item.quantity;
    
    // ❌ 未覆盖分支
    if (item.discount) {
      total -= item.discount;
    }
  }
  
  // ❌ 未覆盖分支
  if (order.coupon) {
    total *= (1 - order.coupon.discount);
  }
  
  return total;
}

// 添加测试覆盖未覆盖的分支
describe('processOrder coverage', () => {
  it('should apply item discount', () => {
    const order = {
      items: [
        { price: 100, quantity: 1, discount: 10 }
      ]
    };
    expect(processOrder(order)).toBe(90);
  });
  
  it('should apply coupon discount', () => {
    const order = {
      items: [{ price: 100, quantity: 1 }],
      coupon: { discount: 0.1 }
    };
    expect(processOrder(order)).toBe(90);
  });
});

边界条件测试

typescript
function calculateAge(birthDate: Date): number {
  const today = new Date();
  let age = today.getFullYear() - birthDate.getFullYear();
  const monthDiff = today.getMonth() - birthDate.getMonth();
  
  if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
    age--;
  }
  
  return age;
}

describe('calculateAge boundary tests', () => {
  // 正常情况
  it('should calculate age', () => {
    const birthDate = new Date('1990-01-01');
    expect(calculateAge(birthDate)).toBeGreaterThan(30);
  });
  
  // 边界: 生日还未到
  it('should handle birthday not yet reached', () => {
    const today = new Date();
    const birthDate = new Date(today.getFullYear() - 20, today.getMonth() + 1, today.getDate());
    expect(calculateAge(birthDate)).toBe(19);
  });
  
  // 边界: 同月但日期未到
  it('should handle same month but date not reached', () => {
    const today = new Date();
    const birthDate = new Date(today.getFullYear() - 20, today.getMonth(), today.getDate() + 1);
    expect(calculateAge(birthDate)).toBe(19);
  });
  
  // 边界: 今天是生日
  it('should handle birthday today', () => {
    const today = new Date();
    const birthDate = new Date(today.getFullYear() - 20, today.getMonth(), today.getDate());
    expect(calculateAge(birthDate)).toBe(20);
  });
});

错误路径覆盖

typescript
async function fetchUserProfile(userId: number) {
  if (userId < 0) {
    throw new Error('Invalid user ID');
  }
  
  try {
    const response = await fetch(`/api/users/${userId}`);
    
    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('User not found');
      }
      throw new Error('Server error');
    }
    
    return await response.json();
  } catch (error) {
    if (error.name === 'NetworkError') {
      throw new Error('Network connection failed');
    }
    throw error;
  }
}

describe('fetchUserProfile error paths', () => {
  it('should throw for invalid ID', async () => {
    await expect(fetchUserProfile(-1)).rejects.toThrow('Invalid user ID');
  });
  
  it('should throw for 404', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
      status: 404,
    });
    await expect(fetchUserProfile(1)).rejects.toThrow('User not found');
  });
  
  it('should throw for server error', async () => {
    (global.fetch as jest.Mock).mockResolvedValue({
      ok: false,
      status: 500,
    });
    await expect(fetchUserProfile(1)).rejects.toThrow('Server error');
  });
  
  it('should handle network error', async () => {
    const networkError = new Error('Failed to fetch');
    networkError.name = 'NetworkError';
    (global.fetch as jest.Mock).mockRejectedValue(networkError);
    
    await expect(fetchUserProfile(1)).rejects.toThrow('Network connection failed');
  });
});

覆盖率最佳实践

合理的覆盖率目标

javascript
// 不同模块的不同标准
module.exports = {
  coverageThreshold: {
    // 全局最低标准
    global: {
      branches: 70,
      functions: 75,
      lines: 75,
      statements: 75,
    },
    
    // 核心业务逻辑 - 高标准
    './src/core/': {
      branches: 90,
      functions: 95,
      lines: 95,
      statements: 95,
    },
    
    // 工具函数 - 最高标准
    './src/utils/': {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100,
    },
    
    // UI组件 - 中等标准
    './src/components/': {
      branches: 75,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

忽略不需要测试的代码

typescript
// 配置忽略
// jest.config.js
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
    '!src/index.tsx',
    '!src/reportWebVitals.ts',
  ],
};

// 使用注释忽略
function debugOnly() {
  /* istanbul ignore next */
  if (process.env.NODE_ENV === 'development') {
    console.log('Debug info');
  }
}

// 忽略整个文件
/* istanbul ignore file */

// 忽略特定行
const x = 1; /* istanbul ignore next */
const y = 2;

关注覆盖质量而非数量

typescript
// ❌ 低质量的100%覆盖率
test('bad coverage', () => {
  const result = complexFunction(input);
  expect(result).toBeDefined(); // 只验证有返回值
});

// ✅ 高质量的覆盖率
describe('good coverage', () => {
  it('should handle valid input', () => {
    const result = complexFunction({ valid: true });
    expect(result.status).toBe('success');
    expect(result.data).toEqual(expectedData);
  });
  
  it('should handle invalid input', () => {
    expect(() => complexFunction({ valid: false }))
      .toThrow('Invalid input');
  });
  
  it('should handle edge case', () => {
    const result = complexFunction({ edge: true });
    expect(result.handled).toBe(true);
  });
});

CI/CD集成

GitHub Actions

yaml
# .github/workflows/test.yml
name: Test Coverage

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'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run tests with coverage
        run: npm test -- --coverage
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/coverage-final.json
          fail_ci_if_error: true
      
      - name: Check coverage thresholds
        run: npm test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'

覆盖率报告服务

yaml
# Codecov配置
# codecov.yml
coverage:
  status:
    project:
      default:
        target: 80%
        threshold: 1%
    patch:
      default:
        target: 80%

comment:
  layout: "reach,diff,flags,files,footer"
  behavior: default
  require_changes: false

覆盖率工具

NYC (Istanbul)

bash
npm install --save-dev nyc

# package.json
{
  "scripts": {
    "test": "nyc mocha"
  },
  "nyc": {
    "reporter": ["html", "text", "lcov"],
    "exclude": [
      "**/*.spec.ts",
      "**/*.test.ts"
    ],
    "all": true
  }
}

覆盖率徽章

markdown
# README.md
[![Coverage Status](https://codecov.io/gh/username/repo/branch/main/graph/badge.svg)](https://codecov.io/gh/username/repo)

[![Coverage](https://img.shields.io/codecov/c/github/username/repo)](https://codecov.io/gh/username/repo)

实战案例

1. React组件覆盖率

typescript
// Button.tsx
interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  loading?: boolean;
  onClick?: () => void;
  children: React.ReactNode;
}

export function Button({
  variant = 'primary',
  size = 'md',
  disabled = false,
  loading = false,
  onClick,
  children,
}: ButtonProps) {
  const handleClick = () => {
    if (!disabled && !loading && onClick) {
      onClick();
    }
  };
  
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled || loading}
      onClick={handleClick}
    >
      {loading ? 'Loading...' : children}
    </button>
  );
}

// Button.test.tsx - 100%覆盖率
describe('Button', () => {
  it('should render primary button', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-primary btn-md');
  });
  
  it('should render all variants', () => {
    const { rerender } = render(<Button variant="secondary">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-secondary');
    
    rerender(<Button variant="danger">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-danger');
  });
  
  it('should render all sizes', () => {
    const { rerender } = render(<Button size="sm">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-sm');
    
    rerender(<Button size="lg">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-lg');
  });
  
  it('should handle click', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();
    
    render(<Button onClick={handleClick}>Click</Button>);
    await user.click(screen.getByRole('button'));
    
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
  
  it('should not click when disabled', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();
    
    render(<Button disabled onClick={handleClick}>Click</Button>);
    await user.click(screen.getByRole('button'));
    
    expect(handleClick).not.toHaveBeenCalled();
  });
  
  it('should not click when loading', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();
    
    render(<Button loading onClick={handleClick}>Click</Button>);
    
    expect(screen.getByText('Loading...')).toBeInTheDocument();
    await user.click(screen.getByRole('button'));
    expect(handleClick).not.toHaveBeenCalled();
  });
});

2. 复杂逻辑覆盖率

typescript
// orderProcessor.ts
export function processOrder(order: Order): ProcessedOrder {
  // 验证订单
  if (!order.items || order.items.length === 0) {
    throw new Error('Empty order');
  }
  
  let subtotal = 0;
  
  // 计算小计
  for (const item of order.items) {
    if (item.quantity <= 0) {
      throw new Error(`Invalid quantity for item ${item.id}`);
    }
    
    let itemTotal = item.price * item.quantity;
    
    // 应用商品折扣
    if (item.discount) {
      itemTotal -= item.discount;
    }
    
    subtotal += itemTotal;
  }
  
  // 应用优惠券
  if (order.coupon) {
    if (order.coupon.minAmount && subtotal < order.coupon.minAmount) {
      throw new Error('Order does not meet coupon minimum');
    }
    subtotal *= (1 - order.coupon.discount);
  }
  
  // 计算运费
  let shipping = 0;
  if (order.shippingMethod === 'express') {
    shipping = 15;
  } else if (order.shippingMethod === 'standard') {
    shipping = subtotal >= 50 ? 0 : 5;
  }
  
  const total = subtotal + shipping;
  
  return {
    orderId: order.id,
    subtotal,
    shipping,
    total,
    items: order.items,
  };
}

// 完整覆盖率测试
describe('processOrder - Full Coverage', () => {
  it('should throw for empty order', () => {
    expect(() => processOrder({ id: '1', items: [] }))
      .toThrow('Empty order');
  });
  
  it('should throw for invalid quantity', () => {
    expect(() => processOrder({
      id: '1',
      items: [{ id: '1', price: 10, quantity: 0 }]
    })).toThrow('Invalid quantity');
  });
  
  it('should calculate basic order', () => {
    const result = processOrder({
      id: '1',
      items: [
        { id: '1', price: 10, quantity: 2 },
        { id: '2', price: 15, quantity: 1 },
      ],
    });
    
    expect(result.subtotal).toBe(35);
    expect(result.total).toBe(35);
  });
  
  it('should apply item discount', () => {
    const result = processOrder({
      id: '1',
      items: [
        { id: '1', price: 100, quantity: 1, discount: 10 },
      ],
    });
    
    expect(result.subtotal).toBe(90);
  });
  
  it('should apply coupon', () => {
    const result = processOrder({
      id: '1',
      items: [{ id: '1', price: 100, quantity: 1 }],
      coupon: { discount: 0.1 },
    });
    
    expect(result.subtotal).toBe(90);
  });
  
  it('should check coupon minimum', () => {
    expect(() => processOrder({
      id: '1',
      items: [{ id: '1', price: 10, quantity: 1 }],
      coupon: { discount: 0.1, minAmount: 50 },
    })).toThrow('Order does not meet coupon minimum');
  });
  
  it('should calculate express shipping', () => {
    const result = processOrder({
      id: '1',
      items: [{ id: '1', price: 10, quantity: 1 }],
      shippingMethod: 'express',
    });
    
    expect(result.shipping).toBe(15);
  });
  
  it('should calculate standard shipping - free', () => {
    const result = processOrder({
      id: '1',
      items: [{ id: '1', price: 50, quantity: 1 }],
      shippingMethod: 'standard',
    });
    
    expect(result.shipping).toBe(0);
  });
  
  it('should calculate standard shipping - paid', () => {
    const result = processOrder({
      id: '1',
      items: [{ id: '1', price: 10, quantity: 1 }],
      shippingMethod: 'standard',
    });
    
    expect(result.shipping).toBe(5);
  });
});

总结

测试覆盖率是代码质量的重要指标,但不应盲目追求100%。关键是:

  • ✅ 设置合理的覆盖率目标
  • ✅ 关注覆盖质量而非数量
  • ✅ 覆盖关键路径和边界条件
  • ✅ 集成到CI/CD流程
  • ✅ 持续监控和改进

通过合理使用覆盖率工具和最佳实践,可以有效提升代码质量和可维护性。