编写可测试的JavaScript代码,核心在于管理依赖与副作用。一个函数如果内部直接访问全局变量、操作DOM或发起网络请求,那么测试就必须先搭建完整运行环境,用例会变得脆弱且缓慢。反过来,如果代码从一开始就把外部交互隔离在边界,测试就可以用假实现替代真实依赖,断言也更稳定。

一、可测试代码的基础:纯函数与依赖注入
纯函数是单元测试的理想对象。它接收输入参数并返回结果,不修改外部状态,也不读取隐藏状态。以计算购物车总价为例,函数只依赖传入的商品列表和税率,输出完全可预测,测试无需任何准备。反之,如果函数内部读取了模块级变量或操作DOM,测试就必须先为这些外部状态提供默认值,稍有不慎就会造成用例之间相互影响。
function calculateTotal(items, taxRate = 0.1) {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0) * (1 + taxRate);
}
let currentUser = null;
function renderUser() {
document.querySelector('#user').innerHTML = currentUser.name;
}
上面的calculateTotal只依赖入参,没有任何隐藏输入,因此单测只需构造不同商品数组就能覆盖多种税率场景。而renderUser直接操作DOM并读取全局变量,测试时要先创建模拟DOM节点,再设置全局状态,断言也变得困难。可测试的第一步,就是把这类副作用集中到边界处,而不是散落在业务逻辑中。
依赖注入是隔离外部交互的主要手段。网络请求、数据库读写、本地存储等操作都不应直接写在业务函数里,而应通过参数传入。比如用户数据请求函数可以接收一个httpClient参数,生产环境传入真实的fetch,测试环境传入假实现,这样就能在不启动服务器的情况下验证逻辑。
async function fetchUserData(userId, httpClient = fetch) {
const response = await httpClient('/api/users/' + userId);
if (!response.ok) {
throw new Error('请求失败');
}
return response.json();
}
const fakeClient = async (url) => ({
ok: true,
json: async () => ({ id: 1, name: 'Alice' })
});
这种写法并没有增加多少复杂度,却让测试变得轻松很多。测试时可以只断言fakeClient收到的URL、返回的数据以及异常分支,完全不用考虑真实网络波动或跨域问题。浏览器API也可以抽象为接口,在测试中注入桩对象,生产代码使用真实API,测试代码使用可控假对象。
二、测试框架选型与项目搭建
Jest是目前使用最广的JavaScript测试框架,内置测试运行器、断言库、模拟函数和覆盖率统计,几乎零配置即可运行。Vitest基于Vite构建,运行速度更快,适合现代前端工程。如果项目已经使用Vite,可以直接接入Vitest;如果是Node库或传统前端项目,Jest的生态更成熟,遇到问题时也更容易找到解决方案。
{
"scripts": {
"test": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"collectCoverageFrom": ["src/**/*.js"]
}
}
测试环境配置非常关键。前端组件测试通常需要jsdom环境来模拟DOM结构,而纯Node模块则使用node环境即可。Jest通过testEnvironment切换,Vitest通过environment配置。测试文件建议放在__tests__目录,或者与源文件同名并使用.test.js或.spec.js后缀,这样查找和维护都更方便。
除了默认配置,还可以通过setupFilesAfterEach加载全局的测试辅助函数,通过moduleNameMapper处理路径别名,通过collectCoverageFrom指定覆盖率统计范围。一个良好的初始配置能让后续编写用例的速度大幅提升,也能减少因环境差异导致的测试失败。
三、编写高质量测试用例的实践方法
AAA模式是组织测试用例的通用结构,先准备数据或依赖,再执行被测函数,最后验证结果。使用describe和it分组,能让测试报告更清晰。以纯函数calculateTotal为例,每个测试都独立准备输入,执行后只关注返回值,不依赖前一个用例的状态。
import { calculateTotal } from './cart';
describe('calculateTotal', () => {
it('should apply default tax rate when no rate provided', () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 1 }
];
expect(calculateTotal(items)).toBe(27.5);
});
it('should use custom tax rate', () => {
const items = [{ price: 100, quantity: 1 }];
expect(calculateTotal(items, 0.2)).toBe(120);
});
});
测试替身包括mock、stub和spy。mock函数可以记录调用参数和返回值,stub用于固定依赖行为,spy用于检查调用情况。但过度mock会让测试与实现细节强耦合,重构时大量失效。建议优先对输入输出做断言,只对真正的边界如网络请求、数据库使用替身。下面是对异步请求进行mock的示例。
import { fetchUserData } from './user';
test('fetchUserData returns user when request succeeds', async () => {
const mockClient = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Alice' })
});
const user = await fetchUserData(1, mockClient);
expect(user.name).toBe('Alice');
expect(mockClient).toHaveBeenCalledWith('/api/users/1');
});
异步测试需要返回Promise或使用async/await,Jest会自动等待。定时器可以用useFakeTimers接管,用advanceTimersByTime推进时间,避免真实等待。快照测试适合UI组件,但要谨慎使用,避免对无意义的大快照产生依赖。
jest.useFakeTimers();
test('debounce delays function call', () => {
const callback = jest.fn();
const debounced = debounce(callback, 300);
debounced();
jest.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(1);
});
四、从覆盖率到持续集成:建立完整测试体系
行覆盖率是最直观的指标,但它无法发现遗漏的分支逻辑。分支覆盖率能反映if/else、循环和异常路径。变异测试会修改代码再运行测试,能进一步验证断言的敏感性。建议在CI中设置阈值,比如行覆盖率80%、分支覆盖率75%,低于阈值则阻止合并。这样可以在团队中形成统一的测试质量标准。
将测试纳入CI流水线后,每一次提交都自动运行测试并生成报告。可以结合GitHub Actions、GitLab CI或Jenkins,在pull request阶段执行npm test,失败时直接反馈。测试金字塔原则要求单元测试数量最多,集成测试次之,端到端测试最少,保持快速反馈。单元测试的价值就在于几分钟内定位问题,而不是等整个CI跑完才发现。
测试代码也需要维护。命名清晰的测试用例,把测试目标写在describe或it里;当业务变化时,及时更新断言;删除重复或无意义的测试。可测试代码与现代JavaScript开发流程融合后,单元测试不再是额外负担,而是重构和安全交付的基础。
JavaScript单元测试可测试代码单元测试体系修改时间:2026-09-29 21:42:15