Appearance
测试覆盖率
概述
测试覆盖率是衡量代码测试完整性的重要指标,它帮助识别未测试的代码路径,提高代码质量。本文将全面介绍测试覆盖率的概念、指标、工具配置以及如何提升和维护高质量的测试覆盖率。
覆盖率指标
四大覆盖率指标
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.htmlHTML报告提供:
- 📊 可视化覆盖率图表
- 📁 文件夹和文件级别的覆盖率
- 🔍 代码高亮显示覆盖/未覆盖的行
- 📈 覆盖率趋势(配合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
[](https://codecov.io/gh/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流程
- ✅ 持续监控和改进
通过合理使用覆盖率工具和最佳实践,可以有效提升代码质量和可维护性。