使用Vitest测试React自定义Hook返回空结果问题排查
React自定义Hook useFileFilter测试问题排查
问题场景
我实现了一个React自定义Hook useFileFilter,用于根据用户配置过滤文件列表:
import { getUserInfo } from '@/scripts/services/userService'; import { useState, useEffect } from 'react'; const useFileFilter = (repositoryFiles: any[]) => { let enabledFilters: string[] = []; let disabledFilters: string[] = []; const [filteredRepositoryFiles, setFilteredRepositoryFiles] = useState([]); useEffect(() => { const filterFiles = async () => { let filteredFiles: object[] = []; const userInfo = await getUserInfo(); for (const filter of userInfo.showSourceFiles) { if (filter.bool) { enabledFilters.push(`.${filter.name}`); } else { disabledFilters.push(`.${filter.name}`); } } if (enabledFilters.length === 0) { enabledFilters = disabledFilters; disabledFilters = []; } const fileExtentionsFilter = { include: enabledFilters, exclude: disabledFilters, }; for (const file of repositoryFiles) { const included = fileExtentionsFilter.include.some(fileExtentions => file.name.endsWith(fileExtentions), ); const excluded = fileExtentionsFilter.exclude.some(fileExtentions => file.name.endsWith(fileExtentions), ); if (included && !excluded && !file.isDirectory) { filteredFiles.push(file); } } console.log('works: ', filteredFiles); setFilteredRepositoryFiles(filteredFiles); } filterFiles(); }, [repositoryFiles]); console.log('doesnt work: ', filteredRepositoryFiles); return filteredRepositoryFiles; }; export default useFileFilter;
该Hook在实际项目中能正常返回过滤后的文件列表,但用Vitest + renderHook编写测试时,始终返回空数组:
测试代码:
import { describe, test, vi } from 'vitest'; import useFileFilter from '@/hooks/useFileFilter'; import { renderHook, waitFor } from '@testing-library/react'; import * as userService from '@/scripts/services/userService'; describe('For the useFileFilter hook', () => { const fetchSpy = vi.spyOn(userService, "getUserInfo"); beforeAll(() => { const mockResolveValue = { "id": 1, "email": "some@email.com", "role": "user", "showSourceFiles": [ { "id": "cm4v6cj260003mpetpyicw2jm", "name": "txt", "bool": false, "usersId": 1 }, { "id": "cm4v6cj260004mpetznm7rpj6", "name": "py", "bool": false, "usersId": 1 }, { "id": "cm4v6cj260001mpet4z81kmcm", "name": "gitignore", "bool": true, "usersId": 1 }, { "id": "cm4v6cj260002mpetxe6csa1j", "name": "yml", "bool": true, "usersId": 1 } ], "firstName": null, "lastName": null, "avatarUrl": null, "githubUsername": "SomeUser", "githubAccessToken": "a_token" } fetchSpy.mockReturnValue(mockResolveValue as any); }); afterAll(() => { fetchSpy.mockRestore(); }); it('should return filtered repository files', async () => { const repositoryFiles = [ { "id": 1, "isDirectory": false, "path": ".gitignore", "name": ".gitignore", "lineCount": 161, "functionalLineCount": 80, "symlinkTarget": "", "branchId": 1 }, { "id": 2, "isDirectory": false, "path": "Dockerfile", "name": "Dockerfile", "lineCount": 8, "functionalLineCount": 7, "symlinkTarget": "", "branchId": 1 }, { "id": 3, "isDirectory": false, "path": "compose.yml", "name": "compose.yml", "lineCount": 18, "functionalLineCount": 17, "symlinkTarget": "", "branchId": 1 } ] const result = renderHook(() => useFileFilter(repositoryFiles)); console.log('result: ', result); }); });
测试输出:
result: { result: { current: [] }, rerender: [Function: rerender], unmount: [Function: unmount] }
Hook内部的console.log('works: ', filteredFiles)能输出正确的过滤结果,但调用setFilteredRepositoryFiles后测试中状态未更新。
问题原因
- Mock函数返回值错误:
getUserInfo是异步函数,应该返回Promise,但测试中用mockReturnValue直接返回了普通对象,导致await getUserInfo()无法正确等待异步完成,后续逻辑执行异常。 - 未等待异步状态更新:Hook中的过滤逻辑是异步执行的,测试中调用
renderHook后立即打印结果,此时异步操作还未完成,状态仍为初始的空数组。 - Hook存在闭包隐患:
enabledFilters和disabledFilters是Hook内部的普通变量,而非状态或useRef存储,虽然实际使用中可能暂时正常,但在组件重渲染时会导致变量重置或闭包捕获旧值的问题。
解决方案
1. 修正Mock函数返回值
将mockReturnValue改为mockResolvedValue,确保返回Promise:
beforeAll(() => { const mockResolveValue = { /* ... 原有数据 ... */ }; fetchSpy.mockResolvedValue(mockResolveValue as any); });
2. 在测试中等待状态更新
使用waitFor等待Hook的状态更新完成,再断言结果:
it('should return filtered repository files', async () => { const repositoryFiles = [ /* ... 测试文件列表 ... */ ]; const { result } = renderHook(() => useFileFilter(repositoryFiles)); await waitFor(() => { expect(result.current).not.toEqual([]); }); // 断言过滤结果符合预期 expect(result.current).toEqual(expect.arrayContaining([ expect.objectContaining({ name: '.gitignore' }), expect.objectContaining({ name: 'compose.yml' }) ])); });
3. 修复Hook的闭包问题
将enabledFilters和disabledFilters移到filterFiles函数内部,避免闭包捕获旧值或变量污染:
const useFileFilter = (repositoryFiles: any[]) => { const [filteredRepositoryFiles, setFilteredRepositoryFiles] = useState([]); useEffect(() => { const filterFiles = async () => { let enabledFilters: string[] = []; // 移到函数内部 let disabledFilters: string[] = []; let filteredFiles: object[] = []; const userInfo = await getUserInfo(); for (const filter of userInfo.showSourceFiles) { if (filter.bool) { enabledFilters.push(`.${filter.name}`); } else { disabledFilters.push(`.${filter.name}`); } } if (enabledFilters.length === 0) { enabledFilters = disabledFilters; disabledFilters = []; } const fileExtentionsFilter = { include: enabledFilters, exclude: disabledFilters, }; for (const file of repositoryFiles) { const included = fileExtentionsFilter.include.some(fileExtentions => file.name.endsWith(fileExtentions), ); const excluded = fileExtentionsFilter.exclude.some(fileExtentions => file.name.endsWith(fileExtentions), ); if (included && !excluded && !file.isDirectory) { filteredFiles.push(file); } } setFilteredRepositoryFiles(filteredFiles); } filterFiles(); }, [repositoryFiles]); return filteredRepositoryFiles; };
验证效果
修改后重新运行测试,waitFor会等待异步操作完成,状态更新后即可获取正确的过滤结果,同时Hook的闭包问题也得到解决,避免后续潜在的bug。
内容的提问来源于stack exchange,提问作者Snackoverflow
相关产品推荐
相关产品推荐

