diff --git a/.changeset/tricky-bats-perform.md b/.changeset/tricky-bats-perform.md new file mode 100644 index 0000000000..b9b7a31d0d --- /dev/null +++ b/.changeset/tricky-bats-perform.md @@ -0,0 +1,5 @@ +--- +"@ima/testing-library": minor +--- + +Add `renderHookWithContext`, see the [docs](https://imajs.io/basic-features/testing/#renderhookwithcontext) for more details. diff --git a/docs/basic-features/testing.md b/docs/basic-features/testing.md index d2fcfb7481..4e967d9454 100644 --- a/docs/basic-features/testing.md +++ b/docs/basic-features/testing.md @@ -94,6 +94,17 @@ test('renders component with custom app configuration', async () => { }); ``` +### renderHookWithContext + +```javascript +async function renderHookWithContext( + hook: (props: TProps) => TResult, + options?: { contextValue?: ContextValue; app?: ImaApp } +): Promise> & { app: ImaApp | null; contextValue: ContextValue; }> +``` + +`renderHookWithContext` is a wrapper around [`renderHook` from `@testing-library/react`](https://testing-library.com/docs/react-testing-library/api#renderhook). It uses the same logic as `renderWithContext` to provide the IMA.js context. See [the `renderWithContext` section](#renderwithcontext) for more information. + ## Extending IMA boot config methods You can extend IMA boot config by using [IMA `pluginLoader.register`](https://imajs.io/api/classes/ima_core.PluginLoader/#register) method. Use the same approach as in IMA plugins. diff --git a/jest.config.base.js b/jest.config.base.js index 9bcf7ad933..9f83c386f9 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -1,6 +1,51 @@ +/** + * @type {import('jest').Config} + */ module.exports = { rootDir: '.', modulePaths: ['/'], + // @FIXME: It would be nice to generate `moduleNameMapper` dynamically, but is is not easily possible + // due to our `exports` field definitions in package.json files. But maybe YOU can figure it out and send PR? + moduleNameMapper: { + // @ima/testing-library is using this import to get the main application file + // It would map to the transpiled file by default, but we want to use the source file here + '^app/main$': '/../testing-library/src/client/app/main', + // Map all packages to their source entry points + '^@ima/cli$': '/../cli/src/index', + '^@ima/core$': '/../core/src/index', + '^@ima/core/setupJest.js$': '/../core/setupJest', + '^@ima/dev-utils/(.*)$': '/../dev-utils/src/$1', + '^@ima/error-overlay$': '/../error-overlay/src/index', + '^@ima/helpers$': '/../helpers/src/index', + '^@ima/hmr-client$': '/../hmr-client/src/index', + '^@ima/plugin-cli$': '/../plugin-cli/src/index', + '^@ima/react-page-renderer$': '/../react-page-renderer/src/index', + '^@ima/react-page-renderer/renderer/(.*)$': + '/../react-page-renderer/src/renderer/$1', + '^@ima/react-page-renderer/hook/(.*)$': + '/../react-page-renderer/src/hook/$1', + '^@ima/storybook-integration$': + '/../storybook-integration/src/index', + '^@ima/storybook-integration/preset$': + '/../storybook-integration/src/preset', + '^@ima/storybook-integration/preview$': + '/../storybook-integration/src/preview', + '^@ima/storybook-integration/helpers$': + '/../storybook-integration/src/helpers/index', + '^@ima/testing-library$': '/../testing-library/src/index', + '^@ima/testing-library/client$': + '/../testing-library/src/client/index', + '^@ima/testing-library/server$': + '/../testing-library/src/server/index', + '^@ima/testing-library/fallback/app/main$': + '/../testing-library/src/client/app/main', + '^@ima/testing-library/fallback/server/(.*)$': + '/../testing-library/src/server/$1', + '^@ima/testing-library/jest-preset$': + '/../testing-library/src/jest-preset', + '^@ima/testing-library/jestSetupFileAfterEnv$': + '/../testing-library/src/jestSetupFileAfterEnv', + }, setupFiles: ['/setupJest.js'], testRegex: '(/__tests__/).*Spec\\.jsx?$', transform: { diff --git a/packages/react-page-renderer/src/hooks/__tests__/componentSpec.js b/packages/react-page-renderer/src/hooks/__tests__/componentSpec.js index ee0cdb561c..d6354d5ab3 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/componentSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/componentSpec.js @@ -1,22 +1,17 @@ -import { renderWithContext } from '@ima/testing-library'; +import { renderHookWithContext } from '@ima/testing-library'; -import { renderHook } from '../../testUtils'; import { useComponent, useOnce } from '../component'; describe('useComponent', () => { - let result; - - it('should return object of component utility functions', () => { - renderHook(() => { - result = useComponent(); - }, {}); + it('should return object of component utility functions', async () => { + const { result } = await renderHookWithContext(() => useComponent()); expect( ['cssClasses', 'localize', 'link', 'fire', 'listen', 'unlisten'].every( - key => typeof result[key] === 'function' + key => typeof result.current[key] === 'function' ) ).toBeTruthy(); - expect(Object.keys(result)).toEqual([ + expect(Object.keys(result.current)).toEqual([ 'utils', 'cssClasses', 'localize', @@ -32,20 +27,15 @@ describe('useOnce', () => { it('should call callback only once', async () => { let count = 0; - const TestComponent = () => { - useOnce(() => count++); - - return
NotEmpty
; - }; - - const { container, rerender } = await renderWithContext(); + const { rerender } = await renderHookWithContext(() => + useOnce(() => count++) + ); - rerender(); - rerender(); - rerender(); - rerender(); + rerender(); + rerender(); + rerender(); + rerender(); - expect(container).not.toBeEmptyDOMElement(); expect(count).toBe(1); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/componentUtilsSpec.js b/packages/react-page-renderer/src/hooks/__tests__/componentUtilsSpec.js index cecba4084f..c987b3e2c5 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/componentUtilsSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/componentUtilsSpec.js @@ -1,15 +1,13 @@ -import { renderHook } from '../../testUtils'; +import { renderHookWithContext } from '@ima/testing-library'; + import { useComponentUtils } from '../componentUtils'; describe('useComponentUtils', () => { - let result; - let contextMock = { $Utils: { CustomContextHelper: {} } }; - - it('should return componentUtils', () => { - renderHook(() => { - result = useComponentUtils(); - }, contextMock); + it('should return componentUtils', async () => { + const { result, contextValue } = await renderHookWithContext(() => + useComponentUtils() + ); - expect(Object.keys(result).includes('CustomContextHelper')).toBeTruthy(); + expect(result.current).toBe(contextValue.$Utils); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/cssClassesSpec.js b/packages/react-page-renderer/src/hooks/__tests__/cssClassesSpec.js index 6d0f87eead..d375626cc4 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/cssClassesSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/cssClassesSpec.js @@ -1,20 +1,17 @@ -import { renderHook } from '../../testUtils'; +import { getContextValue, renderHookWithContext } from '@ima/testing-library'; + import { useCssClasses } from '../cssClasses'; describe('useCssClasses', () => { - let result; - let contextMock = { - $Utils: { - $CssClasses: () => '$CssClasses', - }, - }; + it('should return shortcut to $CssClasses utility', async () => { + const contextValue = await getContextValue(); + + contextValue.$Utils.$CssClasses = jest.fn().mockReturnValue('$CssClasses'); - it('should return shortcut to $CssClasses utility', () => { - renderHook(() => { - result = useCssClasses(); - }, contextMock); + const { result } = await renderHookWithContext(() => useCssClasses(), { + contextValue, + }); - expect(typeof result === 'function').toBe(true); - expect(result()).toBe('$CssClasses'); + expect(result.current()).toBe('$CssClasses'); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/dispatcherSpec.js b/packages/react-page-renderer/src/hooks/__tests__/dispatcherSpec.js index 7c4b95febd..47db11d1ca 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/dispatcherSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/dispatcherSpec.js @@ -1,22 +1,59 @@ -import React from 'react'; +import { getContextValue, renderHookWithContext } from '@ima/testing-library'; -import { renderHook } from '../../testUtils'; import { useDispatcher } from '../dispatcher'; +/** + * Create dispatcher mock instance and set it to context value. + * @param {import('@ima/testing-library').ContextValue} contextValue + * @returns {object} dispatcher mock + */ +function mockDispatcher(contextValue) { + const dispatcher = { + fire: jest.fn(), + listen: jest.fn(), + unlisten: jest.fn(), + }; + + contextValue.$Utils.$Dispatcher = dispatcher; + + return dispatcher; +} + describe('useDispatcher', () => { - let result; + it('should return `fire` callback', async () => { + const { result } = await renderHookWithContext(() => useDispatcher()); - beforeEach(() => { - jest.spyOn(React, 'useEffect').mockImplementation(f => f()); + expect(result.current.fire).toBeInstanceOf(Function); }); - it('should return `fire` callback', () => { - renderHook(() => { - result = useDispatcher(); - }); + it('should call listen/unlisten/fire methods', async () => { + const listenerArgs = ['foo', 'bar']; + const contextValue = await getContextValue(); + + const dispatcher = mockDispatcher(contextValue); + + const { result, unmount } = await renderHookWithContext( + () => useDispatcher(...listenerArgs), + { contextValue } + ); + + // Only listen should be called on component mount + expect(dispatcher.listen).toHaveBeenCalledTimes(1); + expect(dispatcher.listen).toHaveBeenCalledWith(...listenerArgs); + expect(dispatcher.unlisten).toHaveBeenCalledTimes(0); + expect(dispatcher.fire).toHaveBeenCalledTimes(0); + + result.current.fire(); + + // The hook fire method should call dispatcher fire method + expect(dispatcher.fire).toHaveBeenCalledTimes(1); + + unmount(); - expect(result).toEqual({ - fire: expect.any(Function), - }); + // Only unlisten should be called on component unmount + expect(dispatcher.listen).toHaveBeenCalledTimes(1); + expect(dispatcher.unlisten).toHaveBeenCalledTimes(1); + expect(dispatcher.unlisten).toHaveBeenCalledWith(...listenerArgs); + expect(dispatcher.fire).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/eventBusSpec.js b/packages/react-page-renderer/src/hooks/__tests__/eventBusSpec.js index 787273f306..c12adc516f 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/eventBusSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/eventBusSpec.js @@ -1,38 +1,76 @@ -import React from 'react'; +import { getContextValue, renderHookWithContext } from '@ima/testing-library'; -import { renderHook } from '../../testUtils'; import { useEventBus } from '../eventBus'; +/** + * Create eventBus mock instance and set it to context value. + * @param {import('@ima/testing-library').ContextValue} contextValue + * @returns {object} eventBus mock + */ +function mockEventBus(contextValue) { + let listener = null; + const eventBus = { + fire: jest.fn(() => { + if (listener) { + listener(); + } + }), + listen: jest.fn((_, __, fn) => { + listener = fn; + }), + unlisten: jest.fn(), + }; + + contextValue.$Utils.$EventBus = eventBus; + + return eventBus; +} + describe('useEventBus', () => { - let result, context; - - beforeEach(() => { - jest.spyOn(React, 'useEffect').mockImplementation(f => f()); - context = { - $Utils: { - $EventBus: { - listen: jest.fn(), - unlisten: jest.fn(), - }, - }, - }; + it('should return `fire` callback', async () => { + const { result } = await renderHookWithContext(() => useEventBus()); + + expect(result.current.fire).toBeInstanceOf(Function); }); - it('should return `fire` callback', () => { - renderHook(() => { - const ref = React.createRef(null); + it('should call listen/unlisten/fire methods', async () => { + const listener = jest.fn(); + const listenerArgs = ['eventTarget', 'eventName']; + const contextValue = await getContextValue(); + + const eventBus = mockEventBus(contextValue); + + const { result, unmount } = await renderHookWithContext( + () => useEventBus(...listenerArgs, listener), + { contextValue } + ); + + // Only listen should be called on component mount + expect(eventBus.listen).toHaveBeenCalledTimes(1); + expect(eventBus.listen).toHaveBeenCalledWith( + ...listenerArgs, + expect.any(Function) + ); + expect(listener).toHaveBeenCalledTimes(0); + expect(eventBus.unlisten).toHaveBeenCalledTimes(0); + expect(eventBus.fire).toHaveBeenCalledTimes(0); + + result.current.fire(); + + // The hook fire method should call eventBus fire method and trigger listener + expect(eventBus.fire).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(1); - result = useEventBus(ref, 'event', () => {}); - }, context); + unmount(); - expect(result).toEqual({ - fire: expect.any(Function), - }); - expect(context.$Utils.$EventBus.listen).toHaveBeenCalledWith( - { current: null }, - 'event', + // Only unlisten should be called on component unmount + expect(eventBus.listen).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(1); + expect(eventBus.unlisten).toHaveBeenCalledTimes(1); + expect(eventBus.unlisten).toHaveBeenCalledWith( + ...listenerArgs, expect.any(Function) ); - expect(context.$Utils.$EventBus.unlisten).not.toHaveBeenCalledWith(); + expect(eventBus.fire).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/linkSpec.js b/packages/react-page-renderer/src/hooks/__tests__/linkSpec.js index adaf83304c..bebc02b593 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/linkSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/linkSpec.js @@ -1,22 +1,19 @@ -import { renderHook } from '../../testUtils'; +import { getContextValue, renderHookWithContext } from '@ima/testing-library'; + import { useLink } from '../link'; describe('useLink', () => { - let result; - let contextMock = { - $Utils: { - $Router: { - link: () => '$Router.link() function', - }, - }, - }; + it('should return shortcut to $Router.link utility', async () => { + const contextValue = await getContextValue(); + + contextValue.$Utils.$Router.link = jest + .fn() + .mockReturnValue('$Router.link'); - it('should return shortcut to router link', () => { - renderHook(() => { - result = useLink(); - }, contextMock); + const { result } = await renderHookWithContext(() => useLink(), { + contextValue, + }); - expect(typeof result === 'function').toBe(true); - expect(result()).toBe('$Router.link() function'); + expect(result.current()).toBe('$Router.link'); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/localizeSpec.js b/packages/react-page-renderer/src/hooks/__tests__/localizeSpec.js index cca6bf4e4a..f9b4d88d64 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/localizeSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/localizeSpec.js @@ -1,22 +1,19 @@ -import { renderHook } from '../../testUtils'; +import { getContextValue, renderHookWithContext } from '@ima/testing-library'; + import { useLocalize } from '../localize'; describe('useLocalize', () => { - let result; - let contextMock = { - $Utils: { - $Dictionary: { - get: () => 'Dictionary.get() function', - }, - }, - }; + it('should return shortcut to $Dictionary.get utility', async () => { + const contextValue = await getContextValue(); + + contextValue.$Utils.$Dictionary.get = jest + .fn() + .mockReturnValue('$Dictionary.get'); - it('should return shortcut to $Dictionary.get function', () => { - renderHook(() => { - result = useLocalize(); - }, contextMock); + const { result } = await renderHookWithContext(() => useLocalize(), { + contextValue, + }); - expect(typeof result === 'function').toBe(true); - expect(result()).toBe('Dictionary.get() function'); + expect(result.current()).toBe('$Dictionary.get'); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/pageContextSpec.js b/packages/react-page-renderer/src/hooks/__tests__/pageContextSpec.js index 83348a6ab9..3cad6be4cf 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/pageContextSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/pageContextSpec.js @@ -1,21 +1,13 @@ -import { renderHook } from '../../testUtils'; +import { renderHookWithContext } from '@ima/testing-library'; + import { usePageContext } from '../pageContext'; describe('usePageContext', () => { - let result; - let contextMock = { - customContextValues: 'value', - anotherOne: false, - }; - - it('should return pageContext', () => { - renderHook(() => { - result = usePageContext(); - }, contextMock); + it('should return context', async () => { + const { result, contextValue } = await renderHookWithContext(() => + usePageContext() + ); - expect(Object.keys(result)).toStrictEqual([ - 'customContextValues', - 'anotherOne', - ]); + expect(result.current).toBe(contextValue); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/settingsSpec.js b/packages/react-page-renderer/src/hooks/__tests__/settingsSpec.js index 25f2f2e961..5c0bd1d44a 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/settingsSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/settingsSpec.js @@ -1,40 +1,46 @@ -import { renderHook } from '../../testUtils'; +import { getContextValue, renderHookWithContext } from '@ima/testing-library'; + import { useSettings } from '../settings'; describe('useSettings', () => { - let result; - let contextMock = { - $Utils: { - $Settings: { - $Page: { - scripts: ['script.js'], - }, - documentView: 'documentView', + let contextValue; + + beforeEach(async () => { + contextValue = await getContextValue(); + + contextValue.$Utils.$Settings = { + $Page: { + scripts: ['script.js'], }, - }, - }; + documentView: 'documentView', + }; + }); - it('should return settings object by default', () => { - renderHook(() => { - result = useSettings(); - }, contextMock); + it('should return settings object by default', async () => { + const { result } = await renderHookWithContext(() => useSettings(), { + contextValue, + }); - expect(result).toStrictEqual(contextMock.$Utils.$Settings); + expect(result.current).toStrictEqual(contextValue.$Utils.$Settings); }); - it('should return specific sub-settings for given selector', () => { - renderHook(() => { - result = useSettings('$Page.scripts'); - }, contextMock); + it('should return specific sub-settings for given selector', async () => { + const { result } = await renderHookWithContext( + () => useSettings('$Page.scripts'), + { contextValue } + ); - expect(result).toStrictEqual(contextMock.$Utils.$Settings.$Page.scripts); + expect(result.current).toStrictEqual( + contextValue.$Utils.$Settings.$Page.scripts + ); }); - it('should return empty object for invalid selectors', () => { - renderHook(() => { - result = useSettings('invalid.settings.path'); - }, contextMock); + it('should return empty object for invalid selectors', async () => { + const { result } = await renderHookWithContext( + () => useSettings('invalid.settings.path'), + { contextValue } + ); - expect(result).toBeUndefined(); + expect(result.current).toBeUndefined(); }); }); diff --git a/packages/react-page-renderer/src/hooks/__tests__/windowEventSpec.js b/packages/react-page-renderer/src/hooks/__tests__/windowEventSpec.js index cd8dd2d0d0..7d96dc4a68 100644 --- a/packages/react-page-renderer/src/hooks/__tests__/windowEventSpec.js +++ b/packages/react-page-renderer/src/hooks/__tests__/windowEventSpec.js @@ -1,61 +1,58 @@ -import React from 'react'; +import { getContextValue, renderHookWithContext } from '@ima/testing-library'; -import { renderHook } from '../../testUtils'; import { useWindowEvent } from '../windowEvent'; describe('useWindowEvent', () => { - let result; + let contextValue; let windowMock = { dispatchEvent: jest.fn(), }; - let contextMock = { - $Utils: { - $Window: { - getWindow: jest.fn().mockReturnValue(windowMock), - createCustomEvent: jest.fn(), - bindEventListener: jest.fn(), - unbindEventListener: jest.fn(), - }, - }, - }; - afterEach(() => { jest.clearAllMocks(); }); - beforeEach(() => { - jest.spyOn(React, 'useEffect').mockImplementation(f => f()); + beforeEach(async () => { + contextValue = await getContextValue(); + + contextValue.$Utils.$Window = { + getWindow: jest.fn().mockReturnValue(windowMock), + createCustomEvent: jest.fn(), + bindEventListener: jest.fn(), + unbindEventListener: jest.fn(), + }; }); - it('should return window and utility functions', () => { - renderHook(() => { - result = useWindowEvent('custom-target', 'custom-event', jest.fn()); - - expect(result).toMatchInlineSnapshot(` - { - "createCustomEvent": [Function], - "dispatchEvent": [Function], - "window": { - "dispatchEvent": [MockFunction], - }, - } - `); - }, contextMock); + it('should return window and utility functions', async () => { + const { result } = await renderHookWithContext( + () => useWindowEvent('custom-target', 'custom-event', jest.fn()), + { contextValue } + ); + + expect(result.current).toMatchInlineSnapshot(` + { + "createCustomEvent": [Function], + "dispatchEvent": [Function], + "window": { + "dispatchEvent": [MockFunction], + }, + } + `); }); - it('should bind events correctly', () => { + it('should bind events correctly', async () => { let cb = jest.fn(); - renderHook(() => { - result = useWindowEvent('custom-target', 'custom-event', cb, true); - - expect(contextMock.$Utils.$Window.bindEventListener).toHaveBeenCalledWith( - 'custom-target', - 'custom-event', - cb, - true - ); - }, contextMock); + await renderHookWithContext( + () => useWindowEvent('custom-target', 'custom-event', cb, true), + { contextValue } + ); + + expect(contextValue.$Utils.$Window.bindEventListener).toHaveBeenCalledWith( + 'custom-target', + 'custom-event', + cb, + true + ); }); }); diff --git a/packages/react-page-renderer/src/testUtils.jsx b/packages/react-page-renderer/src/testUtils.jsx deleted file mode 100644 index 10de47bb09..0000000000 --- a/packages/react-page-renderer/src/testUtils.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import { render } from '@ima/testing-library'; - -import { PageContext } from './PageContext'; - -/** - * - * @param {Function} callback - * @param {object} context - * @param {object} props - * @returns {ReactWrapper} - */ -function renderHook(callback, context = {}, props = {}) { - const TestHookComponent = ({ __callback__ }) => { - __callback__(); - return null; - }; - - return render( - - - - ); -} - -export { renderHook }; diff --git a/packages/testing-library/src/client/configuration.ts b/packages/testing-library/src/client/configuration.ts index 7257aff6e9..ce1d0a1cbe 100644 --- a/packages/testing-library/src/client/configuration.ts +++ b/packages/testing-library/src/client/configuration.ts @@ -1,4 +1,9 @@ -import type { ContextValue, ImaApp, ImaRenderResult } from '../types'; +import type { + ContextValue, + ImaApp, + ImaRenderResult, + ImaRenderHookResult, +} from '../types'; export interface ClientConfiguration { /** @@ -33,6 +38,18 @@ export interface ClientConfiguration { contextValue, ...result }: ImaRenderResult) => void | Promise; + beforeRenderHookWithContext: ({ + app, + contextValue, + }: { + app: ImaApp | null; + contextValue: ContextValue; + }) => void | Promise; + afterRenderHookWithContext({ + app, + contextValue, + ...result + }: ImaRenderHookResult): void | Promise; } const clientConfiguration: ClientConfiguration = { @@ -45,6 +62,8 @@ const clientConfiguration: ClientConfiguration = { }), beforeRenderWithContext: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function afterRenderWithContext: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + beforeRenderHookWithContext: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + afterRenderHookWithContext: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function }; /** diff --git a/packages/testing-library/src/rtl.tsx b/packages/testing-library/src/rtl.tsx index 1109e18f8f..6d66d02818 100644 --- a/packages/testing-library/src/rtl.tsx +++ b/packages/testing-library/src/rtl.tsx @@ -1,6 +1,6 @@ import { PageContext } from '@ima/react-page-renderer'; -import { render } from '@testing-library/react'; -import type { RenderOptions } from '@testing-library/react'; +import { render, renderHook } from '@testing-library/react'; +import type { RenderOptions, RenderHookOptions } from '@testing-library/react'; import type { ReactElement } from 'react'; // import of app/main is resolved by the jest moduleNameMapper @@ -12,6 +12,7 @@ import type { ContextValue, ImaApp, ImaContextWrapper, + ImaRenderHookResult, ImaRenderResult, } from './types'; @@ -108,5 +109,39 @@ async function renderWithContext( }; } +async function renderHookWithContext( + hook: (props: TProps) => TResult, + options?: RenderHookOptions & { + contextValue?: ContextValue; + app?: ImaApp; + } +): Promise> { + let { app = null, contextValue, ...rest } = options ?? {}; // eslint-disable-line prefer-const + + if (!contextValue) { + if (!app) { + app = await initImaApp(); + } + + contextValue = await getContextValue(app); + } + + const wrapper = await getContextWrapper(contextValue); + + const config = getImaTestingLibraryClientConfig(); + + await config.beforeRenderHookWithContext({ app, contextValue }); + + const result = renderHook(hook, { ...rest, wrapper }); + + await config.afterRenderHookWithContext({ app, contextValue, ...result }); + + return { + ...result, + app, + contextValue, + }; +} + export * from '@testing-library/react'; -export { renderWithContext }; +export { renderHookWithContext, renderWithContext }; diff --git a/packages/testing-library/src/types.ts b/packages/testing-library/src/types.ts index 23c3930437..569b90581d 100644 --- a/packages/testing-library/src/types.ts +++ b/packages/testing-library/src/types.ts @@ -1,5 +1,5 @@ import type { Utils, createImaApp } from '@ima/core'; -import { render } from '@testing-library/react'; +import { render, renderHook } from '@testing-library/react'; export interface ContextValue { $Utils: Utils; @@ -7,7 +7,12 @@ export interface ContextValue { export type ImaApp = ReturnType; export type ImaContextWrapper = React.FC<{ children: React.ReactNode }>; -export type ImaRenderResult = ReturnType & { +export type ITLResultExtension = { app: ImaApp | null; contextValue: ContextValue; }; +export type ImaRenderResult = ReturnType & ITLResultExtension; +export type ImaRenderHookResult = ReturnType< + typeof renderHook +> & + ITLResultExtension;