You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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后测试中状态未更新。

问题原因

  1. Mock函数返回值错误:getUserInfo是异步函数,应该返回Promise,但测试中用mockReturnValue直接返回了普通对象,导致await getUserInfo()无法正确等待异步完成,后续逻辑执行异常。
  2. 未等待异步状态更新:Hook中的过滤逻辑是异步执行的,测试中调用renderHook后立即打印结果,此时异步操作还未完成,状态仍为初始的空数组。
  3. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 13:34:53