依赖注入的核心思想是把组件依赖的东西从外部传进来,而不是在组件内部硬编码创建。在 React 项目里,这个概念体现在很多地方:组件通过 props 接收回调、通过 Context 接收全局服务、通过 import 引入数据层模块。当我们要为这样的组件写单元测试时,第一个碰到的问题往往就是这些依赖不可控——接口还没开发完、网络请求太慢、localStorage 在测试环境里行为诡异。Jest 的手动模拟机制正是为了解决这类问题而生,它可以在模块加载阶段就把真实实现替换成一个我们完全掌控的假实现。这篇文章会从依赖注入的测试视角出发,详细讲解如何用 Jest 的手动模拟来隔离模块依赖。

为什么测试时需要手动模拟模块
假设有一个用户资料组件,它内部直接 import 了一个 API 模块并调用其 getUser 方法。如果不做任何处理,测试运行时就会真的发起网络请求。这带来三个直接后果:一是测试速度慢,网络往返动辄几百毫秒;二是结果不稳定,接口偶尔超时会导致测试随机失败;三是断言困难,你无法控制接口返回什么数据,也就无法覆盖加载中、加载成功、加载失败这些分支。
手动模拟的本质是在模块系统层面做一次替换。Jest 有自己的模块加载器,当代码执行 import 或 require 时,Jest 会先查这个模块有没有被标记为需要模拟。如果有,就返回我们手工编写的假模块,而不是磁盘上的真实文件。这个替换发生在模块加载期,比运行时打补丁更彻底,也比在组件里写 if (process.env.NODE_ENV === 'test') 这种判断优雅得多。从依赖注入的角度看,相当于测试环境给组件注入了一个假的服务实现,组件代码本身毫不知情,也就不需要为了测试而做出任何妥协。
__mocks__ 目录与 jest.mock 的两种用法
Jest 提供的手动模拟主要有两种落地方式。第一种是在项目根目录或模块同级目录下创建 __mocks__ 文件夹,把同名的模拟文件放进去。以 axios 为例,在根目录下建立 __mocks__/axios.js:
// __mocks__/axios.js const mockAxios = jest.fn(); mockAxios.get = jest.fn(); mockAxios.post = jest.fn(); export default mockAxios;
对于 node_modules 里的第三方包,只要根目录存在对应的 __mocks__ 文件,Jest 会自动应用它,不需要额外调用。但对于项目内部的业务模块,仅创建文件还不够,必须在测试文件里显式声明 jest.mock('./api') 才会生效,这是初学者最常踩的一个坑。第二种方式是直接在测试文件里调用 jest.mock 并传入工厂函数:
// UserProfile.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
import * as api from './api';
// 工厂函数返回的假模块,会替换真实的 ./api 模块
jest.mock('./api', () => ({
getUser: jest.fn(),
updateUser: jest.fn(),
}));
test('加载成功后展示用户名', async () => {
api.getUser.mockResolvedValue({ name: '张三', age: 28 });
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
expect(api.getUser).toHaveBeenCalledWith('1');
});工厂函数方式的优点是模拟内容一目了然,且作用域只限于当前测试文件,不会污染其他用例。需要注意的是 jest.mock 会被 Jest 提升到文件顶部执行,所以即使把它写在 test 回调里面,它也会在整个文件加载时生效。如果工厂函数里引用了外部变量,必须给变量名加 mock 前缀,否则会报提示错误,这是代码提升机制带来的限制。
模拟函数的断言与行为配置
手动模拟的威力来自 jest.fn() 创建的模拟函数。它不仅是一个可以随意返回值的替身,还会记录每一次被调用的参数、返回值和调用顺序,这些记录构成了断言的素材。常用配置方法有 mockReturnValue(固定返回值)、mockResolvedValue(返回兑现的 Promise)、mockRejectedValue(返回拒绝的 Promise)以及 mockImplementation(自定义完整实现)。
在测试错误分支时,让模拟函数抛出异常即可验证组件的容错逻辑:
test('请求失败时展示错误提示', async () => {
api.getUser.mockRejectedValueOnce(new Error('网络超时'));
render(<UserProfile userId="2" />);
await waitFor(() => {
expect(screen.getByText(/加载失败/)).toBeInTheDocument();
});
});断言方面除了 toHaveBeenCalledWith,还可以用 toHaveBeenCalledTimes 校验调用次数,用 toHaveBeenLastCalledWith 校验最后一次调用的参数。一个实用技巧是配合 beforeEach 调用 jest.clearAllMocks(),在每个用例开始前清空调用记录和配置,避免上一个用例的 mockResolvedValue 泄漏到下一个用例,这是保持测试隔离的关键习惯。
与 jest.spyOn 的取舍及单例替换技巧
jest.spyOn 是另一种隔离手段,它保留原模块的其他导出,只拦截指定的方法。如果你只想模拟 getUser 而让 updateUser 走真实逻辑,spyOn 比手动模拟整个模块更合适。但 spyOn 的局限是:如果组件内部用的是 ES Module 默认导出的对象,而打包工具做了属性不可配置的处理,spy 可能会失效。遇到这种情况,回到 jest.mock 加工厂函数的方案更稳妥。
还有一个高频场景是替换模块级单例,比如全局的配置对象、WebSocket 连接或路由实例。思路是在工厂函数里返回一个可配置的对象,然后在测试里通过 requireActual 混入真实实现中不需要模拟的部分:
jest.mock('./storage', () => {
// 保留真实模块中不打算模拟的部分
const actual = jest.requireActual('./storage');
return {
...actual,
getToken: jest.fn(() => 'fake-token'),
};
});最后要提醒一点:手动模拟虽然强大,但也意味着测试与真实实现之间出现了缝隙。如果 API 模块的函数签名变了,模拟版本可能还停留在旧形态,测试照样通过,线上却挂了。因此建议对被模拟的模块本身保留少量集成测试,单元测试中大量使用模拟,两者互补,才能既快又稳地守护代码质量。
React依赖注入Jest手动模拟Manual Mocks模块测试修改时间:2026-09-16 01:15:33