├── v2.0 ├── .prettierignore ├── vite-env.d.ts ├── src │ ├── components │ │ ├── Header │ │ │ ├── index.tsx │ │ │ └── Header.tsx │ │ └── Form │ │ │ ├── Form.types.ts │ │ │ ├── formValidationSchema.tsx │ │ │ ├── components │ │ │ └── TextInput │ │ │ │ ├── TextInput.types.ts │ │ │ │ └── TextInput.tsx │ │ │ └── Form.tsx │ ├── index.css │ ├── index.tsx │ └── App.tsx ├── postcss.config.js ├── .prettierrc ├── .gitignore ├── tailwind.config.js ├── vite.config.ts ├── tsconfig.json ├── index.html ├── jest.config.js ├── README.md ├── package.json └── .eslintrc.js ├── v1.0 ├── src │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── button.spec.jsx.snap │ │ │ ├── row.spec.jsx.snap │ │ │ ├── container.spec.jsx.snap │ │ │ ├── jumbotron.spec.jsx.snap │ │ │ ├── columns.spec.jsx.snap │ │ │ ├── titleHeader.spec.jsx.snap │ │ │ ├── multiselect.spec.jsx.snap │ │ │ ├── textInput.spec.jsx.snap │ │ │ ├── options.spec.jsx.snap │ │ │ └── checkbox.spec.jsx.snap │ │ ├── row.spec.jsx │ │ ├── columns.spec.jsx │ │ ├── container.spec.jsx │ │ ├── jumbotron.spec.jsx │ │ ├── titleHeader.spec.jsx │ │ ├── button.spec.jsx │ │ ├── multiselect.spec.jsx │ │ ├── options.spec.jsx │ │ ├── textInput.spec.jsx │ │ ├── checkbox.spec.jsx │ │ └── form.spec.jsx │ ├── components │ │ ├── Container.jsx │ │ ├── Row.jsx │ │ ├── Jumbotron.jsx │ │ ├── Button.jsx │ │ ├── Column.jsx │ │ ├── Options.jsx │ │ ├── TitleHeader.jsx │ │ ├── MultiSelect.jsx │ │ ├── Checkbox.jsx │ │ ├── TextInput.jsx │ │ └── Form.jsx │ ├── setupTests.js │ ├── index.jsx │ └── App.jsx ├── public │ └── index.html ├── workflows │ └── main.yml ├── .eslintrc.js ├── package.json ├── .gitignore └── README.md └── README.md /v2.0/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore all Yaml files 2 | *.yaml -------------------------------------------------------------------------------- /v2.0/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /v2.0/src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Header' 2 | -------------------------------------------------------------------------------- /v2.0/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /v2.0/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /v2.0/src/components/Form/Form.types.ts: -------------------------------------------------------------------------------- 1 | export interface FormValues { 2 | firstName: string 3 | lastName: string 4 | } 5 | -------------------------------------------------------------------------------- /v2.0/src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | export const Header = () => ( 2 |

Hello World

3 | ) 4 | -------------------------------------------------------------------------------- /v2.0/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /v1.0/src/__tests__/__snapshots__/button.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` 53 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /v1.0/README.md: -------------------------------------------------------------------------------- 1 | # React Form Unit Test Example 2 | Example of unit testing React form with Jest and Enzyme 3 | 4 | This is a simple React form unit test example. On submit, it pops up an alert that displays values from the form. Using yarn as a package manager. 5 | 6 | Testing examples include: 7 | - Snapshot testing on stateless component 8 | - Testing props on stateless component 9 | - Text input change handler testing 10 | - Multi Select change handler testing 11 | - Checkbox change handler testing 12 | - Form submit event testing 13 | 14 | ## Reference 15 | - [create-react-app](https://reactjs.org/docs/create-a-new-react-app.html) 16 | - [React Form](https://reactjs.org/docs/forms.html) 17 | - [Test Utilities](https://reactjs.org/docs/test-utils.html) 18 | - [Airbnb React/JSX Style Guide](https://github.com/airbnb/javascript/tree/master/react) 19 | - [Typechecking With PropTypes](https://reactjs.org/docs/typechecking-with-proptypes.html) 20 | 21 | ## Running Test 22 | ```bash 23 | yarn test 24 | ``` 25 | 26 | ## Starting the app 27 | ```bash 28 | yarn start 29 | ``` 30 | 31 | ## Set up 32 | **(1) Create-react-app to create a project.** 33 | When the project folder is already created 34 | ```bash 35 | npx create-react-app ./ 36 | ``` 37 | 38 | When the folder needs to be created at the same time 39 | ```bash 40 | npx create-react-app my-app 41 | ``` 42 | 43 | Instead of npx, we can also use npm or yarn as below 44 | ```bash 45 | npm init react-app my-app 46 | yarn create react-app my-app 47 | ``` 48 | 49 | **(2) Install modules for testing** 50 | create-react-app uses Jest as a unit testing framework. All we need is the two extra modules below. 51 | ```bash 52 | yarn add enzyme enzyme-adapter-react-16 --dev 53 | yarn add react-test-renderer --dev 54 | ``` 55 | 56 | **(3) Add bootstrap** 57 | This example uses bootstrap for styling. 58 | ```bash 59 | yarn add bootstrap 60 | ``` 61 | 62 | To use bootstrap in the component, import the module as below. 63 | ```javascript 64 | import '../node_modules/bootstrap/dist/css/bootstrap.min.css' 65 | ``` 66 | -------------------------------------------------------------------------------- /v2.0/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-form-unit-test-examples-v2", 3 | "version": "2.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/mydatahack/react-form-unit-test-example", 6 | "author": "mdh", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "tsc && vite build", 11 | "preview": "vite preview", 12 | "start": "yarn build && yarn preview", 13 | "test": "jest", 14 | "lint": "eslint './**/*.{ts,tsx,js,jsx}' --quiet" 15 | }, 16 | "dependencies": { 17 | "@hookform/resolvers": "^3.3.2", 18 | "@mui/base": "^5.0.0-beta.23", 19 | "@types/jest": "^29.5.7", 20 | "clsx": "^2.0.0", 21 | "jest": "^29.7.0", 22 | "jest-environment-jsdom": "^29.7.0", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-hook-form": "^7.48.2", 26 | "ts-jest": "^29.1.1", 27 | "zod": "^3.22.4" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/dom": "^9.3.3", 31 | "@testing-library/react": "^14.0.0", 32 | "@testing-library/user-event": "^14.5.1", 33 | "@types/node": "^20.8.10", 34 | "@types/react": "^18.2.35", 35 | "@types/react-dom": "^18.2.14", 36 | "@typescript-eslint/eslint-plugin": "^6.9.1", 37 | "@typescript-eslint/parser": "^6.9.1", 38 | "@vitejs/plugin-react": "^4.1.1", 39 | "autoprefixer": "^10.4.16", 40 | "eslint": "^8.53.0", 41 | "eslint-config-airbnb": "^19.0.4", 42 | "eslint-config-prettier": "^9.0.0", 43 | "eslint-import-resolver-typescript": "^3.6.1", 44 | "eslint-plugin-import": "^2.29.0", 45 | "eslint-plugin-jest": "^27.6.0", 46 | "eslint-plugin-jsx-a11y": "^6.8.0", 47 | "eslint-plugin-prettier": "^5.0.1", 48 | "eslint-plugin-react": "^7.33.2", 49 | "eslint-plugin-react-hooks": "^4.6.0", 50 | "eslint-plugin-simple-import-sort": "^10.0.0", 51 | "postcss": "^8.4.31", 52 | "prettier": "^3.0.3", 53 | "pretty-quick": "^3.1.3", 54 | "tailwindcss": "^3.3.5", 55 | "typescript": "^5.2.2", 56 | "vite": "^4.5.0", 57 | "vite-plugin-svgr": "^4.1.0", 58 | "vite-tsconfig-paths": "^4.2.1" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /v1.0/src/__tests__/form.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import Form from '../components/Form' 4 | 5 | describe('
', () => { 6 | it('Should capture first name correctly onChange', () => { 7 | const component = mount() 8 | const input = component.find('input').at(0) 9 | input.instance().value = 'hello' 10 | input.simulate('change') 11 | expect(component.state().firstname).toEqual('hello') 12 | }) 13 | 14 | it('Should capture last name correctly onChange', () => { 15 | const component = mount() 16 | const input = component.find('input').at(1) 17 | input.instance().value = 'world' 18 | input.simulate('change') 19 | expect(component.state().lastname).toEqual('world') 20 | }) 21 | 22 | it('Should capture email correctly onChange and change the props accordingly', () => { 23 | const component = mount() 24 | const input = component.find('input').at(2) 25 | // input.simulate('change', {target: {email: 'mail@hotmail.com'}}); -- this does not work 26 | 27 | input.instance().value = 'mail@hotmail.com' 28 | input.simulate('change') 29 | expect(component.find('input').at(2).props().value).toEqual('mail@hotmail.com') 30 | // Alternatively, can check state 31 | // expect(component.state().email).toEqual('mail@hotmail.com'); 32 | }) 33 | 34 | it('Should capture email correctly onChange and change the state accordingly', () => { 35 | const component = mount() 36 | const input = component.find('input').at(2) 37 | input.instance().value = 'mail@hotmail.com' 38 | input.simulate('change') 39 | expect(component.state().email).toEqual('mail@hotmail.com') 40 | }) 41 | 42 | it('Should capture multi select languages correctly onChange', () => { 43 | // For multi select, set the individual option's selected property to true 44 | const component = mount() 45 | const input = component.find('select').at(0) 46 | const optionEnglish = component.find('option').at(0) 47 | optionEnglish.instance().selected = true 48 | const optionGerman = component.find('option').at(3) 49 | optionGerman.instance().selected = true 50 | input.simulate('change') 51 | 52 | // Simulating change on select element doesn't work. 53 | // input.simulate('change', {target: {selectedOptions: ['Japanese', 'French']}}); 54 | // input.instance().value = ['Japanese', 'French']; 55 | // input.simulate('select', {target: input.instance()}); 56 | 57 | expect(component.find('select').at(0).props().value).toEqual(['English', 'German']) 58 | 59 | // Alternatiely can check state 60 | // expect(component.state().languages).toEqual(['Japanese', 'French']); 61 | }) 62 | 63 | it('Should capture checkbox ticked correctly onChange', () => { 64 | const component = mount() 65 | const input = component.find('input').at(3) 66 | input.instance().checked = true 67 | input.simulate('change') 68 | expect(component.state().subscribed).toEqual(true) 69 | }) 70 | 71 | it('Should call alert() when submit button is clicked', () => { 72 | const state = { 73 | firstname: 'hello', 74 | lastname: 'world', 75 | email: 'hello@world.com', 76 | languages: ['English', 'French'], 77 | subscribed: true, 78 | } 79 | const expectedArg = 'First Name: hello, Last Name: world, Email: hello@world.com, Language: English,French, Subscribed: Yes' 80 | const component = mount() 81 | window.alert = jest.fn() 82 | component.setState(state) 83 | 84 | component.find('form').simulate('submit') 85 | expect(window.alert).toHaveBeenCalledWith(expectedArg) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /v1.0/src/components/Form.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import TextInput from './TextInput' 3 | import MultiSelect from './MultiSelect' 4 | import Options from './Options' 5 | import Checkbox from './Checkbox' 6 | import Button from './Button' 7 | 8 | class Form extends React.Component { 9 | constructor(props) { 10 | super(props) 11 | this.state = { 12 | firstname: '', 13 | lastname: '', 14 | email: '', 15 | languages: [], 16 | subscribed: false, 17 | } 18 | this.handleChange = this.handleChange.bind(this) 19 | this.handleMultiSelect = this.handleMultiSelect.bind(this) 20 | this.handleSubmit = this.handleSubmit.bind(this) 21 | } 22 | 23 | handleChange(event) { 24 | const { target } = event 25 | const { name } = target 26 | const value = target.type === 'checkbox' ? target.checked : target.value 27 | this.setState({ [name]: value }, () => { 28 | /* eslint-disable-next-line no-console */ 29 | console.log(name) 30 | }) 31 | } 32 | 33 | handleMultiSelect(event) { 34 | this.setState({ [event.target.name]: [...event.target.selectedOptions].map((o) => o.value) }) 35 | } 36 | 37 | handleSubmit() { 38 | const { 39 | firstname, 40 | lastname, 41 | email, 42 | languages, 43 | subscribed, 44 | } = this.state 45 | const subscribedText = subscribed ? 'Yes' : 'No' 46 | /* eslint-disable-next-line no-alert */ 47 | alert(`First Name: ${firstname}, Last Name: ${lastname}, Email: ${email}, Language: ${languages}, Subscribed: ${subscribedText}`) 48 | } 49 | 50 | render() { 51 | const languageList = ['English', 'Spanish', 'French', 'German', 'Japanese'] 52 | const { 53 | firstname, 54 | lastname, 55 | email, 56 | languages, 57 | subscribed, 58 | } = this.state 59 | return ( 60 | 61 | 71 | 81 | 91 | 98 | 99 | 100 | 107 | 113 | 114 | 115 | ) 116 | } 117 | } 118 | 119 | export default Form 120 | -------------------------------------------------------------------------------- /v2.0/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const eslint = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'airbnb', 9 | 'airbnb/hooks', 10 | 'plugin:react/recommended', 11 | 'plugin:prettier/recommended', 12 | 'eslint:recommended', 13 | 'plugin:react/recommended', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'plugin:jest/recommended', 16 | 'prettier', 17 | 'plugin:import/errors', 18 | 'plugin:import/warnings', 19 | 'plugin:import/typescript', 20 | 'plugin:jsx-a11y/recommended', 21 | ], 22 | globals: { 23 | Atomics: 'readonly', 24 | SharedArrayBuffer: 'readonly', 25 | }, 26 | parser: '@typescript-eslint/parser', 27 | parserOptions: { 28 | ecmaFeatures: { 29 | jsx: true, 30 | }, 31 | ecmaVersion: 2018, 32 | sourceType: 'module', 33 | }, 34 | plugins: [ 35 | 'prettier', 36 | 'react', 37 | '@typescript-eslint', 38 | 'jest', 39 | 'import', 40 | 'jsx-a11y', 41 | 'simple-import-sort', 42 | ], 43 | rules: { 44 | 'react/react-in-jsx-scope': 0, 45 | // note we must disable the base rule as it can report incorrect errors 46 | 'no-use-before-define': 'off', 47 | '@typescript-eslint/no-use-before-define': ['error'], 48 | // note we must disable the base rule as it can report incorrect errors 49 | 'no-shadow': 'off', 50 | '@typescript-eslint/no-shadow': 'error', 51 | '@typescript-eslint/explicit-module-boundary-types': 0, 52 | 'react/require-default-props': 0, 53 | 'prettier/prettier': 'error', 54 | 'react/prop-types': 0, 55 | '@typescript-eslint/explicit-function-return-type': 0, 56 | '@typescript-eslint/no-explicit-any': 0, 57 | '@typescript-eslint/prefer-interface': 0, 58 | '@typescript-eslint/camelcase': 0, 59 | '@typescript-eslint/ban-ts-ignore': 0, 60 | '@typescript-eslint/no-non-null-assertion': 0, 61 | 'react/jsx-filename-extension': 0, 62 | 'react/jsx-props-no-spreading': 0, 63 | 'jest/expect-expect': 0, 64 | 'jest/valid-expect': 0, 65 | 'import/prefer-default-export': 0, 66 | 'react/display-name': 0, 67 | 'cypress/no-unnecessary-waiting': 0, 68 | 'jsx-a11y/alt-text': [ 69 | 2, 70 | { 71 | img: ['ExpertImage'], 72 | }, 73 | ], 74 | 'react/function-component-definition': [ 75 | 2, 76 | { 77 | namedComponents: ['arrow-function', 'function-declaration'], 78 | }, 79 | ], 80 | 'import/extensions': [ 81 | 'error', 82 | 'ignorePackages', 83 | { 84 | js: 'never', 85 | mjs: 'never', 86 | jsx: 'never', 87 | ts: 'never', 88 | tsx: 'never', 89 | }, 90 | ], 91 | 'import/no-extraneous-dependencies': 0, 92 | 'simple-import-sort/imports': [ 93 | 1, 94 | { 95 | groups: [ 96 | // Side effect imports. 97 | ['^\\u0000'], 98 | // Packages. `react` related packages come first. 99 | // Absolute imports and other imports such as Vue-style `@/foo`. 100 | ['^react', '^@?\\w'], 101 | // Internal packages. 102 | // Anything that does not start with a dot. 103 | ['^(assets)(/.*|$)', '^[^.]'], 104 | ['^\\.'], 105 | ], 106 | }, 107 | ], 108 | }, 109 | settings: { 110 | react: { 111 | version: 'detect', 112 | }, 113 | jest: { 114 | version: 'detect', 115 | }, 116 | 'import/resolver': { 117 | typescript: {}, 118 | }, 119 | }, 120 | overrides: [ 121 | { 122 | files: ['**/*.stories.*'], 123 | rules: { 124 | 'import/no-anonymous-default-export': 'off', 125 | }, 126 | }, 127 | ], 128 | } 129 | 130 | module.exports = eslint 131 | --------------------------------------------------------------------------------