diff --git a/README.md b/README.md index e2083c6d..6af9b1bc 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ See [Recipes](./docs/recipes.md) for more examples. - `mount` is the most important function, allows to mount a given React component as a mini web application and interact with it using Cypress commands - `createMount` factory function that creates new `mount` function with default options - `unmount` removes previously mounted component, mostly useful to test how the component cleans up after itself -- `mountHook` mounts a given React Hook in a test component for full testing, see `hooks` example +- `mountHook` mounts a given React Hook in a test component for full testing, see the [`hooks` example](cypress/component/advanced/hooks) ## Examples @@ -191,7 +191,7 @@ Spec | Description [context](cypress/component/advanced/context) | Confirms components that use React context feature work [custom-command](cypress/component/advanced/custom-command) | Wraps `mount` in a custom command for convenience [forward-ref](cypress/component/advanced/forward-ref) | Tests a component that uses a forward ref feature -[hooks](cypress/component/advanced/hooks) | Tests several components that use React Hooks like `useState`, `useCallback` +[hooks](cypress/component/advanced/hooks) | Tests several components that use React Hooks like `useState`, `useCallback` by using `mountHook` function [lazy-loaded](cypress/component/advanced/lazy-loaded) | Confirms components that use `React.lazy` and dynamic imports work [material-ui-example](cypress/component/advanced/material-ui-example) | Large components demos from [Material UI](https://material-ui.com/) [mobx-v6](cypress/component/advanced/mobx-v6) | Test components with MobX v6 observable diff --git a/cypress/component/advanced/hooks/README.md b/cypress/component/advanced/hooks/README.md index 053f08b3..716b61a1 100644 --- a/cypress/component/advanced/hooks/README.md +++ b/cypress/component/advanced/hooks/README.md @@ -2,7 +2,19 @@ - [counter-with-hooks.spec.js](counter-with-hooks.spec.js) and [counter2-with-hooks.spec.js](counter2-with-hooks.spec.js) test React components that uses hooks - [use-counter.spec.js](use-counter.spec.js) shows how to test a React hook using `mountHook` function +- [custom-hook.mount-spec.js](custom-hook.mount-spec.js) manually creates a wrapper component around a custom hook that uses Redux provider +- [custom-hook.mount-hook-spec.js](custom-hook.mount-hook-spec.js) shows how `mountHook` can be surrounded with `wrapper` element ![Hook test](images/hook.png) Note: hooks are mounted inside a test component following the approach shown in [react-hooks-testing-library](https://github.com/testing-library/react-hooks-testing-library/blob/master/src/pure.js) + +Example: + +```js +import { mountHook } from 'cypress-react-unit-test' +// wrapper is optional, only if your hook requires +// something like a context provider +const wrapper = ({ children }) => {children} +mountHook(() => useCustomHook(), { wrapper }) +``` diff --git a/cypress/component/advanced/hooks/count-reducer.js b/cypress/component/advanced/hooks/count-reducer.js new file mode 100644 index 00000000..e988ad75 --- /dev/null +++ b/cypress/component/advanced/hooks/count-reducer.js @@ -0,0 +1,10 @@ +export const countReducer = function(state = 0, action) { + switch (action.type) { + case 'INCREMENT': + return state + 1 + case 'DECREMENT': + return state - 1 + default: + return state + } +} diff --git a/cypress/component/advanced/hooks/custom-hook.js b/cypress/component/advanced/hooks/custom-hook.js new file mode 100644 index 00000000..2e752937 --- /dev/null +++ b/cypress/component/advanced/hooks/custom-hook.js @@ -0,0 +1,10 @@ +import { useEffect } from 'react' +import { useDispatch } from 'react-redux' + +export function useCustomHook() { + useDispatch() + + useEffect(() => { + console.log('hello world!') + }, []) +} diff --git a/cypress/component/advanced/hooks/custom-hook.mount-hook-spec.js b/cypress/component/advanced/hooks/custom-hook.mount-hook-spec.js new file mode 100644 index 00000000..d66bc0eb --- /dev/null +++ b/cypress/component/advanced/hooks/custom-hook.mount-hook-spec.js @@ -0,0 +1,20 @@ +/// +import React from 'react' +import { mountHook } from 'cypress-react-unit-test' +const { useCustomHook } = require('./custom-hook') +import { Provider } from 'react-redux' +import store from './store' + +describe('custom hook that needs redux provider', () => { + it('mounted with wrapper', () => { + const wrapper = ({ children }) => ( + {children} + ) + + cy.spy(console, 'log').as('log') + mountHook(() => useCustomHook(), { wrapper }) + + // make sure the custom hook calls "useEffect" + cy.get('@log').should('have.been.calledWith', 'hello world!') + }) +}) diff --git a/cypress/component/advanced/hooks/custom-hook.mount-spec.js b/cypress/component/advanced/hooks/custom-hook.mount-spec.js new file mode 100644 index 00000000..fb0ac306 --- /dev/null +++ b/cypress/component/advanced/hooks/custom-hook.mount-spec.js @@ -0,0 +1,23 @@ +/// +import React from 'react' +import { mount } from 'cypress-react-unit-test' +const { useCustomHook } = require('./custom-hook') +import { Provider } from 'react-redux' +import store from './store' + +describe('custom hook that needs redux provider', () => { + it('mounts if we make a test component around it', () => { + const App = () => { + useCustomHook() + + return <> + } + cy.spy(console, 'log').as('log') + mount( + + + , + ) + cy.get('@log').should('have.been.calledWith', 'hello world!') + }) +}) diff --git a/cypress/component/advanced/hooks/store.js b/cypress/component/advanced/hooks/store.js new file mode 100644 index 00000000..f75d0b10 --- /dev/null +++ b/cypress/component/advanced/hooks/store.js @@ -0,0 +1,6 @@ +import { createStore } from 'redux' +import { countReducer } from './count-reducer' + +const store = createStore(countReducer) + +export default store diff --git a/lib/mountHook.ts b/lib/mountHook.ts index 97d23c87..e8bc7d8d 100644 --- a/lib/mountHook.ts +++ b/lib/mountHook.ts @@ -60,21 +60,39 @@ function TestHook({ callback, onError, children }: TestHookProps) { return null } +type MountHookOptions = { + wrapper?: React.ReactElement +} + /** * Mounts a React hook function in a test component for testing. * * @see https://github.com/bahmutov/cypress-react-unit-test#advanced-examples */ -export const mountHook = (hookFn: (...args: any[]) => any) => { +export const mountHook = ( + hookFn: (...args: any[]) => any, + options: MountHookOptions = {}, +) => { const { result, setValue, setError } = resultContainer() - return mount( - React.createElement(TestHook, { - callback: hookFn, - onError: setError, - children: setValue, - }), - ).then(() => { + const testElement = React.createElement(TestHook, { + callback: hookFn, + onError: setError, + children: setValue, + key: Math.random().toString(), + }) + + let mountElement: any = testElement + if (options.wrapper) { + // what's the proper type? I don't even care anymore + // because types for React seem to be a mess + // @ts-ignore + mountElement = React.createElement(options.wrapper, { + children: [testElement], + }) + } + + return mount(mountElement).then(() => { cy.wrap(result) }) } diff --git a/package-lock.json b/package-lock.json index e6a6d34d..3d14b331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35383,6 +35383,42 @@ } } }, + "react-redux": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.2.tgz", + "integrity": "sha512-8+CQ1EvIVFkYL/vu6Olo7JFLWop1qRUeb46sGtIMDCSpgwPQq8fPLpirIB0iTqFe9XYEFPHssdX8/UwN6pAkEA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.1", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.13.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, "react-refresh": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz", @@ -37093,6 +37129,16 @@ "esprima": "~4.0.0" } }, + "redux": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", + "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "symbol-observable": "^1.2.0" + } + }, "regenerate": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", diff --git a/package.json b/package.json index d286e6ea..df948dc8 100644 --- a/package.json +++ b/package.json @@ -114,9 +114,11 @@ "react-google-maps": "9.4.5", "react-i18next": "11.7.2", "react-loading-skeleton": "2.0.1", + "react-redux": "7.2.2", "react-router": "6.0.0-alpha.1", "react-router-dom": "6.0.0-alpha.1", "react-scripts": "3.4.1", + "redux": "4.0.5", "rollup-plugin-istanbul": "2.0.1", "semantic-release": "17.2.2", "standard": "14.3.3",