如何在 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);
})
})
我有一个简单的钩子,可以获取值并将其设置为选项,如下所示:
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);
})
})