mirror of
https://github.com/callstack/react-native-testing-library.git
synced 2026-09-18 23:09:04 +08:00
refactor: make renderHook async be default (v2) (#1843)
This commit is contained in:
committed by
GitHub
parent
c4cedd8533
commit
aba244fc77
@@ -1,266 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { act, renderHookAsync } from '..';
|
||||
import { excludeConsoleMessage } from '../test-utils/console';
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
const originalConsoleError = console.error;
|
||||
afterEach(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
function useSuspendingHook(promise: Promise<string>) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: React 18 does not have `use` hook
|
||||
return React.use(promise);
|
||||
}
|
||||
|
||||
test('renderHookAsync renders hook asynchronously', async () => {
|
||||
const { result } = await renderHookAsync(() => {
|
||||
const [state, setState] = React.useState(1);
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(2);
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
});
|
||||
|
||||
expect(result.current).toEqual(2);
|
||||
});
|
||||
|
||||
test('renderHookAsync with wrapper option', async () => {
|
||||
const Context = React.createContext('default');
|
||||
|
||||
function useTestHook() {
|
||||
return React.useContext(Context);
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <Context.Provider value="provided">{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
const { result } = await renderHookAsync(useTestHook, { wrapper: Wrapper });
|
||||
expect(result.current).toEqual('provided');
|
||||
});
|
||||
|
||||
test('rerenderAsync function updates hook asynchronously', async () => {
|
||||
function useTestHook(props: { value: number }) {
|
||||
const [state, setState] = React.useState(props.value);
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(props.value * 2);
|
||||
}, [props.value]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
const { result, rerenderAsync } = await renderHookAsync(useTestHook, {
|
||||
initialProps: { value: 5 },
|
||||
});
|
||||
expect(result.current).toEqual(10);
|
||||
|
||||
await rerenderAsync({ value: 10 });
|
||||
expect(result.current).toEqual(20);
|
||||
});
|
||||
|
||||
test('unmount function unmounts hook asynchronously', async () => {
|
||||
let cleanupCalled = false;
|
||||
|
||||
function useTestHook() {
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
cleanupCalled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return 'test';
|
||||
}
|
||||
|
||||
const { unmountAsync } = await renderHookAsync(useTestHook);
|
||||
expect(cleanupCalled).toBe(false);
|
||||
|
||||
await unmountAsync();
|
||||
expect(cleanupCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('handles hook with state updates during effects', async () => {
|
||||
function useTestHook() {
|
||||
const [count, setCount] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCount((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
const { result } = await renderHookAsync(useTestHook);
|
||||
expect(result.current).toBe(1);
|
||||
});
|
||||
|
||||
test('handles multiple state updates in effects', async () => {
|
||||
function useTestHook() {
|
||||
const [first, setFirst] = React.useState(1);
|
||||
const [second, setSecond] = React.useState(2);
|
||||
|
||||
React.useEffect(() => {
|
||||
setFirst(10);
|
||||
setSecond(20);
|
||||
}, []);
|
||||
|
||||
return { first, second };
|
||||
}
|
||||
|
||||
const { result } = await renderHookAsync(useTestHook);
|
||||
expect(result.current).toEqual({ first: 10, second: 20 });
|
||||
});
|
||||
|
||||
test('handles hook with suspense', async () => {
|
||||
let resolvePromise: (value: string) => void;
|
||||
const promise = new Promise<string>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
const { result } = await renderHookAsync(useSuspendingHook, {
|
||||
initialProps: promise,
|
||||
wrapper: ({ children }) => (
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>{children}</React.Suspense>
|
||||
),
|
||||
});
|
||||
|
||||
// Initially suspended, result should not be available
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => resolvePromise('resolved'));
|
||||
expect(result.current).toBe('resolved');
|
||||
});
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback: string },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode; fallback: string }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
test('handles hook suspense with error boundary', async () => {
|
||||
const ERROR_MESSAGE = 'Hook Promise Rejected In Test';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE);
|
||||
|
||||
let rejectPromise: (error: Error) => void;
|
||||
const promise = new Promise<string>((_resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
});
|
||||
|
||||
const { result } = await renderHookAsync(useSuspendingHook, {
|
||||
initialProps: promise,
|
||||
wrapper: ({ children }) => (
|
||||
<ErrorBoundary fallback="error-fallback">
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>{children}</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
});
|
||||
|
||||
// Initially suspended
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => rejectPromise(new Error(ERROR_MESSAGE)));
|
||||
|
||||
// After error, result should still be null (error boundary caught it)
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
test('handles custom hooks with complex logic', async () => {
|
||||
function useCounter(initialValue: number) {
|
||||
const [count, setCount] = React.useState(initialValue);
|
||||
|
||||
const increment = React.useCallback(() => {
|
||||
setCount((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const decrement = React.useCallback(() => {
|
||||
setCount((prev) => prev - 1);
|
||||
}, []);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setCount(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return { count, increment, decrement, reset };
|
||||
}
|
||||
|
||||
const { result } = await renderHookAsync(useCounter, { initialProps: 5 });
|
||||
expect(result.current.count).toBe(5);
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => {
|
||||
result.current.increment();
|
||||
});
|
||||
expect(result.current.count).toBe(6);
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => {
|
||||
result.current.reset();
|
||||
});
|
||||
expect(result.current.count).toBe(5);
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => {
|
||||
result.current.decrement();
|
||||
});
|
||||
expect(result.current.count).toBe(4);
|
||||
});
|
||||
|
||||
test('handles hook with cleanup and re-initialization', async () => {
|
||||
let effectCount = 0;
|
||||
let cleanupCount = 0;
|
||||
|
||||
function useTestHook(props: { key: string }) {
|
||||
const [value, setValue] = React.useState(props.key);
|
||||
|
||||
React.useEffect(() => {
|
||||
effectCount++;
|
||||
setValue(`${props.key}-effect`);
|
||||
|
||||
return () => {
|
||||
cleanupCount++;
|
||||
};
|
||||
}, [props.key]);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const { result, rerenderAsync, unmountAsync } = await renderHookAsync(useTestHook, {
|
||||
initialProps: { key: 'initial' },
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial-effect');
|
||||
expect(effectCount).toBe(1);
|
||||
expect(cleanupCount).toBe(0);
|
||||
|
||||
await rerenderAsync({ key: 'updated' });
|
||||
expect(result.current).toBe('updated-effect');
|
||||
expect(effectCount).toBe(2);
|
||||
expect(cleanupCount).toBe(1);
|
||||
|
||||
await unmountAsync();
|
||||
expect(effectCount).toBe(2);
|
||||
expect(cleanupCount).toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { deprecated_renderHookSync } from '../pure';
|
||||
|
||||
test('renders hook and returns committed result', () => {
|
||||
const { result } = deprecated_renderHookSync(() => {
|
||||
const [state, setState] = React.useState(1);
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(2);
|
||||
}, []);
|
||||
|
||||
return [state, setState];
|
||||
});
|
||||
|
||||
expect(result.current).toEqual([2, expect.any(Function)]);
|
||||
});
|
||||
|
||||
test('works with wrapper option', () => {
|
||||
const Context = React.createContext('default');
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <Context.Provider value="provided">{children}</Context.Provider>;
|
||||
}
|
||||
const { result } = deprecated_renderHookSync(
|
||||
() => {
|
||||
return React.useContext(Context);
|
||||
},
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current).toEqual('provided');
|
||||
});
|
||||
|
||||
test('works with initialProps option', () => {
|
||||
const { result } = deprecated_renderHookSync(
|
||||
(props: { branch: 'left' | 'right' }) => {
|
||||
const [left, setLeft] = React.useState('left');
|
||||
const [right, setRight] = React.useState('right');
|
||||
|
||||
switch (props.branch) {
|
||||
case 'left':
|
||||
return [left, setLeft];
|
||||
case 'right':
|
||||
return [right, setRight];
|
||||
default:
|
||||
throw new Error('No Props passed. This is a bug in the implementation');
|
||||
}
|
||||
},
|
||||
{ initialProps: { branch: 'left' } },
|
||||
);
|
||||
|
||||
expect(result.current).toEqual(['left', expect.any(Function)]);
|
||||
});
|
||||
|
||||
test('works without initialProps option', () => {
|
||||
function useTestHook() {
|
||||
const [count, setCount] = React.useState(0);
|
||||
return { count, setCount };
|
||||
}
|
||||
|
||||
const { result } = deprecated_renderHookSync(useTestHook);
|
||||
expect(result.current.count).toBe(0);
|
||||
});
|
||||
|
||||
test('rerender updates hook with new props', () => {
|
||||
const { result, rerender } = deprecated_renderHookSync(
|
||||
(props: { branch: 'left' | 'right' }) => {
|
||||
const [left, setLeft] = React.useState('left');
|
||||
const [right, setRight] = React.useState('right');
|
||||
|
||||
switch (props.branch) {
|
||||
case 'left':
|
||||
return [left, setLeft];
|
||||
case 'right':
|
||||
return [right, setRight];
|
||||
default:
|
||||
throw new Error('No Props passed. This is a bug in the implementation');
|
||||
}
|
||||
},
|
||||
{ initialProps: { branch: 'left' } },
|
||||
);
|
||||
|
||||
expect(result.current).toEqual(['left', expect.any(Function)]);
|
||||
|
||||
rerender({ branch: 'right' });
|
||||
expect(result.current).toEqual(['right', expect.any(Function)]);
|
||||
});
|
||||
|
||||
test('unmount triggers cleanup effects', () => {
|
||||
let cleanupCalled = false;
|
||||
|
||||
function useTestHook() {
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
cleanupCalled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return 'test';
|
||||
}
|
||||
|
||||
const { unmount } = deprecated_renderHookSync(useTestHook);
|
||||
expect(cleanupCalled).toBe(false);
|
||||
|
||||
unmount();
|
||||
expect(cleanupCalled).toBe(true);
|
||||
});
|
||||
|
||||
function useMyHook<T>(param: T) {
|
||||
return { param };
|
||||
}
|
||||
|
||||
test('props type is inferred correctly when initial props is defined', () => {
|
||||
const { result, rerender } = deprecated_renderHookSync((num: number) => useMyHook(num), {
|
||||
initialProps: 5,
|
||||
});
|
||||
expect(result.current.param).toBe(5);
|
||||
|
||||
rerender(6);
|
||||
expect(result.current.param).toBe(6);
|
||||
});
|
||||
|
||||
test('props type is inferred correctly when initial props is explicitly undefined', () => {
|
||||
const { result, rerender } = deprecated_renderHookSync(
|
||||
(num: number | undefined) => useMyHook(num),
|
||||
{
|
||||
initialProps: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.param).toBeUndefined();
|
||||
|
||||
rerender(6);
|
||||
expect(result.current.param).toBe(6);
|
||||
});
|
||||
@@ -1,85 +1,291 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { renderHook } from '../pure';
|
||||
import { act, renderHook } from '..';
|
||||
import { excludeConsoleMessage } from '../test-utils/console';
|
||||
|
||||
test('gives committed result', () => {
|
||||
const { result } = renderHook(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
const originalConsoleError = console.error;
|
||||
afterEach(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
function useSuspendingHook(promise: Promise<string>) {
|
||||
return React.use(promise);
|
||||
}
|
||||
|
||||
test('renders hook and returns committed result', async () => {
|
||||
const { result } = await renderHook(() => {
|
||||
const [state, setState] = React.useState(1);
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(2);
|
||||
}, []);
|
||||
|
||||
return [state, setState];
|
||||
return state;
|
||||
});
|
||||
|
||||
expect(result.current).toEqual([2, expect.any(Function)]);
|
||||
expect(result.current).toEqual(2);
|
||||
});
|
||||
|
||||
test('allows rerendering', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { branch: 'left' | 'right' }) => {
|
||||
const [left, setLeft] = React.useState('left');
|
||||
const [right, setRight] = React.useState('right');
|
||||
test('handles hook with state updates during effects', async () => {
|
||||
function useTestHook() {
|
||||
const [count, setCount] = React.useState(0);
|
||||
|
||||
switch (props.branch) {
|
||||
case 'left':
|
||||
return [left, setLeft];
|
||||
case 'right':
|
||||
return [right, setRight];
|
||||
default:
|
||||
throw new Error('No Props passed. This is a bug in the implementation');
|
||||
}
|
||||
},
|
||||
{ initialProps: { branch: 'left' } },
|
||||
);
|
||||
React.useEffect(() => {
|
||||
setCount((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
expect(result.current).toEqual(['left', expect.any(Function)]);
|
||||
return count;
|
||||
}
|
||||
|
||||
rerender({ branch: 'right' });
|
||||
|
||||
expect(result.current).toEqual(['right', expect.any(Function)]);
|
||||
const { result } = await renderHook(useTestHook);
|
||||
expect(result.current).toBe(1);
|
||||
});
|
||||
|
||||
test('allows wrapper components', () => {
|
||||
test('handles multiple state updates in effects', async () => {
|
||||
function useTestHook() {
|
||||
const [first, setFirst] = React.useState(1);
|
||||
const [second, setSecond] = React.useState(2);
|
||||
|
||||
React.useEffect(() => {
|
||||
setFirst(10);
|
||||
setSecond(20);
|
||||
}, []);
|
||||
|
||||
return { first, second };
|
||||
}
|
||||
|
||||
const { result } = await renderHook(useTestHook);
|
||||
expect(result.current).toEqual({ first: 10, second: 20 });
|
||||
});
|
||||
|
||||
test('works with wrapper option', async () => {
|
||||
const Context = React.createContext('default');
|
||||
|
||||
function useTestHook() {
|
||||
return React.useContext(Context);
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <Context.Provider value="provided">{children}</Context.Provider>;
|
||||
}
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
return React.useContext(Context);
|
||||
},
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = await renderHook(useTestHook, { wrapper: Wrapper });
|
||||
expect(result.current).toEqual('provided');
|
||||
});
|
||||
|
||||
function useMyHook<T>(param: T) {
|
||||
return { param };
|
||||
test('works with initialProps option', async () => {
|
||||
function useTestHook(props: { value: number }) {
|
||||
const [state, setState] = React.useState(props.value);
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(props.value * 2);
|
||||
}, [props.value]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
const { result } = await renderHook(useTestHook, {
|
||||
initialProps: { value: 5 },
|
||||
});
|
||||
expect(result.current).toEqual(10);
|
||||
});
|
||||
|
||||
test('works without initialProps option', async () => {
|
||||
function useTestHook() {
|
||||
const [count, setCount] = React.useState(0);
|
||||
return { count, setCount };
|
||||
}
|
||||
|
||||
const { result } = await renderHook(useTestHook);
|
||||
expect(result.current.count).toBe(0);
|
||||
});
|
||||
|
||||
test('rerender updates hook with new props', async () => {
|
||||
function useTestHook(props: { value: number }) {
|
||||
const [state, setState] = React.useState(props.value);
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(props.value * 2);
|
||||
}, [props.value]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
const { result, rerender } = await renderHook(useTestHook, {
|
||||
initialProps: { value: 5 },
|
||||
});
|
||||
expect(result.current).toEqual(10);
|
||||
|
||||
await rerender({ value: 10 });
|
||||
expect(result.current).toEqual(20);
|
||||
});
|
||||
|
||||
test('unmount triggers cleanup effects', async () => {
|
||||
let cleanupCalled = false;
|
||||
|
||||
function useTestHook() {
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
cleanupCalled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return 'test';
|
||||
}
|
||||
|
||||
const { unmount } = await renderHook(useTestHook);
|
||||
expect(cleanupCalled).toBe(false);
|
||||
|
||||
await unmount();
|
||||
expect(cleanupCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('handles hook with cleanup and re-initialization', async () => {
|
||||
let effectCount = 0;
|
||||
let cleanupCount = 0;
|
||||
|
||||
function useTestHook(props: { key: string }) {
|
||||
const [value, setValue] = React.useState(props.key);
|
||||
|
||||
React.useEffect(() => {
|
||||
effectCount++;
|
||||
setValue(`${props.key}-effect`);
|
||||
|
||||
return () => {
|
||||
cleanupCount++;
|
||||
};
|
||||
}, [props.key]);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const { result, rerender, unmount } = await renderHook(useTestHook, {
|
||||
initialProps: { key: 'initial' },
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial-effect');
|
||||
expect(effectCount).toBe(1);
|
||||
expect(cleanupCount).toBe(0);
|
||||
|
||||
await rerender({ key: 'updated' });
|
||||
expect(result.current).toBe('updated-effect');
|
||||
expect(effectCount).toBe(2);
|
||||
expect(cleanupCount).toBe(1);
|
||||
|
||||
await unmount();
|
||||
expect(effectCount).toBe(2);
|
||||
expect(cleanupCount).toBe(2);
|
||||
});
|
||||
|
||||
test('handles hook with suspense', async () => {
|
||||
let resolvePromise: (value: string) => void;
|
||||
const promise = new Promise<string>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
const { result } = await renderHook(useSuspendingHook, {
|
||||
initialProps: promise,
|
||||
wrapper: ({ children }) => (
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>{children}</React.Suspense>
|
||||
),
|
||||
});
|
||||
|
||||
// Initially suspended, result should not be available
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => resolvePromise('resolved'));
|
||||
expect(result.current).toBe('resolved');
|
||||
});
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback: string },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode; fallback: string }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
test('props type is inferred correctly when initial props is defined', () => {
|
||||
const { result, rerender } = renderHook((num: number) => useMyHook(num), {
|
||||
initialProps: 5,
|
||||
});
|
||||
expect(result.current.param).toBe(5);
|
||||
test('handles hook suspense with error boundary', async () => {
|
||||
const ERROR_MESSAGE = 'Hook Promise Rejected In Test';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE);
|
||||
|
||||
rerender(6);
|
||||
expect(result.current.param).toBe(6);
|
||||
});
|
||||
|
||||
test('props type is inferred correctly when initial props is explicitly undefined', () => {
|
||||
const { result, rerender } = renderHook((num: number | undefined) => useMyHook(num), {
|
||||
initialProps: undefined,
|
||||
let rejectPromise: (error: Error) => void;
|
||||
const promise = new Promise<string>((_resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
});
|
||||
|
||||
expect(result.current.param).toBeUndefined();
|
||||
const { result } = await renderHook(useSuspendingHook, {
|
||||
initialProps: promise,
|
||||
wrapper: ({ children }) => (
|
||||
<ErrorBoundary fallback="error-fallback">
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>{children}</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
});
|
||||
|
||||
rerender(6);
|
||||
expect(result.current.param).toBe(6);
|
||||
// Initially suspended
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => rejectPromise(new Error(ERROR_MESSAGE)));
|
||||
|
||||
// After error, result should still be null (error boundary caught it)
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
test('handles custom hooks with complex logic', async () => {
|
||||
function useCounter(initialValue: number) {
|
||||
const [count, setCount] = React.useState(initialValue);
|
||||
|
||||
const increment = React.useCallback(() => {
|
||||
setCount((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const decrement = React.useCallback(() => {
|
||||
setCount((prev) => prev - 1);
|
||||
}, []);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setCount(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return { count, increment, decrement, reset };
|
||||
}
|
||||
|
||||
const { result } = await renderHook(useCounter, { initialProps: 5 });
|
||||
expect(result.current.count).toBe(5);
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => {
|
||||
result.current.increment();
|
||||
});
|
||||
expect(result.current.count).toBe(6);
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => {
|
||||
result.current.reset();
|
||||
});
|
||||
expect(result.current.count).toBe(5);
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
await act(async () => {
|
||||
result.current.decrement();
|
||||
});
|
||||
expect(result.current.count).toBe(4);
|
||||
});
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ export { within, getQueriesForElement } from './within';
|
||||
export { configure, resetToDefaults } from './config';
|
||||
export { isHiddenFromAccessibility, isInaccessible } from './helpers/accessibility';
|
||||
export { getDefaultNormalizer } from './matches';
|
||||
export { renderHook, renderHookAsync } from './render-hook';
|
||||
export { renderHook, deprecated_renderHookSync } from './render-hook';
|
||||
export { screen } from './screen';
|
||||
export { userEvent } from './user-event';
|
||||
|
||||
@@ -21,6 +21,6 @@ export type {
|
||||
DebugFunction,
|
||||
} from './render';
|
||||
export type { RenderAsyncOptions, RenderAsyncResult } from './render-async';
|
||||
export type { RenderHookOptions, RenderHookResult, RenderHookAsyncResult } from './render-hook';
|
||||
export type { RenderHookOptions, RenderHookResult, RenderHookSyncResult } from './render-hook';
|
||||
export type { Config } from './config';
|
||||
export type { UserEventConfig } from './user-event';
|
||||
|
||||
+38
-37
@@ -6,14 +6,15 @@ import type { RefObject } from './types';
|
||||
|
||||
export type RenderHookResult<Result, Props> = {
|
||||
result: RefObject<Result>;
|
||||
rerender: (props: Props) => void;
|
||||
unmount: () => void;
|
||||
rerender: (props: Props) => Promise<void>;
|
||||
unmount: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type RenderHookAsyncResult<Result, Props> = {
|
||||
/** @deprecated - Use `renderHook` and `RenderHookResult` instead. */
|
||||
export type RenderHookSyncResult<Result, Props> = {
|
||||
result: RefObject<Result>;
|
||||
rerenderAsync: (props: Props) => Promise<void>;
|
||||
unmountAsync: () => Promise<void>;
|
||||
rerender: (props: Props) => void;
|
||||
unmount: () => void;
|
||||
};
|
||||
|
||||
export type RenderHookOptions<Props> = {
|
||||
@@ -30,10 +31,40 @@ export type RenderHookOptions<Props> = {
|
||||
wrapper?: React.ComponentType<any>;
|
||||
};
|
||||
|
||||
export function renderHook<Result, Props>(
|
||||
export async function renderHook<Result, Props>(
|
||||
hookToRender: (props: Props) => Result,
|
||||
options?: RenderHookOptions<NoInfer<Props>>,
|
||||
): RenderHookResult<Result, Props> {
|
||||
): Promise<RenderHookResult<Result, Props>> {
|
||||
const result = React.createRef<Result>() as RefObject<Result>;
|
||||
|
||||
function HookContainer({ hookProps }: { hookProps: Props }) {
|
||||
const renderResult = hookToRender(hookProps);
|
||||
React.useEffect(() => {
|
||||
result.current = renderResult;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { initialProps, ...renderOptions } = options ?? {};
|
||||
const { rerenderAsync: rerenderComponentAsync, unmountAsync } = await renderAsync(
|
||||
// @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt
|
||||
<HookContainer hookProps={initialProps} />,
|
||||
renderOptions,
|
||||
);
|
||||
|
||||
return {
|
||||
result: result,
|
||||
rerender: (hookProps: Props) => rerenderComponentAsync(<HookContainer hookProps={hookProps} />),
|
||||
unmount: unmountAsync,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated - Use async `renderHook` instead. */
|
||||
export function deprecated_renderHookSync<Result, Props>(
|
||||
hookToRender: (props: Props) => Result,
|
||||
options?: RenderHookOptions<NoInfer<Props>>,
|
||||
): RenderHookSyncResult<Result, Props> {
|
||||
const result = React.createRef<Result>() as RefObject<Result>;
|
||||
|
||||
function HookContainer({ hookProps }: { hookProps: Props }) {
|
||||
@@ -58,33 +89,3 @@ export function renderHook<Result, Props>(
|
||||
unmount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderHookAsync<Result, Props>(
|
||||
hookToRender: (props: Props) => Result,
|
||||
options?: RenderHookOptions<NoInfer<Props>>,
|
||||
): Promise<RenderHookAsyncResult<Result, Props>> {
|
||||
const result = React.createRef<Result>() as RefObject<Result>;
|
||||
|
||||
function TestComponent({ hookProps }: { hookProps: Props }) {
|
||||
const renderResult = hookToRender(hookProps);
|
||||
React.useEffect(() => {
|
||||
result.current = renderResult;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { initialProps, ...renderOptions } = options ?? {};
|
||||
const { rerenderAsync: rerenderComponentAsync, unmountAsync } = await renderAsync(
|
||||
// @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt
|
||||
<TestComponent hookProps={initialProps} />,
|
||||
renderOptions,
|
||||
);
|
||||
|
||||
return {
|
||||
result: result,
|
||||
rerenderAsync: (hookProps: Props) =>
|
||||
rerenderComponentAsync(<TestComponent hookProps={hookProps} />),
|
||||
unmountAsync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,23 +3,29 @@
|
||||
## `renderHook`
|
||||
|
||||
```ts
|
||||
function renderHook<Result, Props>(
|
||||
async function renderHook<Result, Props>(
|
||||
hookFn: (props?: Props) => Result,
|
||||
options?: RenderHookOptions<Props>
|
||||
): RenderHookResult<Result, Props>;
|
||||
): Promise<RenderHookResult<Result, Props>>;
|
||||
```
|
||||
|
||||
Renders a test component that will call the provided `callback`, including any hooks it calls, every time it renders. Returns [`RenderHookResult`](#renderhookresult) object, which you can interact with.
|
||||
Renders a test component that will call the provided `callback`, including any hooks it calls, every time it renders. Returns a Promise that resolves to a [`RenderHookResult`](#renderhookresult) object, which you can interact with.
|
||||
|
||||
**This is the recommended default API** for testing hooks. It uses async `act` internally to ensure all pending React updates are executed during rendering, making it compatible with React 19, React Suspense, and `React.use()`.
|
||||
|
||||
- **Returns a Promise**: Should be awaited
|
||||
- **Async methods**: Both `rerender` and `unmount` return Promises and should be awaited
|
||||
- **Suspense support**: Compatible with React Suspense boundaries and `React.use()`
|
||||
|
||||
```ts
|
||||
import { renderHook } from '@testing-library/react-native';
|
||||
import { renderHook, act } from '@testing-library/react-native';
|
||||
import { useCount } from '../useCount';
|
||||
|
||||
it('should increment count', () => {
|
||||
const { result } = renderHook(() => useCount());
|
||||
it('should increment count', async () => {
|
||||
const { result } = await renderHook(() => useCount());
|
||||
|
||||
expect(result.current.count).toBe(0);
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
// Note that you should wrap the calls to functions your hook returns with `act` if they trigger an update of your hook's state to ensure pending useEffects are run before your next assertion.
|
||||
result.current.increment();
|
||||
});
|
||||
@@ -55,22 +61,17 @@ The initial values to pass as `props` to the `callback` function of `renderHook`
|
||||
|
||||
A React component to wrap the test component in when rendering. This is usually used to add context providers from `React.createContext` for the hook to access with `useContext`.
|
||||
|
||||
### `concurrentRoot` {#concurrent-root}
|
||||
|
||||
Set to `false` to disable concurrent rendering.
|
||||
Otherwise, `render` will default to using concurrent rendering used in the React Native New Architecture.
|
||||
|
||||
### Result
|
||||
|
||||
```ts
|
||||
interface RenderHookResult<Result, Props> {
|
||||
result: { current: Result };
|
||||
rerender: (props: Props) => void;
|
||||
unmount: () => void;
|
||||
rerender: (props: Props) => Promise<void>;
|
||||
unmount: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
The `renderHook` function returns an object that has the following properties:
|
||||
The `renderHook` function returns a Promise that resolves to an object with the following properties:
|
||||
|
||||
#### `result`
|
||||
|
||||
@@ -78,11 +79,15 @@ The `current` value of the `result` will reflect the latest of whatever is retur
|
||||
|
||||
#### `rerender`
|
||||
|
||||
A function to rerender the test component, causing any hooks to be recalculated. If `newProps` are passed, they will replace the `callback` function's `initialProps` for subsequent rerenders. The `Props` type is determined by the type passed to or inferred by the `renderHook` call.
|
||||
An async function to rerender the test component, causing any hooks to be recalculated. If `newProps` are passed, they will replace the `callback` function's `initialProps` for subsequent rerenders. The `Props` type is determined by the type passed to or inferred by the `renderHook` call.
|
||||
|
||||
**Note**: This method returns a Promise and should be awaited.
|
||||
|
||||
#### `unmount`
|
||||
|
||||
A function to unmount the test component. This is commonly used to trigger cleanup effects for `useEffect` hooks.
|
||||
An async function to unmount the test component. This is commonly used to trigger cleanup effects for `useEffect` hooks.
|
||||
|
||||
**Note**: This method returns a Promise and should be awaited.
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -102,19 +107,19 @@ const useCount = (initialCount: number) => {
|
||||
return { count, increment };
|
||||
};
|
||||
|
||||
it('should increment count', () => {
|
||||
const { result, rerender } = renderHook((initialCount: number) => useCount(initialCount), {
|
||||
it('should increment count', async () => {
|
||||
const { result, rerender } = await renderHook((initialCount: number) => useCount(initialCount), {
|
||||
initialProps: 1,
|
||||
});
|
||||
|
||||
expect(result.current.count).toBe(1);
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.increment();
|
||||
});
|
||||
|
||||
expect(result.current.count).toBe(2);
|
||||
rerender(5);
|
||||
await rerender(5);
|
||||
expect(result.current.count).toBe(5);
|
||||
});
|
||||
```
|
||||
@@ -122,61 +127,51 @@ it('should increment count', () => {
|
||||
#### With `wrapper`
|
||||
|
||||
```tsx
|
||||
it('should use context value', () => {
|
||||
it('should use context value', async () => {
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <Context.Provider value="provided">{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useHook(), { wrapper: Wrapper });
|
||||
const { result } = await renderHook(() => useHook(), { wrapper: Wrapper });
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## `renderHookAsync` function
|
||||
#### With React Suspense
|
||||
|
||||
```ts
|
||||
async function renderHookAsync<Result, Props>(
|
||||
hookFn: (props?: Props) => Result,
|
||||
options?: RenderHookOptions<Props>
|
||||
): Promise<RenderHookAsyncResult<Result, Props>>;
|
||||
```
|
||||
```tsx
|
||||
import { renderHook, act } from '@testing-library/react-native';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
Async versions of `renderHook` designed for working with React 19 and React Suspense. This method uses async `act` function internally to ensure all pending React updates are executed during rendering.
|
||||
|
||||
- **Returns a Promise**: Should be awaited
|
||||
- **Async methods**: Both `rerender` and `unmount` return Promises and should be awaited
|
||||
- **Suspense support**: Compatible with React Suspense boundaries and `React.use()`
|
||||
|
||||
### Result {#result-async}
|
||||
|
||||
```ts
|
||||
interface RenderHookAsyncResult<Result, Props> {
|
||||
result: { current: Result };
|
||||
rerenderAsync: (props: Props) => Promise<void>;
|
||||
unmountAsync: () => Promise<void>;
|
||||
function useSuspendingHook(promise: Promise<string>) {
|
||||
return React.use(promise);
|
||||
}
|
||||
```
|
||||
|
||||
The `RenderHookAsyncResult` differs from `RenderHookResult` in that `rerenderAsync` and `unmountAsync` are async functions.
|
||||
|
||||
```ts
|
||||
import { renderHookAsync, act } from '@testing-library/react-native';
|
||||
|
||||
test('should handle async hook behavior', async () => {
|
||||
const { result, rerenderAsync } = await renderHookAsync(useAsyncHook);
|
||||
|
||||
// Test initial state
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
// Wait for async operation to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
it('handles hook with suspense', async () => {
|
||||
let resolvePromise: (value: string) => void;
|
||||
const promise = new Promise<string>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
// Re-render to get updated state
|
||||
await rerenderAsync();
|
||||
expect(result.current.loading).toBe(false);
|
||||
const { result } = await renderHook(useSuspendingHook, {
|
||||
initialProps: promise,
|
||||
wrapper: ({ children }) => (
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>{children}</React.Suspense>
|
||||
),
|
||||
});
|
||||
|
||||
// Initially suspended, result should not be available
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
await act(async () => resolvePromise('resolved'));
|
||||
expect(result.current).toBe('resolved');
|
||||
});
|
||||
```
|
||||
|
||||
Use `renderHookAsync` when testing hooks that use React Suspense, `React.use()`, or other concurrent features where timing of re-renders matters.
|
||||
## Migration from v13
|
||||
|
||||
If you're migrating from React Native Testing Library v13, you may have existing tests using the synchronous `renderHook` API. To ease migration, we provide `deprecated_renderHookSync` which maintains the same synchronous behavior.
|
||||
|
||||
**⚠️ Deprecated**: `deprecated_renderHookSync` is provided for migration purposes only. Use async `renderHook` instead.
|
||||
|
||||
For detailed migration instructions, see the [v14 migration guide](../../migration/v14).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
"v14",
|
||||
"v13",
|
||||
"jest-matchers",
|
||||
{ "type": "dir", "name": "previous", "label": "Previous versions", "collapsed": true }
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Migration to 14.x
|
||||
|
||||
This guide describes the migration to React Native Testing Library version 14 from version 13.x.
|
||||
|
||||
## Breaking changes
|
||||
|
||||
### `renderHook` is now async by default
|
||||
|
||||
In v14, `renderHook` is now async by default and returns a Promise. This change makes it compatible with React 19, React Suspense, and `React.use()`.
|
||||
|
||||
**Before (v13):**
|
||||
|
||||
```ts
|
||||
import { renderHook } from '@testing-library/react-native';
|
||||
|
||||
it('should test hook', () => {
|
||||
const { result, rerender } = renderHook(() => useMyHook());
|
||||
|
||||
rerender(newProps);
|
||||
unmount();
|
||||
});
|
||||
```
|
||||
|
||||
**After (v14):**
|
||||
|
||||
```ts
|
||||
import { renderHook } from '@testing-library/react-native';
|
||||
|
||||
it('should test hook', async () => {
|
||||
const { result, rerender } = await renderHook(() => useMyHook());
|
||||
|
||||
await rerender(newProps);
|
||||
await unmount();
|
||||
});
|
||||
```
|
||||
|
||||
### Migration path
|
||||
|
||||
To ease migration, we provide `deprecated_renderHookSync` which maintains the same synchronous behavior as v13. This allows you to migrate gradually.
|
||||
|
||||
#### `deprecated_renderHookSync` {#deprecated-render-hook-sync}
|
||||
|
||||
```ts
|
||||
function deprecated_renderHookSync<Result, Props>(
|
||||
hookFn: (props?: Props) => Result,
|
||||
options?: RenderHookOptions<Props>
|
||||
): RenderHookSyncResult<Result, Props>;
|
||||
```
|
||||
|
||||
**⚠️ Deprecated**: This function is provided for migration purposes only. Use async `renderHook` instead.
|
||||
|
||||
The synchronous version of `renderHook` that returns immediately without awaiting React updates. This maintains backward compatibility with v13 tests but is not recommended for new code.
|
||||
|
||||
```ts
|
||||
// Old v13 code (still works but deprecated)
|
||||
import { deprecated_renderHookSync } from '@testing-library/react-native';
|
||||
|
||||
it('should increment count', () => {
|
||||
const { result, rerender } = deprecated_renderHookSync(() => useCount());
|
||||
|
||||
expect(result.current.count).toBe(0);
|
||||
act(() => {
|
||||
result.current.increment();
|
||||
});
|
||||
expect(result.current.count).toBe(1);
|
||||
|
||||
rerender();
|
||||
});
|
||||
```
|
||||
|
||||
#### Result types
|
||||
|
||||
**v13 (deprecated):**
|
||||
|
||||
```ts
|
||||
interface RenderHookSyncResult<Result, Props> {
|
||||
result: { current: Result };
|
||||
rerender: (props: Props) => void;
|
||||
unmount: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**v14 (default):**
|
||||
|
||||
```ts
|
||||
interface RenderHookResult<Result, Props> {
|
||||
result: { current: Result };
|
||||
rerender: (props: Props) => Promise<void>;
|
||||
unmount: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Both `RenderHookSyncResult` and `RenderHookResult` use `rerender` and `unmount` methods. The difference is that `RenderHookSyncResult` methods are synchronous (return `void`), while `RenderHookResult` methods are async (return `Promise<void>`).
|
||||
|
||||
## Step-by-step migration guide
|
||||
|
||||
To migrate from `deprecated_renderHookSync` to `renderHook`:
|
||||
|
||||
### 1. Add `async` to your test function
|
||||
|
||||
```ts
|
||||
// Before
|
||||
it('should test hook', () => {
|
||||
|
||||
// After
|
||||
it('should test hook', async () => {
|
||||
```
|
||||
|
||||
### 2. Await `renderHook`
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const { result } = deprecated_renderHookSync(() => useMyHook());
|
||||
|
||||
// After
|
||||
const { result } = await renderHook(() => useMyHook());
|
||||
```
|
||||
|
||||
### 3. Update `rerender` calls to await
|
||||
|
||||
```ts
|
||||
// Before
|
||||
rerender(newProps);
|
||||
|
||||
// After
|
||||
await rerender(newProps);
|
||||
```
|
||||
|
||||
### 4. Update `unmount` calls to await
|
||||
|
||||
```ts
|
||||
// Before
|
||||
unmount();
|
||||
|
||||
// After
|
||||
await unmount();
|
||||
```
|
||||
|
||||
### 5. Update `act` calls to use async `act`
|
||||
|
||||
```ts
|
||||
// Before
|
||||
act(() => {
|
||||
result.current.doSomething();
|
||||
});
|
||||
|
||||
// After
|
||||
await act(async () => {
|
||||
result.current.doSomething();
|
||||
});
|
||||
```
|
||||
|
||||
## Complete example
|
||||
|
||||
**Before (v13):**
|
||||
|
||||
```ts
|
||||
import { renderHook, act } from '@testing-library/react-native';
|
||||
|
||||
it('should increment count', () => {
|
||||
const { result, rerender } = renderHook((initialCount: number) => useCount(initialCount), {
|
||||
initialProps: 1,
|
||||
});
|
||||
|
||||
expect(result.current.count).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.increment();
|
||||
});
|
||||
|
||||
expect(result.current.count).toBe(2);
|
||||
rerender(5);
|
||||
expect(result.current.count).toBe(5);
|
||||
});
|
||||
```
|
||||
|
||||
**After (v14):**
|
||||
|
||||
```ts
|
||||
import { renderHook, act } from '@testing-library/react-native';
|
||||
|
||||
it('should increment count', async () => {
|
||||
const { result, rerender } = await renderHook((initialCount: number) => useCount(initialCount), {
|
||||
initialProps: 1,
|
||||
});
|
||||
|
||||
expect(result.current.count).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
result.current.increment();
|
||||
});
|
||||
|
||||
expect(result.current.count).toBe(2);
|
||||
await rerender(5);
|
||||
expect(result.current.count).toBe(5);
|
||||
});
|
||||
```
|
||||
|
||||
## Benefits of async `renderHook`
|
||||
|
||||
- **React 19 compatibility**: Works seamlessly with React 19's async features
|
||||
- **Suspense support**: Properly handles React Suspense boundaries and `React.use()`
|
||||
- **Better timing**: Ensures all pending React updates are executed before assertions
|
||||
- **Future-proof**: Aligns with React's direction toward async rendering
|
||||
|
||||
For more details, see the [`renderHook` API documentation](/docs/api/misc/render-hook).
|
||||
@@ -19,7 +19,7 @@ export default defineConfig({
|
||||
},
|
||||
multiVersion: {
|
||||
default: '13.x',
|
||||
versions: ['12.x', '13.x'],
|
||||
versions: ['12.x', '13.x', '14.x'],
|
||||
},
|
||||
route: {
|
||||
cleanUrls: true,
|
||||
|
||||
Reference in New Issue
Block a user