各位好!欢迎来到今天的“React Hooks 单元测试与质量门禁”深度研讨会。我是你们的讲师,一个在这个充满报错的代码世界里摸爬滚打多年的“资深编程老司机”。
今天我们不谈虚的,咱们来聊聊怎么对付那些让你头皮发麻的 Hooks。你有没有过这种感觉:写 React Hooks 就像是在走钢丝,下面是万丈深渊,上面是“未定义的值”和“闭包陷阱”。而当你试图去测试这些 Hooks 时,就像是试图给一个喝醉了的魔术师拍照——你永远不知道下一秒他会变出什么,或者会不会直接把相机给吞了。
但是,别慌!今天我们就来一场彻底的“手术”,把 React Hooks 的逻辑分支扒个精光,确保我们的测试覆盖率能像防弹玻璃一样坚不可摧,并且让 CI/CD 里的“质量门禁”乖乖放行。
准备好了吗?咱们开始吧。
第一部分:幽灵闭包——为什么你的测试总是“空空如也”
首先,我们要面对的是 React Hooks 里最神秘的鬼魂——闭包。
很多新手(甚至一些资深老手)在写 useEffect 时,都会遇到一种“薛定谔的 Bug”:你明明写了逻辑,逻辑也跑通了,但是你的测试用例却报错说“函数未定义”或者“状态未更新”。这就是闭包这个幽灵在作祟。
什么是闭包?
简单来说,闭包就是一个函数记住了它诞生时的环境。在 React Hooks 里,这意味着如果你在 useEffect 里依赖了一个变量,而这个变量在组件渲染时发生了变化,useEffect 里捕获的依然是“旧版本”的那个变量。
场景模拟:
假设我们有一个计数器组件,我们在 useEffect 里监听计数器变化,并打印出来。
// BadExample.js
import { useState, useEffect } from 'react';
function BadExample() {
const [count, setCount] = useState(0);
useEffect(() => {
// 这里有个陷阱!
// useEffect 的依赖数组是 [],意味着它只运行一次
// 但是它捕获的是 count=0 时的闭包
console.log('Current count:', count);
}, []); // 注意这个空数组
const handleClick = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
测试陷阱:
如果你写测试,你会发现 handleClick 被调用了,count 变了,但是 console.log 并没有打印出 1,依然打印着 0。为什么?因为 useEffect 的闭包已经被“锁死”在初始渲染的那个瞬间了。
如何解决?
我们要把 count 放进依赖数组里。
// GoodExample.js
useEffect(() => {
console.log('Current count:', count);
}, [count]); // 现在 count 变了,useEffect 会重新运行
测试策略:
对于这种逻辑分支,我们的测试必须验证 useEffect 是否在特定条件下执行了。
// BadExample.test.js
import { renderHook, act } from '@testing-library/react';
import { BadExample } from './BadExample';
test('should log count on change when count is in dependencies', () => {
// 我们需要 mock console.log
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
const { rerender } = renderHook(() => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('Current count:', count);
}, [count]);
return { count, setCount };
});
expect(consoleSpy).toHaveBeenCalledWith('Current count:', 0);
act(() => {
rerender({ count: 1 });
});
// 验证闭包陷阱被修复了
expect(consoleSpy).toHaveBeenCalledWith('Current count:', 1);
consoleSpy.mockRestore();
});
看懂了吗?这就是“质量门禁”的第一关:你必须测试依赖数组是否正确配置。如果你的代码里 useEffect 依赖数组写错了,导致逻辑分支没跑通,测试就会直接报红,门禁就会拦住你。
第二部分:模拟的艺术——别把 API 当真,那是演员的替身
在 React 应用中,我们经常使用 useEffect 去调用后端 API。如果你在单元测试里真的去 fetch 一个不存在的 URL,那你不仅会浪费 CPU 资源,还会让测试跑得像蜗牛一样慢。
测试的精髓在于隔离。我们要测试的是“组件收到数据后做了什么”,而不是“网络是否通畅”。
场景模拟:
一个获取用户信息的自定义 Hook useFetchUser。
// useFetchUser.js
import { useState, useEffect } from 'react';
export function useFetchUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
// 假设这是我们的 API 调用
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [userId]);
return { user, loading, error };
}
测试策略:
我们不能真的去请求 /api/users/123。我们要用 Jest 的 mock 功能来伪造一个 API 服务器。
// useFetchUser.test.js
import { renderHook, waitFor } from '@testing-library/react';
import { useFetchUser } from './useFetchUser';
// 1. Mock 全局 fetch
global.fetch = jest.fn();
describe('useFetchUser', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should fetch user data successfully', async () => {
// 模拟返回数据
const mockUserData = { id: 1, name: 'Alice' };
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUserData,
});
const { result } = renderHook(() => useFetchUser(1));
// 初始状态是 loading
expect(result.current.loading).toBe(true);
expect(result.current.user).toBeNull();
// 等待异步操作完成
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// 验证 fetch 被调用了
expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
// 验证数据被正确设置
expect(result.current.user).toEqual(mockUserData);
});
test('should handle fetch error', async () => {
// 模拟网络错误
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network Error'));
const { result } = renderHook(() => useFetchUser(2));
await waitFor(() => {
expect(result.current.error).toBe('Network Error');
});
expect(result.current.loading).toBe(false);
});
});
覆盖率分析:
看这个覆盖率报告,我们需要确保 fetch 的 then 和 catch 分支都被覆盖了。
- 成功分支:
mockResolvedValue跑通了then。 - 失败分支:
mockRejectedValue跑通了catch。 - Loading 状态:初始渲染验证了
loading: true。
这就是质量门禁的逻辑:如果你的 API 调用逻辑有错误处理,你的测试里必须有对应的错误模拟。否则,一旦线上网络波动,你的应用就会像断了线的风筝。
第三部分:逻辑分支的迷宫——&&、三元运算符与条件渲染
React Hooks 里最让测试人员头疼的,往往不是 Hooks 本身,而是组件内部基于状态的条件渲染。比如 if (user) return ...,或者 loading && <Spinner />。这些逻辑分支如果不小心,很容易被测试漏掉,导致覆盖率报告上出现一个个刺眼的“0%”。
场景模拟:
一个简单的用户卡片组件,根据 loading、error 和 user 状态显示不同内容。
// UserProfile.js
import { useState, useEffect } from 'react';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 模拟异步获取
setTimeout(() => {
if (userId === 404) {
setError('User not found');
} else {
setUser({ id: userId, name: 'Test User' });
}
setLoading(false);
}, 100);
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user data</div>;
return (
<div>
<h1>{user.name}</h1>
<p>ID: {user.id}</p>
</div>
);
}
测试策略:
这里有三条明显的逻辑分支,我们需要三个测试用例来覆盖它们。
// UserProfile.test.js
import { render } from '@testing-library/react';
import { UserProfile } from './UserProfile';
describe('UserProfile Logic Branches', () => {
// 分支 1: Loading 状态
test('should show loading spinner initially', () => {
render(<UserProfile userId={1} />);
const loadingText = document.querySelector('div'); // 或者更精确的选择器
expect(loadingText).toHaveTextContent('Loading...');
});
// 分支 2: Error 状态
test('should show error message when userId is 404', () => {
render(<UserProfile userId={404} />);
const errorElement = document.querySelector('div');
expect(errorElement).toHaveTextContent('Error: User not found');
});
// 分支 3: 成功状态
test('should display user data when successful', () => {
render(<UserProfile userId={1} />);
// 使用 waitFor 等待异步操作完成
const { container } = render(<UserProfile userId={1} />);
// 验证数据渲染
expect(container.querySelector('h1')).toHaveTextContent('Test User');
expect(container.querySelector('p')).toHaveTextContent('ID: 1');
});
});
进阶技巧:使用 fireEvent
有时候逻辑分支依赖的是用户交互,而不是状态变化。比如一个表单提交按钮,只有在输入框有值时才有效。
// FormComponent.js
import { useState } from 'react';
export function FormComponent() {
const [value, setValue] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = () => {
if (value.trim()) {
setSubmitted(true);
}
};
return (
<div>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button onClick={handleSubmit} disabled={!value.trim()}>
Submit
</button>
{submitted && <p>Success!</p>}
</div>
);
}
测试用例:
// FormComponent.test.js
import { render, fireEvent, screen } from '@testing-library/react';
import { FormComponent } from './FormComponent';
test('should not submit if input is empty', () => {
render(<FormComponent />);
const button = screen.getByRole('button', { name: /submit/i });
// 点击按钮,但输入框是空的
fireEvent.click(button);
// 验证成功消息没有出现
expect(screen.queryByText('Success!')).not.toBeInTheDocument();
});
test('should submit and show success message when input has value', () => {
render(<FormComponent />);
const input = screen.getByRole('textbox');
const button = screen.getByRole('button', { name: /submit/i });
// 输入值
fireEvent.change(input, { target: { value: 'Hello' } });
// 此时按钮应该启用
expect(button).not.toBeDisabled();
// 提交
fireEvent.click(button);
// 验证成功
expect(screen.getByText('Success!')).toBeInTheDocument();
});
通过这种方式,我们不仅覆盖了组件的渲染逻辑,还覆盖了用户的交互逻辑分支。这就是质量门禁的“体格检查”。
第四部分:自定义 Hooks 的圣杯——逻辑复用与测试
React Hooks 的强大之处在于自定义 Hook。我们经常把复杂的业务逻辑抽离出来,比如 useForm、useLocalStorage、useDebounce 等。测试自定义 Hook 是单元测试的“终极奥义”,因为它们剥离了 UI 层,直接测试逻辑。
场景模拟:
一个防抖输入框 Hook。用户输入时,不应该立即触发 API,而是等待 500ms 停止输入后再触发。
// useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// 设置定时器
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// 清除定时器:如果用户在 delay 时间内又输入了,就重新计时
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
测试策略:
测试自定义 Hook,我们需要使用 renderHook。这是 React Testing Library 提供的专门测试 Hook 的工具。
// useDebounce.test.js
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
test('should return debounced value', async () => {
// 渲染 Hook,初始值 'hello', 延迟 500ms
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 500), {
initialProps: { value: 'hello' }
});
// 初始值应该立即返回
expect(result.current).toBe('hello');
// 修改值
act(() => {
rerender({ value: 'world' });
});
// 此时返回的应该还是旧值 'hello',因为 500ms 还没到
expect(result.current).toBe('hello');
// 等待 500ms (使用 jest 的 timers)
await waitFor(() => {
expect(result.current).toBe('world');
}, { timeout: 600 });
});
});
进阶:测试清理函数
在 useEffect 中,我们有一个清理函数 clearTimeout。测试这个逻辑分支也很重要。
// useDebounce.test.js (续)
test('should clear timeout on unmount or value change', () => {
jest.useFakeTimers(); // 开启假定时器
const { result, unmount } = renderHook(({ value }) => useDebounce(value, 500), {
initialProps: { value: 'start' }
});
expect(result.current).toBe('start');
// 修改值
act(() => {
rerender({ value: 'middle' });
});
// 此时应该调用了 clearTimeout
// 虽然我们无法直接断言 clearTimeout 被调用了,但我们可以通过行为验证:
// 因为被清除并重置了,所以 500ms 后 result.current 应该还是 'start'
// 快进时间
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('start'); // 依然是 start,因为被重置了
// 再次修改值
act(() => {
rerender({ value: 'end' });
});
// 再次快进
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('end');
// 卸载组件
unmount();
// 组件卸载也会触发清理函数
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current).toBe('end'); // 依然是 end,因为 unmount 后没有新的值输入了
jest.useRealTimers(); // 关闭假定时器
});
看,这就是自定义 Hook 测试的魅力。我们不需要一个 DOM 元素,只需要关注状态的变化。如果你的 useDebounce 没有正确处理清理函数,测试就会失败,你的质量门禁就会报警。
第五部分:质量门禁的“守门员”——CI/CD 集成
好了,现在你已经写了漂亮的测试,覆盖了所有的逻辑分支。但是,如果你在本地跑得通,到了服务器上就报错,那岂不是成了笑话?这就是为什么我们需要质量门禁。
质量门禁不是让你在代码提交前手动检查,而是让它自动化。我们通常使用 GitHub Actions(或者 GitLab CI, Jenkins 等)来集成 Jest 覆盖率报告。
第一步:配置 Jest 输出覆盖率报告
在你的 jest.config.js 里,我们需要告诉 Jest 把报告输出到哪里。
// jest.config.js
module.exports = {
// ... 其他配置
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.{js,jsx}', // 只要测试 src 目录下的文件
'!src/**/*.test.{js,jsx}', // 排除测试文件本身
'!src/main.{js,jsx}', // 排除入口文件(可选)
],
coverageThreshold: {
global: {
branches: 80, // 分支覆盖率必须 >= 80%
functions: 80, // 函数覆盖率必须 >= 80%
lines: 80, // 行覆盖率必须 >= 80%
statements: 80, // 语句覆盖率必须 >= 80%
},
},
// 生成 HTML 报告
coverageReporters: ['text', 'text-summary', 'html', 'lcov'],
};
第二步:编写 GitHub Actions 工作流
这是质量门禁的核心。当有人 Push 代码或创建 Pull Request 时,GitHub Actions 会自动跑测试,并根据 coverageThreshold 决定是否允许合并。
# .github/workflows/test.yml
name: Test Coverage Gate
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install Dependencies
run: npm ci
- name: Run Tests & Generate Coverage
run: npm test -- --coverage --watchAll=false
- name: Upload Coverage to Codecov (可选,但推荐)
uses: codecov/codecov-action@v2
with:
files: ./coverage/lcov.info
- name: Check Coverage Threshold
# 这是一个自定义的 action,或者你可以用简单的脚本检查
# 这里我们用脚本方式检查,因为不需要额外依赖
run: |
# 读取 jest 的覆盖率结果 (简单示例,实际可能需要解析 lcov.info)
# 或者我们可以直接依赖 Jest 抛出的错误
# 如果覆盖率不达标,Jest 会以非零状态码退出
# 我们这里做一个简单的逻辑判断,模拟门禁拦截
if [ $? -ne 0 ]; then
echo "::error::Coverage threshold not met!"
exit 1
else
echo "Coverage check passed!"
fi
更高级的用法:Codecov
其实不需要自己写脚本判断,直接用 codecov/codecov-action。它会读取 coverage/lcov.info 文件,并与 Codecov 平台上的历史数据进行对比。
在 Codecov 上,你可以设置:
- Patch Coverage: 对比当前 PR 的改动覆盖率。如果 PR 引入了新的逻辑但没有测试,这里会报警。
- Project Coverage: 对比整个项目的覆盖率。
实战效果:
想象一下,你写了一个新功能,包含了一个 if (isAdmin) 的分支。你提交了代码。GitHub Actions 跑了起来。
- Jest 开始运行,生成了报告。
- Codecov 分析了你的代码变更。
- 发现你的
if (isAdmin)分支覆盖率为 0%。 - 砰! PR 被打上了红色的“Coverage Failed”标签。你无法合并代码,直到你补上测试用例。
这就是质量门禁的威力。它像一只守门员,死死地守在代码合并的入口,确保只有高质量的代码才能通过。
第六部分:进阶陷阱与避坑指南
在测试 React Hooks 的过程中,你会遇到各种坑。这里有一些“血泪经验”,帮你少走弯路。
1. 依赖数组里的“幽灵”
有时候你明明在依赖数组里写了 setCount,但是 ESLint 还是警告你 React Hook useEffect has a missing dependency: 'setCount'。
- 原因:在 React 18 之前,函数组件内部的函数每次渲染都会重新创建。如果你把函数传给了依赖数组,它会一直报错。
- 解决方案:使用
useCallback包裹函数,或者使用 ESLint 插件eslint-plugin-react-hooks的exhaustive-deps规则。现代 React (18+) 已经优化了这个问题,但在自定义 Hook 里依然要小心。
2. Context Provider 的缺失
测试组件时,如果组件使用了 useContext,你必须手动把 Context Provider 包起来,否则会报错 Context.Provider is not a function。
// AppContext.test.js
import { render } from '@testing-library/react';
import { AppContext } from './AppContext';
import { MyComponent } from './MyComponent';
test('renders MyComponent with context', () => {
const testValue = { theme: 'dark' };
// 必须包裹 Provider
render(
<AppContext.Provider value={testValue}>
<MyComponent />
</AppContext.Provider>
);
// 测试逻辑...
});
3. setTimeout 和 setInterval
在测试 setTimeout 或 setInterval 时,直接 act 是不够的,因为定时器是异步的。
- 解决方案:使用
jest.useFakeTimers()。jest.useFakeTimers():模拟时间流逝。jest.runAllTimers():快进所有时间。jest.advanceTimersByTime(ms):快进指定毫秒数。
test('timer logic', () => {
jest.useFakeTimers();
const { result } = renderHook(() => useDebounce('test', 500));
// 快进 500ms
jest.advanceTimersByTime(500);
expect(result.current).toBe('test');
jest.useRealTimers();
});
4. 不要过度测试
测试覆盖率不是越高越好,而是越精确越好。
- 不要测:React 内部如何渲染 DOM(比如
div的 class 名怎么拼接)。 - 要测:组件是否渲染了正确的文本,按钮是否在点击后触发了回调。
第七部分:总结与展望
好了,今天的讲座接近尾声。让我们回顾一下今天我们攻克了哪些难关:
- 闭包陷阱:学会了如何识别和修复
useEffect中的闭包问题,确保逻辑分支能正确触发。 - 模拟技术:掌握了
jest.fn和mockResolvedValue,学会了如何隔离外部依赖(API、LocalStorage)。 - 条件渲染:通过多状态渲染测试,覆盖了组件的各种逻辑分支(Loading/Error/Success)。
- 自定义 Hooks:利用
renderHook深入测试逻辑复用,验证了清理函数和状态流转。 - 质量门禁:配置了 Jest 覆盖率阈值,并集成了 GitHub Actions,实现了代码质量的自动化把关。
测试 React Hooks,本质上是在测试状态与副作用之间的契约。只要你的测试用例能验证这个契约是否被遵守,你的覆盖率就是有意义的。
最后,送给大家一句话:代码覆盖率不是目的,代码质量才是。但如果你连代码覆盖率都测不透,又怎么能谈得上代码质量呢? 希望大家都能写出“防御性”极强的测试,让 Bug 在测试阶段就无处遁形!
谢谢大家!现在,去写你的测试用例吧!