Skip to content

Commit

Permalink
Merge pull request #593 from seznam/cnsqa-1727
Browse files Browse the repository at this point in the history
feat: add renderHookWithContext to ITL
  • Loading branch information
Filipoliko authored Nov 29, 2024
2 parents 3cc39cf + 550793a commit 19e5cb7
Show file tree
Hide file tree
Showing 17 changed files with 369 additions and 225 deletions.
5 changes: 5 additions & 0 deletions .changeset/tricky-bats-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ima/testing-library": minor
---

Add `renderHookWithContext`, see the [docs](https://imajs.io/basic-features/testing/#renderhookwithcontext) for more details.
11 changes: 11 additions & 0 deletions docs/basic-features/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ test('renders component with custom app configuration', async () => {
});
```

### renderHookWithContext

```javascript
async function renderHookWithContext<TResult, TProps>(
hook: (props: TProps) => TResult,
options?: { contextValue?: ContextValue; app?: ImaApp }
): Promise<ReturnType<typeof renderHook<TResult, TProps>> & { 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.
Expand Down
45 changes: 45 additions & 0 deletions jest.config.base.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,51 @@
/**
* @type {import('jest').Config}
*/
module.exports = {
rootDir: '.',
modulePaths: ['<rootDir>/'],
// @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$': '<rootDir>/../testing-library/src/client/app/main',
// Map all packages to their source entry points
'^@ima/cli$': '<rootDir>/../cli/src/index',
'^@ima/core$': '<rootDir>/../core/src/index',
'^@ima/core/setupJest.js$': '<rootDir>/../core/setupJest',
'^@ima/dev-utils/(.*)$': '<rootDir>/../dev-utils/src/$1',
'^@ima/error-overlay$': '<rootDir>/../error-overlay/src/index',
'^@ima/helpers$': '<rootDir>/../helpers/src/index',
'^@ima/hmr-client$': '<rootDir>/../hmr-client/src/index',
'^@ima/plugin-cli$': '<rootDir>/../plugin-cli/src/index',
'^@ima/react-page-renderer$': '<rootDir>/../react-page-renderer/src/index',
'^@ima/react-page-renderer/renderer/(.*)$':
'<rootDir>/../react-page-renderer/src/renderer/$1',
'^@ima/react-page-renderer/hook/(.*)$':
'<rootDir>/../react-page-renderer/src/hook/$1',
'^@ima/storybook-integration$':
'<rootDir>/../storybook-integration/src/index',
'^@ima/storybook-integration/preset$':
'<rootDir>/../storybook-integration/src/preset',
'^@ima/storybook-integration/preview$':
'<rootDir>/../storybook-integration/src/preview',
'^@ima/storybook-integration/helpers$':
'<rootDir>/../storybook-integration/src/helpers/index',
'^@ima/testing-library$': '<rootDir>/../testing-library/src/index',
'^@ima/testing-library/client$':
'<rootDir>/../testing-library/src/client/index',
'^@ima/testing-library/server$':
'<rootDir>/../testing-library/src/server/index',
'^@ima/testing-library/fallback/app/main$':
'<rootDir>/../testing-library/src/client/app/main',
'^@ima/testing-library/fallback/server/(.*)$':
'<rootDir>/../testing-library/src/server/$1',
'^@ima/testing-library/jest-preset$':
'<rootDir>/../testing-library/src/jest-preset',
'^@ima/testing-library/jestSetupFileAfterEnv$':
'<rootDir>/../testing-library/src/jestSetupFileAfterEnv',
},
setupFiles: ['<rootDir>/setupJest.js'],
testRegex: '(/__tests__/).*Spec\\.jsx?$',
transform: {
Expand Down
34 changes: 12 additions & 22 deletions packages/react-page-renderer/src/hooks/__tests__/componentSpec.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -32,20 +27,15 @@ describe('useOnce', () => {
it('should call callback only once', async () => {
let count = 0;

const TestComponent = () => {
useOnce(() => count++);

return <div>NotEmpty</div>;
};

const { container, rerender } = await renderWithContext(<TestComponent />);
const { rerender } = await renderHookWithContext(() =>
useOnce(() => count++)
);

rerender(<TestComponent />);
rerender(<TestComponent />);
rerender(<TestComponent />);
rerender(<TestComponent />);
rerender();
rerender();
rerender();
rerender();

expect(container).not.toBeEmptyDOMElement();
expect(count).toBe(1);
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 10 additions & 13 deletions packages/react-page-renderer/src/hooks/__tests__/cssClassesSpec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
61 changes: 49 additions & 12 deletions packages/react-page-renderer/src/hooks/__tests__/dispatcherSpec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
90 changes: 64 additions & 26 deletions packages/react-page-renderer/src/hooks/__tests__/eventBusSpec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 12 additions & 15 deletions packages/react-page-renderer/src/hooks/__tests__/linkSpec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading

0 comments on commit 19e5cb7

Please sign in to comment.