测试覆盖率是衡量测试完整性的重要手段,但它不是最终目标。合理使用覆盖率数据,结合有效的测试用例设计原则,才能真正提升项目的质量和维护信心。
19.3.1 理解测试覆盖率
覆盖率度量的是代码在测试执行过程中被触发的比例。常见指标包括:
- 语句覆盖率(Statement):每个可执行语句是否都被执行过。
- 分支覆盖率(Branch):每个条件语句(如
if-else、switch、三元运算符)的各个分支是否都被覆盖。 - 函数覆盖率(Function):每个函数是否至少被调用一次。
- 行覆盖率(Line):每一行可执行代码是否都被执行过。
在 React 项目中,可通过 Jest 的内置覆盖率工具或 Istanbul(nyc)生成报告。
# Jest 直接输出覆盖率
npx jest --coverage
在 package.json 中配置覆盖率阈值,确保关键指标不下降:
{
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts",
"!src/index.tsx",
"!src/reportWebVitals.ts"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
collectCoverageFrom 明确统计范围,排除类型声明、入口文件等无需测试的部分。阈值不追求 100%,而是根据业务关键度设置合理的底线。
19.3.2 覆盖率数据的正确使用
高覆盖率不等于高质量测试。下面几种情况仍可能存在风险:
- 只测了代码路径,未断言行为:测试调用了函数但没有检查返回值或副作用是否正确。
- 缺少边界和异常场景:仅覆盖常规流程,未测试空数据、错误响应、极端输入等。
- 实现细节耦合:测试验证组件内部状态变量而非用户可见的界面,重构时测试会大量失效。
覆盖率应被视为 “未覆盖区域的发现工具”,而非质量证明。重点审查覆盖率报告中未覆盖的逻辑分支,评估是否存在业务风险。
19.3.3 测试用例设计核心原则
原则一:从用户行为出发,而非实现细节
组件测试应模拟用户操作和观察渲染结果,避免直接测试内部 state、方法名或 Hooks 调用顺序。
// ❌ 绑定实现细节:直接检查 state
test('计数器组件 state 正确', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
// ✅ 从用户视角:验证 UI 展示与交互效果
test('点击按钮后计数显示增加', () => {
render(<Counter />);
const button = screen.getByRole('button', { name: /增加/ });
fireEvent.click(button);
expect(screen.getByText(/计数:1/)).toBeInTheDocument();
});
这样的测试在重构组件内部实现(如 state 变量重命名)时依然有效。
原则二:AAA 模式组织测试
每个测试用例按照 Arrange(准备)→ Act(行为)→ Assert(断言) 的结构清晰编写。
test('删除待办事项后列表更新', async () => {
// Arrange:渲染组件并填充初始数据
render(<TodoList />);
await screen.findByText('学习测试');
// Act:点击删除按钮
fireEvent.click(screen.getByRole('button', { name: /删除 学习测试/ }));
// Assert:待办事项不再存在
expect(screen.queryByText('学习测试')).toBeNull();
});
这种模式让测试意图一目了然,降低维护成本。
原则三:覆盖边界与异常情况
正常路径的测试仅能满足基本验证,健壮的应用必须覆盖:
- 空数据或默认状态:列表为空时是否显示占位提示。
- 错误状态:API 请求失败时是否显示错误信息并重试。
- 极限值:输入超长字符串、数量为 0 或负值等。
- 并发操作:快速双击按钮是否导致重复请求。
- 权限缺失:无权限用户能否看到或操作受限内容。
示例:测试空列表状态
test('任务列表为空时显示空状态提示', () => {
// 模拟接口返回空数组
jest.spyOn(api, 'fetchTasks').mockResolvedValue([]);
render(<TaskBoard />);
expect(screen.getByText('暂无任务,创建第一个吧')).toBeInTheDocument();
});
原则四:保持测试独立性
每个测试用例不应依赖其他测试的执行顺序或共享的可变状态。Jest 默认并行运行测试,共享状态可能导致不可预测的失败。每个测试应自行准备所需数据(通过渲染或模拟),并在清理阶段(afterEach)重置模拟。
afterEach(() => {
jest.clearAllMocks();
cleanup(); // React Testing Library 已自动处理
});
原则五:测试可读性优先
测试代码是文档的一部分。用例名称应明确描述行为和预期结果,使用动词和业务语义。
// ❌ 模糊命名
test('按钮功能正常', () => { ... });
// ✅ 明确命名
test('点击"提交"后,按钮禁用并显示加载状态', () => { ... });
当测试失败时,从名称就能快速理解哪个业务场景被破坏。
19.3.4 平衡覆盖率与维护成本
在实际项目中,100% 覆盖率往往带来边际效益递减。建议策略:
- 核心业务逻辑:工具函数、复杂状态机、权限控制等,追求高分支覆盖率(≥90%)。
- UI 组件:重点覆盖交互路径和异常状态,不强迫覆盖所有视觉变体。
- 第三方库封装:简单封装(如一行
axios.get)可忽略单测,依赖 E2E 覆盖。 - 常量或类型定义:无需测试。
记住:测试的信心价值远高于数字指标。一个覆盖关键流程、边界和错误路径的测试集,比一个刷满覆盖率但断言薄弱的测试集有用得多。
19.3.5 实战:为一组件设计完整测试套件
以一个 SearchInput 组件为例,展示完整的测试用例设计:
function SearchInput({ onSearch }) {
const [value, setValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (value.trim()) onSearch(value.trim());
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="搜索..."
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button type="submit">搜索</button>
</form>
);
}
测试用例列表:
- 正常搜索:输入文本,点击搜索,回调被调用且参数正确。
- 空白输入拦截:输入空格或留空,提交时不触发回调。
- 去除首尾空格:输入“ hello ”,回调参数应为“hello”。
- 表单提交事件:通过键盘回车键也能触发搜索。
- 清空后再次搜索:输入、清除、再输入,验证每次搜索结果正确。
通过遵循设计原则,该测试套件稳固且对重构友好。
测试覆盖率是工具,测试用例设计是艺术。两者结合才能构建出真正守护代码质量的测试体系。