├── .gitignore ├── LICENSE ├── README.md ├── config ├── env.js ├── jest │ ├── cssTransform.js │ ├── fileTransform.js │ └── test-setup.js ├── paths.js ├── webpack.config.js └── webpackDevServer.config.js ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── scripts ├── build.js ├── start.js └── test.js ├── src ├── App.css ├── App.js ├── App.test.js ├── components │ ├── Greet │ │ ├── Greet.js │ │ ├── Greet.test.js │ │ └── index.js │ └── LoginForm │ │ ├── LoginForm.js │ │ ├── LoginForm.styles.css │ │ ├── LoginForm.test.js │ │ └── index.js ├── hooks │ ├── useFormField.js │ └── useFormField.test.js ├── index.css ├── index.js └── serviceWorker.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Dinesh Pandiyan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Testing-Hooks 2 | 3 | This repo lays down different strategies to test React custom hooks and components that use hooks. 4 | 5 | ## Test Strategies 6 | 7 | There are broadly two strategies to test our React codebase. 8 | 9 | 1. Testing user observable behaviour 10 | 2. Testing implementation details 11 | 12 | ### Testing user observable behaviour 13 | 14 | Testing user observable behaviour means writing tests against components that test 15 | 16 | - how the component is rendered 17 | - how the component is re-rendered when user interacts with the DOM 18 | - how props/state control what is rendered 19 | 20 | Consider the following component - `Greet` 21 | 22 | ```jsx 23 | function Greet({ user = 'User' }) { 24 | const [name, setName] = React.useState(user); 25 | 26 | return
setName('Pinocchio')}>Hello, {name}!
; 27 | } 28 | ``` 29 | 30 | Testing the user observable behaviour in `Greet` would mean 31 | 32 | - test if `Greet` is rendered without crashing 33 | - test if `Hello, User!` is rendered when user prop is not passed 34 | - test if `Hello, Bruce!` is rendered when `Bruce` is passed as value to `user` prop 35 | - test if the text changes to `Hello, Pinocchio!` when the user clicks on the element 36 | 37 | ### Testing implementation details 38 | 39 | Testing implementation details means writing tests against state logic that test 40 | 41 | - how the state is initialized with default/prop values 42 | - how the state changes when handlers are invoked 43 | 44 | Consider the same component - `Greet` 45 | 46 | ```jsx 47 | function Greet({ user = 'User' }) { 48 | const [name, setName] = React.useState(user); 49 | 50 | return
setName('Pinocchio')}>Hello, {name}!
; 51 | } 52 | ``` 53 | 54 | Testing implementation details in `Greet` would mean 55 | 56 | - test if `name` is set to default value `User` when user prop is not passed to `Greet` 57 | - test if `name` is set to prop value when user prop is passed to `Greet` 58 | - test if `name` is updated when `setName` is invoked 59 | 60 | ## Test custom hooks with Enzyme 61 | 62 | _Note: Please make sure your React version is `^16.8.5`. Hooks won't re-render components with enzyme shallow render in previous versions and the React team fixed it in this release. If your React version is below that, you might have to use enzyme mount and `.update()` your wrapper after each change to test the re-render._ 63 | 64 | Testing implementation details might seem unnecessary and might even be considered as a bad practice when you are writing tests against components that contains presentational (UI) logic and render elements to the DOM. But **custom hooks** contain only **state logic** and it is imperative that we test the implementation details thoroughly so we know exactly how our custom hook will behave within a component. 65 | 66 | Let's write a custom hook to update and validate a form field. 67 | 68 | ```js 69 | /* useFormField.js */ 70 | 71 | import React from 'react'; 72 | 73 | function useFormField(initialVal = '') { 74 | const [val, setVal] = React.useState(initialVal); 75 | const [isValid, setValid] = React.useState(true); 76 | 77 | function onChange(e) { 78 | setVal(e.target.value); 79 | 80 | if (!e.target.value) { 81 | setValid(false); 82 | } else if (!isValid) setValid(true); 83 | } 84 | 85 | return [val, onChange, isValid]; 86 | } 87 | 88 | export default useFormField; 89 | ``` 90 | 91 | **As great as custom hooks are in abstracting away re-usable logic in our code, they do have one limitation. Even though they are just JavaScript functions they will work only inside React components. You cannot just invoke them and write tests against what a hook returns. You have to wrap them inside a React component and test the values that it returns.** 92 | 93 | - custom hooks cannot be tested like JavaScript functions 94 | - custom hooks should be wrapped inside a React component to test its behaviour 95 | 96 | Thanks to the composibility of hooks, we could pass a hook as a prop to a component and everything will work exactly as how it's supposed to work. We can write a wrapper component to render and test our hook. 97 | 98 | ```jsx 99 | /* useFormField.test.js */ 100 | 101 | function HookWrapper(props) { 102 | const hook = props.hook ? props.hook() : undefined; 103 | return
; 104 | } 105 | ``` 106 | 107 | Now we can access the hook like a JavaScript object and test its behaviour. 108 | 109 | ```jsx 110 | /* useFormField.test.js */ 111 | 112 | import React from 'react'; 113 | import { shallow } from 'enzyme'; 114 | import useFormField from './useFormField'; 115 | 116 | function HookWrapper(props) { 117 | const hook = props.hook ? props.hook() : undefined; 118 | return
; 119 | } 120 | 121 | it('should set init value', () => { 122 | let wrapper = shallow( useFormField('')} />); 123 | 124 | let { hook } = wrapper.find('div').props(); 125 | let [val, onChange, isValid] = hook; 126 | expect(val).toEqual(''); 127 | 128 | wrapper = shallow( useFormField('marco')} />); 129 | 130 | // destructuring objects - {} should be inside brackets - () to avoid syntax error 131 | ({ hook } = wrapper.find('div').props()); 132 | [val, onChange, isValid] = hook; 133 | expect(val).toEqual('marco'); 134 | }); 135 | ``` 136 | 137 | The full test suite for `useFormField` custom hook will look like this. 138 | 139 | ```jsx 140 | /* useFormField.test.js */ 141 | 142 | import React from 'react'; 143 | import { shallow } from 'enzyme'; 144 | import useFormField from './useFormField'; 145 | 146 | function HookWrapper(props) { 147 | const hook = props.hook ? props.hook() : undefined; 148 | return
; 149 | } 150 | 151 | describe('useFormField', () => { 152 | it('should render', () => { 153 | let wrapper = shallow(); 154 | 155 | expect(wrapper.exists()).toBeTruthy(); 156 | }); 157 | 158 | it('should set init value', () => { 159 | let wrapper = shallow( useFormField('')} />); 160 | 161 | let { hook } = wrapper.find('div').props(); 162 | let [val, onChange, isValid] = hook; 163 | expect(val).toEqual(''); 164 | 165 | wrapper = shallow( useFormField('marco')} />); 166 | 167 | // destructuring objects - {} should be inside brackets - () to avoid syntax error 168 | ({ hook } = wrapper.find('div').props()); 169 | [val, onChange, isValid] = hook; 170 | expect(val).toEqual('marco'); 171 | }); 172 | 173 | it('should set the right val value', () => { 174 | let wrapper = shallow( useFormField('marco')} />); 175 | 176 | let { hook } = wrapper.find('div').props(); 177 | let [val, onChange, isValid] = hook; 178 | expect(val).toEqual('marco'); 179 | 180 | onChange({ target: { value: 'polo' } }); 181 | 182 | ({ hook } = wrapper.find('div').props()); 183 | [val, onChange, isValid] = hook; 184 | expect(val).toEqual('polo'); 185 | }); 186 | 187 | it('should set the right isValid value', () => { 188 | let wrapper = shallow( useFormField('marco')} />); 189 | 190 | let { hook } = wrapper.find('div').props(); 191 | let [val, onChange, isValid] = hook; 192 | expect(val).toEqual('marco'); 193 | expect(isValid).toEqual(true); 194 | 195 | onChange({ target: { value: 'polo' } }); 196 | 197 | ({ hook } = wrapper.find('div').props()); 198 | [val, onChange, isValid] = hook; 199 | expect(val).toEqual('polo'); 200 | expect(isValid).toEqual(true); 201 | 202 | onChange({ target: { value: '' } }); 203 | 204 | ({ hook } = wrapper.find('div').props()); 205 | [val, onChange, isValid] = hook; 206 | expect(val).toEqual(''); 207 | expect(isValid).toEqual(false); 208 | }); 209 | }); 210 | ``` 211 | 212 | Rendering the custom hook and accessing it as a prop should give us full access to its return values. 213 | 214 | Happy testing! 215 | 216 | ## License 217 | 218 | MIT © Dinesh Pandiyan 219 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | 7 | // Make sure that including paths.js after env.js will read .env variables. 8 | delete require.cache[require.resolve('./paths')]; 9 | 10 | const NODE_ENV = process.env.NODE_ENV; 11 | if (!NODE_ENV) { 12 | throw new Error( 13 | 'The NODE_ENV environment variable is required but was not specified.' 14 | ); 15 | } 16 | 17 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 18 | var dotenvFiles = [ 19 | `${paths.dotenv}.${NODE_ENV}.local`, 20 | `${paths.dotenv}.${NODE_ENV}`, 21 | // Don't include `.env.local` for `test` environment 22 | // since normally you expect tests to produce the same 23 | // results for everyone 24 | NODE_ENV !== 'test' && `${paths.dotenv}.local`, 25 | paths.dotenv, 26 | ].filter(Boolean); 27 | 28 | // Load environment variables from .env* files. Suppress warnings using silent 29 | // if this file is missing. dotenv will never modify any environment variables 30 | // that have already been set. Variable expansion is supported in .env files. 31 | // https://github.com/motdotla/dotenv 32 | // https://github.com/motdotla/dotenv-expand 33 | dotenvFiles.forEach(dotenvFile => { 34 | if (fs.existsSync(dotenvFile)) { 35 | require('dotenv-expand')( 36 | require('dotenv').config({ 37 | path: dotenvFile, 38 | }) 39 | ); 40 | } 41 | }); 42 | 43 | // We support resolving modules according to `NODE_PATH`. 44 | // This lets you use absolute paths in imports inside large monorepos: 45 | // https://github.com/facebook/create-react-app/issues/253. 46 | // It works similar to `NODE_PATH` in Node itself: 47 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 48 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 49 | // Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. 50 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 51 | // We also resolve them to make sure all tools using them work consistently. 52 | const appDirectory = fs.realpathSync(process.cwd()); 53 | process.env.NODE_PATH = (process.env.NODE_PATH || '') 54 | .split(path.delimiter) 55 | .filter(folder => folder && !path.isAbsolute(folder)) 56 | .map(folder => path.resolve(appDirectory, folder)) 57 | .join(path.delimiter); 58 | 59 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 60 | // injected into the application via DefinePlugin in Webpack configuration. 61 | const REACT_APP = /^REACT_APP_/i; 62 | 63 | function getClientEnvironment(publicUrl) { 64 | const raw = Object.keys(process.env) 65 | .filter(key => REACT_APP.test(key)) 66 | .reduce( 67 | (env, key) => { 68 | env[key] = process.env[key]; 69 | return env; 70 | }, 71 | { 72 | // Useful for determining whether we’re running in production mode. 73 | // Most importantly, it switches React into the correct mode. 74 | NODE_ENV: process.env.NODE_ENV || 'development', 75 | // Useful for resolving the correct path to static assets in `public`. 76 | // For example, . 77 | // This should only be used as an escape hatch. Normally you would put 78 | // images into the `src` and `import` them in code to get their paths. 79 | PUBLIC_URL: publicUrl, 80 | } 81 | ); 82 | // Stringify all values so we can feed into Webpack DefinePlugin 83 | const stringified = { 84 | 'process.env': Object.keys(raw).reduce((env, key) => { 85 | env[key] = JSON.stringify(raw[key]); 86 | return env; 87 | }, {}), 88 | }; 89 | 90 | return { raw, stringified }; 91 | } 92 | 93 | module.exports = getClientEnvironment; 94 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return 'module.exports = {};'; 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return 'cssTransform'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | // This is a custom Jest transformer turning file imports into filenames. 6 | // http://facebook.github.io/jest/docs/en/webpack.html 7 | 8 | module.exports = { 9 | process(src, filename) { 10 | const assetFilename = JSON.stringify(path.basename(filename)); 11 | 12 | if (filename.match(/\.svg$/)) { 13 | return `const React = require('react'); 14 | module.exports = { 15 | __esModule: true, 16 | default: ${assetFilename}, 17 | ReactComponent: React.forwardRef((props, ref) => ({ 18 | $$typeof: Symbol.for('react.element'), 19 | type: 'svg', 20 | ref: ref, 21 | key: null, 22 | props: Object.assign({}, props, { 23 | children: ${assetFilename} 24 | }) 25 | })), 26 | };`; 27 | } 28 | 29 | return `module.exports = ${assetFilename};`; 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /config/jest/test-setup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const url = require('url'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebook/create-react-app/issues/637 9 | const appDirectory = fs.realpathSync(process.cwd()); 10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 11 | 12 | const envPublicUrl = process.env.PUBLIC_URL; 13 | 14 | function ensureSlash(inputPath, needsSlash) { 15 | const hasSlash = inputPath.endsWith('/'); 16 | if (hasSlash && !needsSlash) { 17 | return inputPath.substr(0, inputPath.length - 1); 18 | } else if (!hasSlash && needsSlash) { 19 | return `${inputPath}/`; 20 | } else { 21 | return inputPath; 22 | } 23 | } 24 | 25 | const getPublicUrl = appPackageJson => 26 | envPublicUrl || require(appPackageJson).homepage; 27 | 28 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 29 | // "public path" at which the app is served. 30 | // Webpack needs to know it to put the right