如何在 useEffect 中使用异步状态更新测试钩子?

how to test a hook with async state update in useEffect?

我有一个简单的钩子,可以获取值并将其设置为选项,如下所示:

import Fuse from 'fuse.js'
import React from 'react'

// prefetches options and uses fuzzy search to search on that option
// instead of fetching on each keystroke
export function usePrefetchedOptions<T extends {}>(fetcher: () => Promise<T[]>) {
  const [options, setOptions] = React.useState<T[]>([])
  React.useEffect(() => {
    // fetch options initially
    const optionsFetcher = async () => {
      try {
        const data = await fetcher()
        setOptions(data)
      } catch (err) {
        errorSnack(err)
      }
    }
    optionsFetcher()
  }, [])
  // const fuseOptions = {
  //   isCaseSensitive: false,
  //   keys: ['name'],
  // }

  // const fuse = new Fuse(options, fuseOptions)

  // const dataServiceProxy = (options) => (pattern: string) => {
  //   // console.error('options inside proxy call', { options })
  //   const optionsFromSearch = fuse.search(pattern).map((fuzzyResult) => fuzzyResult.item)
  //   return new Promise((resolve) => resolve(pattern === '' ? options : optionsFromSearch))
  // }

  return options
}

我正在尝试使用以下代码对其进行测试:

import { act, renderHook, waitFor } from '@testing-library/react-hooks'
import { Wrappers } from './test-utils'
import { usePrefetchedOptions } from './usePrefetchedOptions'
import React from 'react'

const setup = ({ fetcher }) => {
  const {
    result: { current },
    waitForNextUpdate,
    ...rest
  } = renderHook(() => usePrefetchedOptions(fetcher), { wrapper: Wrappers })
  return { current, waitForNextUpdate, ...rest }
}

describe('usePrefetchedOptions', () => {
  const mockOptions = [
    {
      value: 'value1',
      text: 'Value one',
    },
    {
      value: 'value2',
      text: 'Value two',
    },
    {
      value: 'value3',
      text: 'Value three',
    },
  ]
  test('searches for appropriate option', async () => {
    const fetcher = jest.fn(() => new Promise((resolve) => resolve(mockOptions)))
    const { rerender, current: options, waitForNextUpdate } = setup({ fetcher })
    await waitFor(() => {
      expect(fetcher).toHaveBeenCalled()
    })
    // async waitForNextUpdate()
    expect(options).toHaveLength(3) // returns initial value of empty options = []
  })
})

问题是当我试图在测试结束时断言选项时,它仍然具有 [] 的初始值。但是,如果我在挂钩中记录值,它 returns mockOptions。如何在 useEffect 以异步方式更新钩子后更新钩子。

我也尝试过在代码中注释的地方使用 using waitForNextUpdate。它超时并出现以下错误: Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Error:

一些事情,目前你正在等待 fetcher 在你的测试中被调用,但状态更新实际上不是在 fetcher 被调用之后发生的,而是在 fetcher 的承诺之后发生的] returns已解决。所以你需要在你的测试中等待这个承诺的解决

此外,当您第一次呈现钩子时,您正在解构 result.current 的值。该值只是 result.current 在第一次渲染后的副本,之后不会更新。要查询 options 的当前值,您应该在断言中查询 result.current

const fetcherPromise = Promise.resolve(mockOptions);
const fetch = jest.fn(() => fetcherPromise);
const { result } = renderHook(() => usePrefetchedOptions(fetcher), { wrappers: Wrappers })
await act(() => fetcherPromise);
expect(result.current).toHaveLength(3) 

下面是我需要测试上下文的第二个效果时对我有用的方法:


import React, {createContext, useContext, useEffect, useState} from "react";
import {IGlobalContext} from "../models";
import {fetchGravatar} from "../services";
import {fetchTokens, Token} from "@mylib/utils";

const GlobalContext = createContext<IGlobalContext>({} as IGlobalContext);


function useGlobalProvider(): IGlobalContext {
    const [token, setToken] = useState<Token>(Token.deserialize(undefined));
    const [gravatar, setGravatar] = useState<string>('');

    useEffect(() => {
        setToken(fetchTokens());
    }, []);

    useEffect(() => {
        if (token?.getIdToken()?.getUsername()) {
            fetchGravatar(token.getIdToken().getUsername())
                .then(setGravatar)
        }
    }, [token]);

    const getToken = (): Token => token;
    const getGravatar = (): string => gravatar;

    return {
        getToken,
        getGravatar
    }
}

const GlobalProvider: React.FC = ({children}) => {
    const globalContextData: IGlobalContext = useGlobalProvider();
    return (
        <GlobalContext.Provider value={globalContextData}>{children}</GlobalContext.Provider>
    );
};


function useGlobalContext() {
    if (!useContext(GlobalContext)) {
        throw new Error('GlobalContext must be used within a Provider');
    }
    return useContext<IGlobalContext>(GlobalContext);
}

export {GlobalProvider, useGlobalContext};


对应测试:

import React from "react";
import {GlobalProvider, useGlobalContext} from './Global';
import {act, renderHook} from "@testing-library/react-hooks";
import utils, {IdToken, Token} from "@mylib/utils";
import {getRandomGravatar, getRandomToken} from 'mock/Token';
import * as myService from './services/myService';
import {Builder} from "builder-pattern";
import faker from "faker";


jest.mock('@mylib/utils', () => ({
    ...jest.requireActual('@mylib/utils')
}));


describe("GlobalContext", () => {
    it("should set Token when context loads", () => {
        const expectedToken = getRandomToken('mytoken');
        const spyFetchToken = spyOn(utils, 'fetchTokens').and.returnValue(expectedToken);

        const wrapper = ({children}: { children?: React.ReactNode }) => <GlobalProvider>{children} </GlobalProvider>;

        const {result} = renderHook(() => useGlobalContext(), {wrapper});

        expect(spyFetchToken).toHaveBeenCalled();
        expect(result.current.getToken()).toEqual(expectedToken);
    })

    it("should fetch Gravatar When Token username changes", async () => {

        const expectedToken = getRandomToken('mytoken');
        const expectedGravatar = getRandomGravatar();
        const returnedGravatarPromise = Promise.resolve(expectedGravatar);
        const spyFetchToken = spyOn(utils, 'fetchTokens').and.returnValue(expectedToken);
        const spyFetchGravatar = spyOn(myService, 'fetchGravatar').and.returnValue(returnedGravatarPromise);


        const wrapper = ({children}: { children?: React.ReactNode }) =>
            <GlobalProvider>{children} </GlobalProvider>;

        const {result, waitForValueToChange} = renderHook(() => useGlobalContext(), {wrapper});

        // see here
        // we need to wait for the promise to be resolved, even though the gravatar spy  returned it
        let resolvedGravatarPromise;
        act(() => {
            resolvedGravatarPromise = returnedGravatarPromise;
        })

        await waitForValueToChange(() => result.current.getGravatar());

        expect(spyFetchToken).toHaveBeenCalled();
        expect(result.current.getToken()).toEqual(expectedToken);
        expect(spyFetchGravatar).toHaveBeenCalledWith(expectedToken.getIdToken().getUsername());
        expect(resolvedGravatarPromise).toBeInstanceOf(Promise);
        expect(result.current.getGravatar()).toEqual(expectedGravatar);

    })
})