├── .all-contributorsrc ├── .gitattributes ├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .gitpod.yml ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.kcd.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── LIVE_INSTRUCTIONS.md ├── README.md ├── docker-compose.yml ├── jsconfig.json ├── other └── testingjavascript.jpg ├── package-lock.json ├── package.json ├── public ├── _redirects ├── antic-slab.woff2 ├── favicon.ico ├── index.html ├── manifest.json └── mockServiceWorker.js ├── sandbox.config.json ├── scripts ├── diff.js ├── fix-feedback-links ├── pre-commit.js ├── pre-push.js ├── setup.js └── update-deps ├── setup.js └── src ├── __tests__ ├── exercise │ ├── 01.js │ ├── 01.md │ ├── 02.js │ ├── 02.md │ ├── 03.js │ ├── 03.md │ ├── 04.js │ ├── 04.md │ ├── 05.js │ ├── 05.md │ ├── 06.js │ ├── 06.md │ ├── 07.js │ ├── 07.md │ ├── 08.js │ └── 08.md └── final │ ├── 01.extra-1.js │ ├── 01.js │ ├── 02.extra-1.js │ ├── 02.js │ ├── 03.extra-1.js │ ├── 03.js │ ├── 04.extra-1.js │ ├── 04.extra-2.js │ ├── 04.extra-3.js │ ├── 04.extra-4.js │ ├── 04.js │ ├── 05.extra-1.js │ ├── 05.extra-2.js │ ├── 05.extra-3.js │ ├── 05.extra-4.js │ ├── 05.js │ ├── 06.extra-1.js │ ├── 06.extra-2.js │ ├── 06.js │ ├── 07.extra-1.js │ ├── 07.extra-2.js │ ├── 07.extra-3.js │ ├── 07.js │ ├── 08.extra-1.js │ ├── 08.extra-2.js │ ├── 08.extra-3.js │ └── 08.js ├── components ├── counter.js ├── easy-button.js ├── login-submission.js ├── login.js ├── spinner.js ├── theme.js └── use-counter.js ├── examples ├── counter-hook.js ├── counter.js ├── easy-button.js ├── location.js ├── login-submission.js └── login.js ├── index.js ├── setupTests.js ├── styles.css └── test ├── server-handlers.js ├── server.js └── test-utils.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributorsPerLine": 7, 8 | "projectName": "testing-react-apps", 9 | "projectOwner": "kentcdodds", 10 | "repoType": "github", 11 | "repoHost": "https://github.com", 12 | "skipCi": true, 13 | "contributors": [ 14 | { 15 | "login": "kentcdodds", 16 | "name": "Kent C. Dodds", 17 | "avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=3", 18 | "profile": "https://kentcdodds.com", 19 | "contributions": [ 20 | "code", 21 | "doc", 22 | "infra", 23 | "test" 24 | ] 25 | }, 26 | { 27 | "login": "gautam-pahuja", 28 | "name": "Gautam Pahuja", 29 | "avatar_url": "https://avatars1.githubusercontent.com/u/32642691?v=4", 30 | "profile": "https://relayr.io/", 31 | "contributions": [ 32 | "test" 33 | ] 34 | }, 35 | { 36 | "login": "pom421", 37 | "name": "pom421", 38 | "avatar_url": "https://avatars1.githubusercontent.com/u/3749428?v=4", 39 | "profile": "https://github.com/pom421", 40 | "contributions": [ 41 | "doc" 42 | ] 43 | }, 44 | { 45 | "login": "marcosvega91", 46 | "name": "Marco Moretti", 47 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", 48 | "profile": "https://github.com/marcosvega91", 49 | "contributions": [ 50 | "code" 51 | ] 52 | }, 53 | { 54 | "login": "PritamSangani", 55 | "name": "Pritam Sangani", 56 | "avatar_url": "https://avatars3.githubusercontent.com/u/22857896?v=4", 57 | "profile": "https://www.linkedin.com/in/pritamsangani/", 58 | "contributions": [ 59 | "code" 60 | ] 61 | }, 62 | { 63 | "login": "emzoumpo", 64 | "name": "Emmanouil Zoumpoulakis", 65 | "avatar_url": "https://avatars2.githubusercontent.com/u/2103443?v=4", 66 | "profile": "https://github.com/emzoumpo", 67 | "contributions": [ 68 | "doc" 69 | ] 70 | }, 71 | { 72 | "login": "Aprillion", 73 | "name": "Peter Hozák", 74 | "avatar_url": "https://avatars0.githubusercontent.com/u/1087670?v=4", 75 | "profile": "http://peter.hozak.info/", 76 | "contributions": [ 77 | "code" 78 | ] 79 | }, 80 | { 81 | "login": "milamer", 82 | "name": "Christian Schurr", 83 | "avatar_url": "https://avatars2.githubusercontent.com/u/12884134?v=4", 84 | "profile": "https://github.com/milamer", 85 | "contributions": [ 86 | "code", 87 | "doc" 88 | ] 89 | }, 90 | { 91 | "login": "tiodan81", 92 | "name": "Dan Schwartz", 93 | "avatar_url": "https://avatars2.githubusercontent.com/u/13711104?v=4", 94 | "profile": "https://github.com/tiodan81", 95 | "contributions": [ 96 | "doc" 97 | ] 98 | }, 99 | { 100 | "login": "wbeuil", 101 | "name": "William BEUIL", 102 | "avatar_url": "https://avatars1.githubusercontent.com/u/8110579?v=4", 103 | "profile": "http://wbeuil.com", 104 | "contributions": [ 105 | "doc" 106 | ] 107 | }, 108 | { 109 | "login": "vasilii-kovalev", 110 | "name": "Vasilii Kovalev", 111 | "avatar_url": "https://avatars0.githubusercontent.com/u/10310491?v=4", 112 | "profile": "https://vk.com/vasilii_kovalev", 113 | "contributions": [ 114 | "bug" 115 | ] 116 | }, 117 | { 118 | "login": "RobbertWolfs", 119 | "name": "Robbert Wolfs", 120 | "avatar_url": "https://avatars2.githubusercontent.com/u/12511178?v=4", 121 | "profile": "https://github.com/RobbertWolfs", 122 | "contributions": [ 123 | "bug" 124 | ] 125 | }, 126 | { 127 | "login": "p10ns11y", 128 | "name": "Peramanathan Sathyamoorthy", 129 | "avatar_url": "https://avatars2.githubusercontent.com/u/9104920?v=4", 130 | "profile": "http://twitter.com/peramanathan", 131 | "contributions": [ 132 | "test", 133 | "code" 134 | ] 135 | }, 136 | { 137 | "login": "MichaelDeBoey", 138 | "name": "Michaël De Boey", 139 | "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", 140 | "profile": "https://michaeldeboey.be", 141 | "contributions": [ 142 | "code" 143 | ] 144 | }, 145 | { 146 | "login": "bartw", 147 | "name": "Bart Wijnants", 148 | "avatar_url": "https://avatars1.githubusercontent.com/u/822859?v=4", 149 | "profile": "https://bartwijnants.be/", 150 | "contributions": [ 151 | "doc" 152 | ] 153 | }, 154 | { 155 | "login": "DaleSeo", 156 | "name": "Dale Seo", 157 | "avatar_url": "https://avatars1.githubusercontent.com/u/5466341?v=4", 158 | "profile": "https://www.daleseo.com", 159 | "contributions": [ 160 | "code", 161 | "doc" 162 | ] 163 | }, 164 | { 165 | "login": "falldowngoboone", 166 | "name": "Ryan Boone", 167 | "avatar_url": "https://avatars0.githubusercontent.com/u/3603771?v=4", 168 | "profile": "https://github.com/falldowngoboone", 169 | "contributions": [ 170 | "doc" 171 | ] 172 | }, 173 | { 174 | "login": "onetruebob", 175 | "name": "Bob Owen", 176 | "avatar_url": "https://avatars3.githubusercontent.com/u/603731?v=4", 177 | "profile": "https://bobowen.tech", 178 | "contributions": [ 179 | "code", 180 | "doc" 181 | ] 182 | }, 183 | { 184 | "login": "alberto", 185 | "name": "alberto", 186 | "avatar_url": "https://avatars.githubusercontent.com/u/72561?v=4", 187 | "profile": "http://www.sharpbites.com", 188 | "contributions": [ 189 | "doc" 190 | ] 191 | }, 192 | { 193 | "login": "marioleed", 194 | "name": "Mario Sannum", 195 | "avatar_url": "https://avatars.githubusercontent.com/u/1763448?v=4", 196 | "profile": "https://github.com/marioleed", 197 | "contributions": [ 198 | "code" 199 | ] 200 | }, 201 | { 202 | "login": "ssmkhrj", 203 | "name": "Som Shekhar Mukherjee", 204 | "avatar_url": "https://avatars.githubusercontent.com/u/49264891?v=4", 205 | "profile": "https://github.com/ssmkhrj", 206 | "contributions": [ 207 | "code" 208 | ] 209 | }, 210 | { 211 | "login": "skgyan", 212 | "name": "Sushil Kumar", 213 | "avatar_url": "https://avatars.githubusercontent.com/u/5046860?v=4", 214 | "profile": "https://github.com/skgyan", 215 | "contributions": [ 216 | "code" 217 | ] 218 | }, 219 | { 220 | "login": "Joao-pina-fernandes", 221 | "name": "João Pina Fernandes", 222 | "avatar_url": "https://avatars.githubusercontent.com/u/7046309?v=4", 223 | "profile": "https://www.linkedin.com/in/joao-fernandes-75b9a763/", 224 | "contributions": [ 225 | "doc" 226 | ] 227 | }, 228 | { 229 | "login": "joshjm", 230 | "name": "Josh", 231 | "avatar_url": "https://avatars.githubusercontent.com/u/21700579?v=4", 232 | "profile": "https://github.com/joshjm", 233 | "contributions": [ 234 | "doc" 235 | ] 236 | }, 237 | { 238 | "login": "creador-dev", 239 | "name": "Pawan Kumar", 240 | "avatar_url": "https://avatars.githubusercontent.com/u/40248406?v=4", 241 | "profile": "https://creador.dev", 242 | "contributions": [ 243 | "doc" 244 | ] 245 | }, 246 | { 247 | "login": "Creeland", 248 | "name": "Creeland A. Provinsal ", 249 | "avatar_url": "https://avatars.githubusercontent.com/u/518406?v=4", 250 | "profile": "https://github.com/Creeland", 251 | "contributions": [ 252 | "doc" 253 | ] 254 | } 255 | ], 256 | "commitConvention": "none", 257 | "commitType": "docs" 258 | } 259 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: validate 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | pull_request: 7 | branches: 8 | - 'main' 9 | jobs: 10 | setup: 11 | # ignore all-contributors PRs 12 | if: ${{ !contains(github.head_ref, 'all-contributors') }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest, macos-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - name: ⬇️ Checkout repo 19 | uses: actions/checkout@v3 20 | 21 | - name: ⎔ Setup node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 18 25 | 26 | - name: npm 8 27 | run: npm install --global npm@8 28 | 29 | - name: ▶️ Run setup script 30 | run: npm run setup 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | build 5 | .idea/ 6 | .eslintcache 7 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | ## Learn more about this file at 'https://www.gitpod.io/docs/references/gitpod-yml' 2 | ## 3 | ## This '.gitpod.yml' file when placed at the root of a project instructs 4 | ## Gitpod how to prepare & build the project, start development environments 5 | ## and configure continuous prebuilds. Prebuilds when enabled builds a project 6 | ## like a CI server so you can start coding right away - no more waiting for 7 | ## dependencies to download and builds to finish when reviewing pull-requests 8 | ## or hacking on something new. 9 | ## 10 | ## With Gitpod you can develop software from any device (even iPads) via 11 | ## desktop or browser based versions of VS Code or any JetBrains IDE and 12 | ## customise it to your individual needs - from themes to extensions, you 13 | ## have full control. 14 | ## 15 | ## The easiest way to try out Gitpod is install the browser extenion: 16 | ## 'https://www.gitpod.io/docs/browser-extension' or by prefixing 17 | ## 'https://gitpod.io#' to the source control URL of any project. 18 | ## 19 | ## For example: 'https://gitpod.io#https://github.com/gitpod-io/gitpod' 20 | 21 | 22 | ## The 'tasks' section defines how Gitpod prepares & builds this project 23 | ## and how it can start development servers. With Gitpod, there are three 24 | ## types of tasks: 25 | ## 26 | ## - before: Use this for tasks that need to run before init and before command. 27 | ## - init: Use this to configure prebuilds of heavy-lifting tasks such as 28 | ## downloading dependencies or compiling source code. 29 | ## - command: Use this to start your database or application when the workspace starts. 30 | ## 31 | ## Learn more about these tasks at 'https://www.gitpod.io/docs/config-start-tasks' 32 | 33 | tasks: 34 | - name: App 35 | init: npm install 36 | command: npm run start 37 | openMode: split-left 38 | 39 | - name: Test 40 | command: npm run test 41 | openMode: split-right 42 | 43 | - name: Set up email 44 | command: | 45 | clear 46 | printf "\n\n\n" 47 | printf "\u001b[36;1mAutofilling Email\u001b[0m\n" 48 | printf "\u001b[2;1mEach exercise comes with a elaboration form to help your retention. Providing your email now will mean you don't have to provide it each time you fill out the form.\u001b[0m\n" 49 | npx "https://gist.github.com/kentcdodds/2d44448a8997b9964b1be44cd294d1f5" \ 50 | && exit 0 51 | ## The 'ports' section defines various ports your may listen on are 52 | ## configured in Gitpod on an authenticated URL. By default, all ports 53 | ## are in private visibility state. 54 | ## 55 | ## Learn more about ports at 'https://www.gitpod.io/docs/config-ports' 56 | 57 | ports: 58 | - port: 3000 # alternatively configure entire ranges via '8080-8090' 59 | visibility: private # either 'public' or 'private' (default) 60 | onOpen: open-browser # either 'open-browser', 'open-preview' or 'ignore' 61 | 62 | ## The 'vscode' section defines a list of Visual Studio Code extensions from 63 | ## the OpenVSX.org registry to be installed upon workspace startup. OpenVSX 64 | ## is an open alternative to the proprietary Visual Studio Code Marketplace 65 | ## and extensions can be added by sending a pull-request with the extension 66 | ## identifier to https://github.com/open-vsx/publish-extensions 67 | ## 68 | ## The identifier of an extension is always ${publisher}.${name}. 69 | ## 70 | ## For example: 'vscodevim.vim' 71 | ## 72 | ## Learn more at 'https://www.gitpod.io/docs/ides-and-editors/vscode' 73 | 74 | vscode: 75 | extensions: 76 | - VisualStudioExptTeam.vscodeintellicode 77 | - dbaeumer.vscode-eslint 78 | - formulahendry.auto-rename-tag 79 | - esbenp.prettier-vscode 80 | - ms-azuretools.vscode-docker 81 | 82 | ## The 'github' section defines configuration of continuous prebuilds 83 | ## for GitHub repositories when the GitHub application 84 | ## 'https://github.com/apps/gitpod-io' is installed in GitHub and granted 85 | ## permissions to access the repository. 86 | ## 87 | ## Learn more at 'https://www.gitpod.io/docs/prebuilds' 88 | 89 | github: 90 | prebuilds: 91 | # enable for the default branch 92 | master: true 93 | # enable for all branches in this repo 94 | branches: false 95 | # enable for pull requests coming from this repo 96 | pullRequests: false 97 | # enable for pull requests coming from forks 98 | pullRequestsFromForks: false 99 | # add a check to pull requests 100 | addCheck: false 101 | # add a "Review in Gitpod" button as a comment to pull requests 102 | addComment: false 103 | # add a "Review in Gitpod" button to the pull request's description 104 | addBadge: false 105 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | package-lock=true 3 | yes=true 4 | legacy-peer-deps=true 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | build 4 | other 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "jsxBracketSameLine": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 80, 10 | "proseWrap": "always", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": true, 15 | "tabWidth": 2, 16 | "trailingComma": "all", 17 | "useTabs": false 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "formulahendry.auto-rename-tag", 6 | "VisualStudioExptTeam.vscodeintellicode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.kcd.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.detectIndentation": true, 5 | "editor.fontFamily": "'Dank Mono', Menlo, Monaco, 'Courier New', monospace", 6 | "editor.fontLigatures": false, 7 | "editor.rulers": [80], 8 | "editor.snippetSuggestions": "top", 9 | "editor.wordBasedSuggestions": false, 10 | "editor.suggest.localityBonus": true, 11 | "editor.acceptSuggestionOnCommitCharacter": false, 12 | "[javascript]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "editor.suggestSelection": "recentlyUsed", 15 | "editor.suggest.showKeywords": false 16 | }, 17 | "editor.renderWhitespace": "boundary", 18 | "files.defaultLanguage": "{activeEditorLanguage}", 19 | "javascript.validate.enable": false, 20 | "search.exclude": { 21 | "**/node_modules": true, 22 | "**/bower_components": true, 23 | "**/coverage": true, 24 | "**/dist": true, 25 | "**/build": true, 26 | "**/.build": true, 27 | "**/.gh-pages": true 28 | }, 29 | "editor.codeActionsOnSave": { 30 | "source.fixAll.eslint": false 31 | }, 32 | "eslint.validate": [ 33 | "javascript", 34 | "javascriptreact", 35 | "typescript", 36 | "typescriptreact" 37 | ], 38 | "eslint.options": { 39 | "env": { 40 | "browser": true, 41 | "jest/globals": true, 42 | "es6": true 43 | }, 44 | "parserOptions": { 45 | "ecmaVersion": 2019, 46 | "sourceType": "module", 47 | "ecmaFeatures": { 48 | "jsx": true 49 | } 50 | }, 51 | "rules": { 52 | "no-debugger": "off" 53 | } 54 | }, 55 | "workbench.colorTheme": "Night Owl", 56 | "workbench.iconTheme": "material-icon-theme", 57 | "breadcrumbs.enabled": true, 58 | "grunt.autoDetect": "off", 59 | "gulp.autoDetect": "off", 60 | "npm.runSilent": true, 61 | "explorer.confirmDragAndDrop": false, 62 | "editor.formatOnPaste": false, 63 | "editor.cursorSmoothCaretAnimation": true, 64 | "editor.smoothScrolling": true, 65 | "php.suggest.basic": false 66 | } 67 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at kent@doddsfamily.us. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ 6 | series [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## Project setup 9 | 10 | 1. Fork and clone the repo 11 | 2. Run `npm run setup -s` to install dependencies and run validation 12 | 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` 13 | 14 | > Tip: Keep your `main` branch pointing at the original repository and make 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ```bash 18 | > git remote add upstream https://github.com/kentcdodds/react-testing-workshop.git 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/main main 21 | > ``` 22 | > 23 | > This will add the original repository as a "remote" called "upstream," Then 24 | > fetch the git information from that remote, then set your local `main` 25 | > branch to use the upstream main branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `main` branch. 27 | > Whenever you want to update your version of `main`, do a regular `git pull`. 28 | 29 | ## Committing and Pushing changes 30 | 31 | Please make sure to run the tests before you commit your changes. You can run 32 | `npm run test` and press `u` which will update any snapshots that need updating. 33 | Make sure to include those changes (if they exist) in your commit. 34 | 35 | ## Help needed 36 | 37 | Please checkout the [the open issues][issues] 38 | 39 | Also, please watch the repo and respond to questions/bug reports/feature 40 | requests! Thanks! 41 | 42 | [egghead]: 43 | https://kcd.im/pull-request 44 | [issues]: https://github.com/kentcdodds/testing-react-apps/issues 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | WORKDIR /app 4 | COPY . . 5 | RUN NO_EMAIL_AUTOFILL=true node setup 6 | 7 | CMD ["npm", "start"] 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This material is available for private, non-commercial use under the 2 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you 3 | would like to use this material to conduct your own workshop, please contact me 4 | at me@kentcdodds.com 5 | -------------------------------------------------------------------------------- /LIVE_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | # Live Workshop Instructions 2 | 3 | Hey there 👋 I'm Kent C. Dodds. Here's some info about me: 4 | 5 | - 🏡 Utah 6 | - 👩 👧 👦 👦 👦 🐕 7 | - 🏢 https://kentcdodds.com 8 | - 🐦/🐙 @kentcdodds 9 | - 🏆 https://TestingJavaScript.com 10 | - 👩‍🚀 https://EpicReact.Dev 11 | - 💬 https://kcd.im/discord 12 | - ❓ https://kcd.im/office-hours 13 | - 💻 https://kcd.im/workshops 14 | - 🎙 https://kcd.im/podcast 15 | - 💌 https://kcd.im/news 16 | - 📝 https://kcd.im/blog 17 | - 📺 https://kcd.im/devtips 18 | - 👨‍💻 https://kcd.im/coding 19 | - 📽 https://kcd.im/youtube 20 | 21 | This workshop is part of the series of self-paced workshops on 22 | [EpicReact.Dev](https://epicreact.dev). This document explains a few things 23 | you'll need to know if you're attending a live version of this workshop. 24 | 25 | ## Pre-Workshop Instructions/Requirements 26 | 27 | Please watch the 28 | [EpicReact.Dev Welcome Videos](https://epicreact.dev/modules/welcome-to-epic-react) 29 | (~30 minutes). If you follow along with this repo, you should be all set up by 30 | the end of it and you'll be ready to go. 31 | 32 | **NOTE: I will assume you know how to work through the exercises.** There will 33 | be _no time_ given for troubleshooting setup issues or answering questions about 34 | exercise logistics. 35 | 36 | Here are the basic things you need to make sure you do (in addition to watching 37 | [the example run through](https://epicreact.dev/modules/welcome-to-epic-react/example-runthrough)): 38 | 39 | - [ ] Ensure you satisfy all the "Prerequisites" and "System Requirements" found 40 | in the `README.md`. 41 | - [ ] Run the project setup as documented in the `README.md` (~5 minutes) 42 | 43 | If our workshop is remote via Zoom: 44 | 45 | - [ ] Install and setup [Zoom](https://zoom.us) on the computer you will be 46 | using (~5 minutes) 47 | - [ ] If our Watch 48 | [Use Zoom for KCD Workshops](https://egghead.io/lessons/egghead-use-zoom-for-kcd-workshops) 49 | (~8 minutes). 50 | 51 | ### Schedule 52 | 53 | Here's the general schedule for the workshop (this is flexible): 54 | 55 | - 😴 Logistics 56 | - 💪 1. Simple Test with ReactDOM 57 | - 💪 2. Simple test with React Testing Library 58 | - 😴 10 Minutes 59 | - 💪 3. Avoid implementation details 60 | - 💪 4. Form testing 61 | - 🌮 30 Minutes 62 | - 💪 5. Mocking HTTP requests 63 | - 😴 10 Minutes 64 | - 💪 6. Mocking Browser APIs and modules 65 | - 💪 7. Testing with context and a custom render method 66 | - 😴 10 Minutes 67 | - 💪 8. Testing custom hooks 68 | - ❓ Q&A 69 | 70 | ### Questions 71 | 72 | Please do ask! **Interrupt me.** If you have an unrelated question, please save 73 | them for [my office hours](https://kcd.im/office-hours). 74 | 75 | ### For remote workshops: 76 | 77 | - Help us make this more human by keeping your video on if possible 78 | - Keep microphone muted unless speaking 79 | - Make the most of breakout rooms during exercises 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

🧐 Testing React Applications 🚀 EpicReact.Dev

3 | 4 | Learn the essential tools and techniques to ship with confidence 5 | 6 |

7 | In this hands-on workshop you'll learn everything you need to test React 8 | components and applications with ease and get the knowledge you need to ship 9 | your applications with confidence. 10 |

11 | 12 | 13 | Learn React from Start to Finish 17 | 18 |
19 | 20 |
21 | 22 | 23 | [![Build Status][build-badge]][build] 24 | [![All Contributors][all-contributors-badge]](#contributors) 25 | [![GPL 3.0 License][license-badge]][license] 26 | [![Code of Conduct][coc-badge]][coc] 27 | [![Gitpod ready-to-code][gitpod-badge]](https://gitpod.io/#https://github.com/kentcdodds/testing-react-apps) 28 | 29 | 30 | ## Prerequisites 31 | 32 | - Read 33 | [But really, what is a JavaScript Test?](https://kentcdodds.com/blog/but-really-what-is-a-javascript-test) 34 | - Read 35 | [But really, what is a JavaScript Mock?](https://kentcdodds.com/blog/but-really-what-is-a-javascript-mock) 36 | 37 | > NOTE: The EpicReact.dev videos were recorded with React version ^16.13 and all 38 | > material in this repo has been updated to React version ^18. Differences are 39 | > minor and any relevant differences are noted in the instructions. 40 | 41 | ## Quick start 42 | 43 | It's recommended you run everything in the same environment you work in every 44 | day, but if you don't want to set up the repository locally, you can get started 45 | in one click with [Gitpod](https://gitpod.io), 46 | [CodeSandbox](https://codesandbox.io/s/github/kentcdodds/testing-react-apps), or 47 | by following the [video demo](https://www.youtube.com/watch?v=gCoVJm3hGk4) 48 | instructions for [GitHub Codespaces](https://github.com/features/codespaces). 49 | 50 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/kentcdodds/testing-react-apps) 51 | 52 | For a local development environment, follow the instructions below 53 | 54 | ## System Requirements 55 | 56 | - [git][git] v2.13 or greater 57 | - [NodeJS][node] `>=16` 58 | - [npm][npm] v8.16.0 or greater 59 | 60 | All of these must be available in your `PATH`. To verify things are set up 61 | properly, you can run this: 62 | 63 | ```shell 64 | git --version 65 | node --version 66 | npm --version 67 | ``` 68 | 69 | If you have trouble with any of these, learn more about the PATH environment 70 | variable and how to fix it here for [windows][win-path] or 71 | [mac/linux][mac-path]. 72 | 73 | ## Setup 74 | 75 | > If you want to commit and push your work as you go, you'll want to 76 | > [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) 77 | > first and then clone your fork rather than this repo directly. 78 | 79 | After you've made sure to have the correct things (and versions) installed, you 80 | should be able to just run a few commands to get set up: 81 | 82 | ```bash 83 | git clone https://github.com/kentcdodds/testing-react-apps.git 84 | cd testing-react-apps 85 | node setup 86 | ``` 87 | 88 | This may take a few minutes. **It will ask you for your email.** This is 89 | optional and just automatically adds your email to the links in the project to 90 | make filling out some forms easier. 91 | 92 | If you get any errors, please read through them and see if you can find out what 93 | the problem is. If you can't work it out on your own then please [file an 94 | issue][issue] and provide _all_ the output from the commands you ran (even if 95 | it's a lot). 96 | 97 | If you can't get the setup script to work, then just make sure you have the 98 | right versions of the requirements listed above, and run the following commands: 99 | 100 | ```bash 101 | npm install 102 | npm run validate 103 | ``` 104 | 105 | If you are still unable to fix issues and you know how to use Docker 🐳 you can 106 | setup the project with the following command: 107 | 108 | ```bash 109 | docker-compose up 110 | ``` 111 | 112 | ## Running the app 113 | 114 | For this one, there's not much to the app itself. The whole reason we have the 115 | app is just so you can see examples of the components that we'll be testing. 116 | You'll spend most of your time in the tests. 117 | 118 | To get the app up and running, run: 119 | 120 | ```shell 121 | npm start 122 | ``` 123 | 124 | This should start up your browser. If you're familiar, this is a standard 125 | [react-scripts](https://create-react-app.dev/) application. 126 | 127 | You can also open 128 | [the deployment of the app on Netlify](https://testing-react-apps.netlify.app/). 129 | 130 | ## Running the tests 131 | 132 | ```shell 133 | npm test 134 | ``` 135 | 136 | This will start [Jest](https://jestjs.io/) in watch mode. Read the output and 137 | play around with it. The tests are there to help you reach the final version, 138 | however _sometimes_ you can accomplish the task and the tests still fail if you 139 | implement things differently than I do in my solution, so don't look to them as 140 | a complete authority. 141 | 142 | ### Exercises 143 | 144 | - `src/__tests__/exercise/00.md`: Background, Exercise Instructions, Extra 145 | Credit 146 | - `src/__tests__/exercise/00.js`: Exercise with Emoji helpers 147 | - `src/__tests__/final/00.js`: Final version 148 | - `src/__tests__/final/00.extra-0.js`: Final version of extra credit 149 | 150 | The purpose of the exercise is **not** for you to work through all the material. 151 | It's intended to get your brain thinking about the right questions to ask me as 152 | _I_ walk through the material. 153 | 154 | ### Helpful Emoji 🐨 💪 🏁 💰 💯 🦉 📜 💣 👨‍💼 🚨 155 | 156 | Each exercise has comments in it to help you get through the exercise. These fun 157 | emoji characters are here to help you. 158 | 159 | - **Kody the Koala** 🐨 will tell you when there's something specific you should 160 | do 161 | - **Matthew the Muscle** 💪 will indicate that you're working with an exercise 162 | - **Chuck the Checkered Flag** 🏁 will indicate that you're working with a final 163 | version 164 | - **Marty the Money Bag** 💰 will give you specific tips (and sometimes code) 165 | along the way 166 | - **Hannah the Hundred** 💯 will give you extra challenges you can do if you 167 | finish the exercises early. 168 | - **Olivia the Owl** 🦉 will give you useful tidbits/best practice notes and a 169 | link for elaboration and feedback. 170 | - **Dominic the Document** 📜 will give you links to useful documentation 171 | - **Berry the Bomb** 💣 will be hanging around anywhere you need to blow stuff 172 | up (delete code) 173 | - **Peter the Product Manager** 👨‍💼 helps us know what our users want 174 | - **Alfred the Alert** 🚨 will occasionally show up in the test failures with 175 | potential explanations for why the tests are failing. 176 | 177 | ## Contributors 178 | 179 | Thanks goes to these wonderful people 180 | ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 |
Kent C. Dodds
Kent C. Dodds

💻 📖 🚇 ⚠️
Gautam Pahuja
Gautam Pahuja

⚠️
pom421
pom421

📖
Marco Moretti
Marco Moretti

💻
Pritam Sangani
Pritam Sangani

💻
Emmanouil Zoumpoulakis
Emmanouil Zoumpoulakis

📖
Peter Hozák
Peter Hozák

💻
Christian Schurr
Christian Schurr

💻 📖
Dan Schwartz
Dan Schwartz

📖
William BEUIL
William BEUIL

📖
Vasilii Kovalev
Vasilii Kovalev

🐛
Robbert Wolfs
Robbert Wolfs

🐛
Peramanathan Sathyamoorthy
Peramanathan Sathyamoorthy

⚠️ 💻
Michaël De Boey
Michaël De Boey

💻
Bart Wijnants
Bart Wijnants

📖
Dale Seo
Dale Seo

💻 📖
Ryan Boone
Ryan Boone

📖
Bob Owen
Bob Owen

💻 📖
alberto
alberto

📖
Mario Sannum
Mario Sannum

💻
Som Shekhar Mukherjee
Som Shekhar Mukherjee

💻
Sushil Kumar
Sushil Kumar

💻
João Pina Fernandes
João Pina Fernandes

📖
Josh
Josh

📖
Pawan Kumar
Pawan Kumar

📖
Creeland A. Provinsal
Creeland A. Provinsal

📖
223 | 224 | 225 | 226 | 227 | 228 | 229 | This project follows the 230 | [all-contributors](https://github.com/kentcdodds/all-contributors) 231 | specification. Contributions of any kind welcome! 232 | 233 | ## Workshop Feedback 234 | 235 | Each exercise has an Elaboration and Feedback link. Please fill that out after 236 | the exercise and instruction. 237 | 238 | At the end of the workshop, please go to this URL to give overall feedback. 239 | Thank you! https://kcd.im/tra-ws-feedback 240 | 241 | 242 | [npm]: https://www.npmjs.com/ 243 | [node]: https://nodejs.org 244 | [git]: https://git-scm.com/ 245 | [build-badge]: https://img.shields.io/github/actions/workflow/status/kentcdodds/testing-react-apps/validate.yml?branch=main&logo=github&style=flat-square 246 | [build]: https://github.com/kentcdodds/testing-react-apps/actions?query=workflow%3Avalidate 247 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square 248 | [license]: https://github.com/kentcdodds/testing-react-apps/blob/main/LICENSE 249 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 250 | [gitpod-badge]: https://img.shields.io/badge/Gitpod-ready--to--code-908a85?logo=gitpod 251 | [coc]: https://github.com/kentcdodds/testing-react-apps/blob/main/CODE_OF_CONDUCT.md 252 | [emojis]: https://github.com/kentcdodds/all-contributors#emoji-key 253 | [all-contributors]: https://github.com/kentcdodds/all-contributors 254 | [all-contributors-badge]: https://img.shields.io/github/all-contributors/kentcdodds/testing-react-apps?color=orange&style=flat-square 255 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/ 256 | [mac-path]: http://stackoverflow.com/a/24322978/971592 257 | [issue]: https://github.com/kentcdodds/testing-react-apps/issues/new 258 | 259 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | node: 5 | build: . 6 | volumes: 7 | - ./src:/app/src 8 | ports: 9 | - '3000:3000' 10 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src" 4 | }, 5 | "include": ["src"] 6 | } 7 | -------------------------------------------------------------------------------- /other/testingjavascript.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/testing-react-apps/2995f34f6c674d23cdba85d5c06b78698b5ad3f6/other/testingjavascript.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testing-react-applications-workshop", 3 | "title": "Testing React Applications 🧐", 4 | "description": "Learn how to test react components and applications", 5 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 6 | "version": "1.0.0", 7 | "private": true, 8 | "license": "GPL-3.0", 9 | "main": "index.js", 10 | "engines": { 11 | "node": ">=16", 12 | "npm": ">=8.16.0" 13 | }, 14 | "scripts": { 15 | "build": "react-scripts build", 16 | "start": "react-scripts start", 17 | "test": "react-scripts test", 18 | "test:coverage": "npm run test -- --watchAll=false", 19 | "test:exercises": "npm run test -- testing.*exercises\\/ --onlyChanged", 20 | "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache", 21 | "format": "prettier \"**/*.+(js|json|less|css|html|ts|tsx|md)\" --write", 22 | "lint": "eslint .", 23 | "validate": "npm-run-all --parallel lint test:coverage build", 24 | "netlify": "npm run validate && cp -r coverage/lcov-report build/lcov-report", 25 | "setup": "node setup" 26 | }, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "node ./scripts/pre-commit", 30 | "pre-push": "node ./scripts/pre-push" 31 | } 32 | }, 33 | "keywords": [], 34 | "dependencies": { 35 | "import-all.macro": "^3.1.0", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "react-error-boundary": "^3.1.4", 39 | "react-router": "^6.3.0", 40 | "react-router-dom": "^6.3.0", 41 | "react-test-renderer": "^18.2.0", 42 | "react-use-geolocation": "^0.1.1" 43 | }, 44 | "devDependencies": { 45 | "@babel/preset-react": "^7.17.12", 46 | "@jackfranklin/test-data-bot": "^1.4.0", 47 | "@testing-library/jest-dom": "^5.16.4", 48 | "@testing-library/react": "^13.3.0", 49 | "@testing-library/user-event": "^14.2.1", 50 | "@types/react": "^18.0.14", 51 | "@types/react-dom": "^18.0.5", 52 | "faker": "^5.5.3", 53 | "husky": "^4.3.8", 54 | "msw": "^0.42.1", 55 | "npm-run-all": "^4.1.5", 56 | "prettier": "^2.7.1", 57 | "react-scripts": "^5.0.1", 58 | "typescript": "^4.7.4" 59 | }, 60 | "babel": { 61 | "presets": [ 62 | "@babel/preset-react" 63 | ] 64 | }, 65 | "eslintConfig": { 66 | "extends": [ 67 | "react-app" 68 | ] 69 | }, 70 | "eslintIgnore": [ 71 | "coverage", 72 | "node_modules", 73 | "build", 74 | "scripts/workshop-setup.js", 75 | "other" 76 | ], 77 | "repository": { 78 | "type": "git", 79 | "url": "git+https://github.com/kentcdodds/testing-react-app.git" 80 | }, 81 | "bugs": { 82 | "url": "https://github.com/kentcdodds/testing-react-app/issues" 83 | }, 84 | "homepage": "https://testing-react-app.netlify.app", 85 | "browserslist": [ 86 | ">0.2%", 87 | "not dead", 88 | "not ie <= 11", 89 | "not op_mini all" 90 | ], 91 | "msw": { 92 | "workerDirectory": "public" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/antic-slab.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/testing-react-apps/2995f34f6c674d23cdba85d5c06b78698b5ad3f6/public/antic-slab.woff2 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/testing-react-apps/2995f34f6c674d23cdba85d5c06b78698b5ad3f6/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 16 | 25 | Testing React Apps 🧐 26 | 82 | 83 | 84 | 85 | 86 |
87 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker (0.42.1). 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929' 12 | const bypassHeaderName = 'x-msw-bypass' 13 | const activeClientIds = new Set() 14 | 15 | self.addEventListener('install', function () { 16 | return self.skipWaiting() 17 | }) 18 | 19 | self.addEventListener('activate', async function (event) { 20 | return self.clients.claim() 21 | }) 22 | 23 | self.addEventListener('message', async function (event) { 24 | const clientId = event.source.id 25 | 26 | if (!clientId || !self.clients) { 27 | return 28 | } 29 | 30 | const client = await self.clients.get(clientId) 31 | 32 | if (!client) { 33 | return 34 | } 35 | 36 | const allClients = await self.clients.matchAll() 37 | 38 | switch (event.data) { 39 | case 'KEEPALIVE_REQUEST': { 40 | sendToClient(client, { 41 | type: 'KEEPALIVE_RESPONSE', 42 | }) 43 | break 44 | } 45 | 46 | case 'INTEGRITY_CHECK_REQUEST': { 47 | sendToClient(client, { 48 | type: 'INTEGRITY_CHECK_RESPONSE', 49 | payload: INTEGRITY_CHECKSUM, 50 | }) 51 | break 52 | } 53 | 54 | case 'MOCK_ACTIVATE': { 55 | activeClientIds.add(clientId) 56 | 57 | sendToClient(client, { 58 | type: 'MOCKING_ENABLED', 59 | payload: true, 60 | }) 61 | break 62 | } 63 | 64 | case 'MOCK_DEACTIVATE': { 65 | activeClientIds.delete(clientId) 66 | break 67 | } 68 | 69 | case 'CLIENT_CLOSED': { 70 | activeClientIds.delete(clientId) 71 | 72 | const remainingClients = allClients.filter((client) => { 73 | return client.id !== clientId 74 | }) 75 | 76 | // Unregister itself when there are no more clients 77 | if (remainingClients.length === 0) { 78 | self.registration.unregister() 79 | } 80 | 81 | break 82 | } 83 | } 84 | }) 85 | 86 | // Resolve the "main" client for the given event. 87 | // Client that issues a request doesn't necessarily equal the client 88 | // that registered the worker. It's with the latter the worker should 89 | // communicate with during the response resolving phase. 90 | async function resolveMainClient(event) { 91 | const client = await self.clients.get(event.clientId) 92 | 93 | if (client.frameType === 'top-level') { 94 | return client 95 | } 96 | 97 | const allClients = await self.clients.matchAll() 98 | 99 | return allClients 100 | .filter((client) => { 101 | // Get only those clients that are currently visible. 102 | return client.visibilityState === 'visible' 103 | }) 104 | .find((client) => { 105 | // Find the client ID that's recorded in the 106 | // set of clients that have registered the worker. 107 | return activeClientIds.has(client.id) 108 | }) 109 | } 110 | 111 | async function handleRequest(event, requestId) { 112 | const client = await resolveMainClient(event) 113 | const response = await getResponse(event, client, requestId) 114 | 115 | // Send back the response clone for the "response:*" life-cycle events. 116 | // Ensure MSW is active and ready to handle the message, otherwise 117 | // this message will pend indefinitely. 118 | if (client && activeClientIds.has(client.id)) { 119 | ;(async function () { 120 | const clonedResponse = response.clone() 121 | sendToClient(client, { 122 | type: 'RESPONSE', 123 | payload: { 124 | requestId, 125 | type: clonedResponse.type, 126 | ok: clonedResponse.ok, 127 | status: clonedResponse.status, 128 | statusText: clonedResponse.statusText, 129 | body: 130 | clonedResponse.body === null ? null : await clonedResponse.text(), 131 | headers: serializeHeaders(clonedResponse.headers), 132 | redirected: clonedResponse.redirected, 133 | }, 134 | }) 135 | })() 136 | } 137 | 138 | return response 139 | } 140 | 141 | async function getResponse(event, client, requestId) { 142 | const { request } = event 143 | const requestClone = request.clone() 144 | const getOriginalResponse = () => fetch(requestClone) 145 | 146 | // Bypass mocking when the request client is not active. 147 | if (!client) { 148 | return getOriginalResponse() 149 | } 150 | 151 | // Bypass initial page load requests (i.e. static assets). 152 | // The absence of the immediate/parent client in the map of the active clients 153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 154 | // and is not ready to handle requests. 155 | if (!activeClientIds.has(client.id)) { 156 | return await getOriginalResponse() 157 | } 158 | 159 | // Bypass requests with the explicit bypass header 160 | if (requestClone.headers.get(bypassHeaderName) === 'true') { 161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers) 162 | 163 | // Remove the bypass header to comply with the CORS preflight check. 164 | delete cleanRequestHeaders[bypassHeaderName] 165 | 166 | const originalRequest = new Request(requestClone, { 167 | headers: new Headers(cleanRequestHeaders), 168 | }) 169 | 170 | return fetch(originalRequest) 171 | } 172 | 173 | // Send the request to the client-side MSW. 174 | const reqHeaders = serializeHeaders(request.headers) 175 | const body = await request.text() 176 | 177 | const clientMessage = await sendToClient(client, { 178 | type: 'REQUEST', 179 | payload: { 180 | id: requestId, 181 | url: request.url, 182 | method: request.method, 183 | headers: reqHeaders, 184 | cache: request.cache, 185 | mode: request.mode, 186 | credentials: request.credentials, 187 | destination: request.destination, 188 | integrity: request.integrity, 189 | redirect: request.redirect, 190 | referrer: request.referrer, 191 | referrerPolicy: request.referrerPolicy, 192 | body, 193 | bodyUsed: request.bodyUsed, 194 | keepalive: request.keepalive, 195 | }, 196 | }) 197 | 198 | switch (clientMessage.type) { 199 | case 'MOCK_SUCCESS': { 200 | return delayPromise( 201 | () => respondWithMock(clientMessage), 202 | clientMessage.payload.delay, 203 | ) 204 | } 205 | 206 | case 'MOCK_NOT_FOUND': { 207 | return getOriginalResponse() 208 | } 209 | 210 | case 'NETWORK_ERROR': { 211 | const { name, message } = clientMessage.payload 212 | const networkError = new Error(message) 213 | networkError.name = name 214 | 215 | // Rejecting a request Promise emulates a network error. 216 | throw networkError 217 | } 218 | 219 | case 'INTERNAL_ERROR': { 220 | const parsedBody = JSON.parse(clientMessage.payload.body) 221 | 222 | console.error( 223 | `\ 224 | [MSW] Uncaught exception in the request handler for "%s %s": 225 | 226 | ${parsedBody.location} 227 | 228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ 229 | `, 230 | request.method, 231 | request.url, 232 | ) 233 | 234 | return respondWithMock(clientMessage) 235 | } 236 | } 237 | 238 | return getOriginalResponse() 239 | } 240 | 241 | self.addEventListener('fetch', function (event) { 242 | const { request } = event 243 | const accept = request.headers.get('accept') || '' 244 | 245 | // Bypass server-sent events. 246 | if (accept.includes('text/event-stream')) { 247 | return 248 | } 249 | 250 | // Bypass navigation requests. 251 | if (request.mode === 'navigate') { 252 | return 253 | } 254 | 255 | // Opening the DevTools triggers the "only-if-cached" request 256 | // that cannot be handled by the worker. Bypass such requests. 257 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 258 | return 259 | } 260 | 261 | // Bypass all requests when there are no active clients. 262 | // Prevents the self-unregistered worked from handling requests 263 | // after it's been deleted (still remains active until the next reload). 264 | if (activeClientIds.size === 0) { 265 | return 266 | } 267 | 268 | const requestId = uuidv4() 269 | 270 | return event.respondWith( 271 | handleRequest(event, requestId).catch((error) => { 272 | if (error.name === 'NetworkError') { 273 | console.warn( 274 | '[MSW] Successfully emulated a network error for the "%s %s" request.', 275 | request.method, 276 | request.url, 277 | ) 278 | return 279 | } 280 | 281 | // At this point, any exception indicates an issue with the original request/response. 282 | console.error( 283 | `\ 284 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, 285 | request.method, 286 | request.url, 287 | `${error.name}: ${error.message}`, 288 | ) 289 | }), 290 | ) 291 | }) 292 | 293 | function serializeHeaders(headers) { 294 | const reqHeaders = {} 295 | headers.forEach((value, name) => { 296 | reqHeaders[name] = reqHeaders[name] 297 | ? [].concat(reqHeaders[name]).concat(value) 298 | : value 299 | }) 300 | return reqHeaders 301 | } 302 | 303 | function sendToClient(client, message) { 304 | return new Promise((resolve, reject) => { 305 | const channel = new MessageChannel() 306 | 307 | channel.port1.onmessage = (event) => { 308 | if (event.data && event.data.error) { 309 | return reject(event.data.error) 310 | } 311 | 312 | resolve(event.data) 313 | } 314 | 315 | client.postMessage(JSON.stringify(message), [channel.port2]) 316 | }) 317 | } 318 | 319 | function delayPromise(cb, duration) { 320 | return new Promise((resolve) => { 321 | setTimeout(() => resolve(cb()), duration) 322 | }) 323 | } 324 | 325 | function respondWithMock(clientMessage) { 326 | return new Response(clientMessage.payload.body, { 327 | ...clientMessage.payload, 328 | headers: clientMessage.payload.headers, 329 | }) 330 | } 331 | 332 | function uuidv4() { 333 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 334 | const r = (Math.random() * 16) | 0 335 | const v = c == 'x' ? r : (r & 0x3) | 0x8 336 | return v.toString(16) 337 | }) 338 | } 339 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "node", 3 | "container": { 4 | "startScript": "start", 5 | "port": 3000, 6 | "node": "14" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /scripts/diff.js: -------------------------------------------------------------------------------- 1 | const {spawnSync} = require('child_process') 2 | const inquirer = require('inquirer') 3 | const glob = require('glob') 4 | 5 | async function go() { 6 | const files = glob 7 | .sync('src/__tests__/+(exercise|final)/*.+(js|ts|tsx)', { 8 | ignore: ['*.d.ts'], 9 | }) 10 | .map(f => f.replace(/^src\/__tests__\//, '')) 11 | const {first} = await inquirer.prompt([ 12 | { 13 | name: 'first', 14 | message: `What's the first file`, 15 | type: 'list', 16 | choices: files, 17 | }, 18 | ]) 19 | const {second} = await inquirer.prompt([ 20 | { 21 | name: 'second', 22 | message: `What's the second file`, 23 | type: 'list', 24 | choices: files.filter(f => f !== first), 25 | }, 26 | ]) 27 | 28 | spawnSync( 29 | `git diff --no-index ./src/__tests__/${first} ./src/__tests__/${second}`, 30 | { 31 | shell: true, 32 | stdio: 'inherit', 33 | }, 34 | ) 35 | } 36 | 37 | go() 38 | -------------------------------------------------------------------------------- /scripts/fix-feedback-links: -------------------------------------------------------------------------------- 1 | npx https://gist.github.com/kentcdodds/cfc1085d6a653956ab95ea2ee85a26d5 -------------------------------------------------------------------------------- /scripts/pre-commit.js: -------------------------------------------------------------------------------- 1 | var spawnSync = require('child_process').spawnSync 2 | const {username} = require('os').userInfo() 3 | 4 | if (username === 'kentcdodds') { 5 | const result = spawnSync('npm run validate', {stdio: 'inherit', shell: true}) 6 | 7 | if (result.status !== 0) { 8 | process.exit(result.status) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/pre-push.js: -------------------------------------------------------------------------------- 1 | try { 2 | const {username} = require('os').userInfo() 3 | const { 4 | repository: {url: repoUrl}, 5 | } = require('../package.json') 6 | 7 | const remote = process.env.HUSKY_GIT_PARAMS.split(' ')[1] 8 | const repoName = repoUrl.match(/(?:.(?!\/))+\.git$/)[0] 9 | if (username !== 'kentcdodds' && remote.includes(`kentcdodds${repoName}`)) { 10 | console.log( 11 | `You're trying to push to Kent's repo directly. If you want to save and push your work or even make a contribution to the workshop material, you'll need to fork the repo first and push changes to your fork. Learn how here: https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo`, 12 | ) 13 | process.exit(1) 14 | } 15 | } catch (error) { 16 | // ignore 17 | } 18 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | var spawnSync = require('child_process').spawnSync 2 | 3 | var styles = { 4 | // got these from playing around with what I found from: 5 | // https://github.com/istanbuljs/istanbuljs/blob/0f328fd0896417ccb2085f4b7888dd8e167ba3fa/packages/istanbul-lib-report/lib/file-writer.js#L84-L96 6 | // they're the best I could find that works well for light or dark terminals 7 | success: {open: '\u001b[32;1m', close: '\u001b[0m'}, 8 | danger: {open: '\u001b[31;1m', close: '\u001b[0m'}, 9 | info: {open: '\u001b[36;1m', close: '\u001b[0m'}, 10 | subtitle: {open: '\u001b[2;1m', close: '\u001b[0m'}, 11 | } 12 | 13 | function color(modifier, string) { 14 | return styles[modifier].open + string + styles[modifier].close 15 | } 16 | 17 | console.log(color('info', '▶️ Starting workshop setup...')) 18 | 19 | var output = spawnSync('npm --version', {shell: true}).stdout.toString().trim() 20 | var outputParts = output.split('.') 21 | var major = Number(outputParts[0]) 22 | var minor = Number(outputParts[1]) 23 | if (major < 8 || (major === 8 && minor < 16)) { 24 | console.error( 25 | color( 26 | 'danger', 27 | '🚨 npm version is ' + 28 | output + 29 | ' which is out of date. Please install npm@8.16.0 or greater', 30 | ), 31 | ) 32 | throw new Error('npm version is out of date') 33 | } 34 | 35 | var command = 36 | 'npx "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q' 37 | console.log( 38 | color('subtitle', ' Running the following command: ' + command), 39 | ) 40 | 41 | var result = spawnSync(command, {stdio: 'inherit', shell: true}) 42 | 43 | if (result.status === 0) { 44 | console.log(color('success', '✅ Workshop setup complete...')) 45 | } else { 46 | process.exit(result.status) 47 | } 48 | 49 | /* 50 | eslint 51 | no-var: "off", 52 | "vars-on-top": "off", 53 | */ 54 | -------------------------------------------------------------------------------- /scripts/update-deps: -------------------------------------------------------------------------------- 1 | # prettier-ignore 2 | npx npm-check-updates --upgrade --reject husky,faker,@jackfranklin/test-data-bot 3 | rm -rf node_modules package-lock.json 4 | npx npm@8 install 5 | npm run validate 6 | -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | require('./scripts/setup') 2 | 3 | -------------------------------------------------------------------------------- /src/__tests__/exercise/01.js: -------------------------------------------------------------------------------- 1 | // simple test with ReactDOM 2 | // http://localhost:3000/counter 3 | 4 | import * as React from 'react' 5 | import {act} from 'react-dom/test-utils' 6 | import {createRoot} from 'react-dom/client' 7 | import Counter from '../../components/counter' 8 | 9 | // NOTE: this is a new requirement in React 18 10 | // https://react.dev/blog/2022/03/08/react-18-upgrade-guide#configuring-your-testing-environment 11 | // Luckily, it's handled for you by React Testing Library :) 12 | global.IS_REACT_ACT_ENVIRONMENT = true 13 | 14 | test('counter increments and decrements when the buttons are clicked', () => { 15 | // 🐨 create a div to render your component to (💰 document.createElement) 16 | // 17 | // 🐨 append the div to document.body (💰 document.body.append) 18 | // 19 | // 🐨 use createRoot to render the to the div 20 | // 🐨 get a reference to the increment and decrement buttons: 21 | // 💰 div.querySelectorAll('button') 22 | // 🐨 get a reference to the message div: 23 | // 💰 div.firstChild.querySelector('div') 24 | // 25 | // 🐨 expect the message.textContent toBe 'Current count: 0' 26 | // 🐨 click the increment button (💰 act(() => increment.click())) 27 | // 🐨 assert the message.textContent 28 | // 🐨 click the decrement button (💰 act(() => decrement.click())) 29 | // 🐨 assert the message.textContent 30 | // 31 | // 🐨 cleanup by removing the div from the page (💰 div.remove()) 32 | // 🦉 If you don't cleanup, then it could impact other tests and/or cause a memory leak 33 | }) 34 | 35 | /* eslint no-unused-vars:0 */ 36 | -------------------------------------------------------------------------------- /src/__tests__/exercise/01.md: -------------------------------------------------------------------------------- 1 | # simple test with ReactDOM 2 | 3 | ## Background 4 | 5 | > "The more your tests resemble the way your software is used, the more 6 | > confidence they can give you." - 7 | > [@kentcdodds](https://twitter.com/kentcdodds/status/977018512689455106) 8 | 9 | This is a critical principle that you'll be learning about through this whole 10 | workshop. Everything we do with testing our React components is walking the line 11 | of trade-offs of getting our tests to resemble the way our software is actually 12 | used and having something that's reasonably possible for testing. 13 | 14 | When we think about how things are used, we need to consider who the users are: 15 | 16 | 1. The end user that's interacting with our code (clicking buttons/etc) 17 | 2. The developer user that's actually using our code (rendering it, calling our 18 | functions, etc.) 19 | 20 | Often a _third_ user creeps into our tests and we want to avoid them as much as 21 | possible: [The Test User](https://kentcdodds.com/blog/avoid-the-test-user). 22 | 23 | When it comes to React components, our developer user will render our component 24 | with `react-dom`'s `createRoot` API (similar concept for React Native) and in 25 | some cases they'll pass props and/or wrap it in a context provider. The end user 26 | will click buttons and assert on the output. 27 | 28 | So that's what our test will do. 29 | 30 | 📜 You'll be using assertions from jest: https://jestjs.io/docs/en/expect 31 | 32 | ## Exercise 33 | 34 | We have a simple counter component (if you have the app running locally, you can 35 | interact with it at: http://localhost:3000/counter). Your job is to make sure 36 | that it starts out saying "Current count: 0" and that when the user clicks 37 | "Increment" it'll increase the count and when they click "Decrement" it'll 38 | decrease the count. 39 | 40 | To do this, you'll need to create a DOM node, add it to the body, and render the 41 | component to that DOM node. You'll also need to clean up the DOM when your test 42 | is finished so the next test has a clean DOM to interact with. 43 | 44 | > NOTE: In React v18, you're required to wrap all your interactions in 45 | > [`act`](https://reactjs.org/docs/test-utils.html#act). So when you render and 46 | > click buttons make sure to do that. Luckily React Testing Library does this 47 | > for you automatically so you'll be able to remove that when we get to that bit 48 | > 🥳 49 | 50 | ## Extra Credit 51 | 52 | ### 1. 💯 use dispatchEvent 53 | 54 | Using `.click` on a DOM node works fine, but what if you wanted to fire an event 55 | that doesn't have a dedicated method (like mouseover). Rather than use 56 | `button.click()`, try using `button.dispatchEvent`: 📜 57 | https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent 58 | 59 | > NOTE: Make sure that your event config sets `bubbles: true` 60 | 61 | 💰 Here's how you create a MouseEvent: 62 | 63 | ```javascript 64 | new MouseEvent('click', { 65 | bubbles: true, 66 | cancelable: true, 67 | button: 0, 68 | }) 69 | ``` 70 | 71 | ## 🦉 Elaboration and Feedback 72 | 73 | After the instruction, if you want to remember what you've just learned, then 74 | fill out the elaboration and feedback form: 75 | 76 | https://ws.kcd.im/?ws=Testing%20React%20Applications%20%F0%9F%A7%90&e=01%3A%20simple%20test%20with%20ReactDOM&em= 77 | -------------------------------------------------------------------------------- /src/__tests__/exercise/02.js: -------------------------------------------------------------------------------- 1 | // simple test with React Testing Library 2 | // http://localhost:3000/counter 3 | 4 | import * as React from 'react' 5 | import {act} from 'react-dom/test-utils' 6 | import {createRoot} from 'react-dom/client' 7 | // 🐨 import the `render` and `fireEvent` utilities from '@testing-library/react' 8 | import Counter from '../../components/counter' 9 | 10 | // NOTE: this is a new requirement in React 18 11 | // https://react.dev/blog/2022/03/08/react-18-upgrade-guide#configuring-your-testing-environment 12 | // Luckily, it's handled for you by React Testing Library :) 13 | // 💣 so you can now delete this! 14 | global.IS_REACT_ACT_ENVIRONMENT = true 15 | 16 | // 💣 remove this. React Testing Library does this automatically! 17 | beforeEach(() => { 18 | document.body.innerHTML = '' 19 | }) 20 | 21 | test('counter increments and decrements when the buttons are clicked', () => { 22 | // 💣 remove these two lines, React Testing Library will create the div for you 23 | const div = document.createElement('div') 24 | document.body.append(div) 25 | 26 | // 🐨 swap createRoot and root.render with React Testing Library's render 27 | // Note that React Testing Library's render doesn't need you to pass a `div` 28 | // so you only need to pass one argument. render returns an object with a 29 | // bunch of utilities on it. For now, let's just grab `container` which is 30 | // the div that React Testing Library creates for us. 31 | // 💰 const {container} = render() 32 | const root = createRoot(div) 33 | act(() => root.render()) 34 | 35 | // 🐨 instead of `div` here you'll want to use the `container` you get back 36 | // from React Testing Library 37 | const [decrement, increment] = div.querySelectorAll('button') 38 | const message = div.firstChild.querySelector('div') 39 | 40 | expect(message.textContent).toBe('Current count: 0') 41 | 42 | // 🐨 replace the next two statements with `fireEvent.click(button)` 43 | // 💰 note that you can remove `act` completely! 44 | const incrementClickEvent = new MouseEvent('click', { 45 | bubbles: true, 46 | cancelable: true, 47 | button: 0, 48 | }) 49 | act(() => increment.dispatchEvent(incrementClickEvent)) 50 | expect(message.textContent).toBe('Current count: 1') 51 | const decrementClickEvent = new MouseEvent('click', { 52 | bubbles: true, 53 | cancelable: true, 54 | button: 0, 55 | }) 56 | act(() => decrement.dispatchEvent(decrementClickEvent)) 57 | expect(message.textContent).toBe('Current count: 0') 58 | }) 59 | -------------------------------------------------------------------------------- /src/__tests__/exercise/02.md: -------------------------------------------------------------------------------- 1 | # simple test with React Testing Library 2 | 3 | ## Background 4 | 5 | As much as I enjoy creating DOM nodes and appending them to the `body`, that 6 | seems like boilerplate that could live in an abstraction. And it is! Among other 7 | things, that's what React Testing Library does. 8 | 9 | [React Testing Library](https://testing-library.com/react) is the React 10 | implementation of the [DOM Testing Library](https://testing-library.com) 11 | (there's also a 12 | [React Native Testing Library](https://testing-library.com/react-native) and 13 | many others). Testing Library comes with a ton of really useful features which 14 | we'll be using throughout this workshop, but for now, we'll just start out with 15 | cleaning up some of this boilerplate. 16 | 17 | Here's a simple example of how to use this: 18 | 19 | ```javascript 20 | import {render, fireEvent, screen} from '@testing-library/react' 21 | 22 | test('it works', () => { 23 | const {container} = render() 24 | // container is the div that your component has been mounted onto. 25 | 26 | const input = container.querySelector('input') 27 | fireEvent.mouseEnter(input) // fires a mouseEnter event on the input 28 | 29 | screen.debug() // logs the current state of the DOM (with syntax highlighting!) 30 | }) 31 | ``` 32 | 33 | Notice the lack of `cleanup` functionality. That's thanks to 34 | `@testing-library/react`'s 35 | [auto-cleanup feature](https://testing-library.com/docs/react-testing-library/api#cleanup) 36 | 37 | Another automatic feature of React Testing Library is its handling of 38 | [React's `act` function](https://reactjs.org/docs/test-utils.html#act). If 39 | you've ever seen a warning about something not being wrapped in `act`, that's 40 | what we're talking about. As mentioned in the React docs, React Testing Library 41 | is recommended for avoiding the issues `act` is warning you about. You can learn 42 | more about this from my blog post 43 | [Fix the "not wrapped in act(...)" warning](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning). 44 | 45 | ## Exercise 46 | 47 | In this exercise, we're going to remove some of our boilerplate that React 48 | Testing Library does for us. The emoji should guide you pretty well on this one 49 | so I'll let you have at it! 50 | 51 | ## Extra Credit 52 | 53 | ### 1. 💯 use @testing-library/jest-dom 54 | 55 | Testing Library also has a suite of assertions that can be installed with Jest. 56 | They're already added to this project, so you can switch from Jest's built-in 57 | assertions to more specific assertions which will give you better error 58 | messages. So go ahead and swap the `expect(message.textContent).toBe(...)` 59 | assertions with `toHaveTextContent` from 60 | [`@testing-library/jest-dom`](http://testing-library.com/jest-dom). 61 | 62 | ## 🦉 Elaboration and Feedback 63 | 64 | After the instruction, if you want to remember what you've just learned, then 65 | fill out the elaboration and feedback form: 66 | 67 | https://ws.kcd.im/?ws=Testing%20React%20Applications%20%F0%9F%A7%90&e=02%3A%20simple%20test%20with%20React%20Testing%20Library&em= 68 | -------------------------------------------------------------------------------- /src/__tests__/exercise/03.js: -------------------------------------------------------------------------------- 1 | // Avoid implementation details 2 | // http://localhost:3000/counter 3 | 4 | import * as React from 'react' 5 | // 🐨 add `screen` to the import here: 6 | import {render, fireEvent} from '@testing-library/react' 7 | import Counter from '../../components/counter' 8 | 9 | test('counter increments and decrements when the buttons are clicked', () => { 10 | const {container} = render() 11 | // 🐨 replace these with screen queries 12 | // 💰 you can use `getByText` for each of these (`getByRole` can work for the button too) 13 | const [decrement, increment] = container.querySelectorAll('button') 14 | const message = container.firstChild.querySelector('div') 15 | 16 | expect(message).toHaveTextContent('Current count: 0') 17 | fireEvent.click(increment) 18 | expect(message).toHaveTextContent('Current count: 1') 19 | fireEvent.click(decrement) 20 | expect(message).toHaveTextContent('Current count: 0') 21 | }) 22 | -------------------------------------------------------------------------------- /src/__tests__/exercise/03.md: -------------------------------------------------------------------------------- 1 | # Avoid implementation details 2 | 3 | ## Background 4 | 5 | One of the most important things to remember about testing our software the way 6 | it is used is to avoid testing implementation details. "Implementation details" 7 | is a term referring to how an abstraction accomplishes a certain outcome. Thanks 8 | to the expressiveness of code, you can typically accomplish the same outcome 9 | using completely different implementation details. For example: 10 | 11 | ```javascript 12 | multiply(4, 5) // 20 13 | ``` 14 | 15 | The `multiply` function can be implemented in basically infinite ways. Here are 16 | two examples: 17 | 18 | ```javascript 19 | const multiply = (a, b) => a * b 20 | ``` 21 | 22 | vs 23 | 24 | ```javascript 25 | function multiply(a, b) { 26 | let total = 0 27 | for (let i = 0; i < b; i++) { 28 | total += a 29 | } 30 | return total 31 | } 32 | ``` 33 | 34 | One of those is more clear than the other, but that's irrelevant to the point: 35 | The implementation of your abstractions does not matter to the users of your 36 | abstraction and if you want to have confidence that it continues to work through 37 | refactors then **neither should your tests.** 38 | 39 | Here's a React example of this: 40 | 41 | ```javascript 42 | function Counter() { 43 | const [count, setCount] = React.useState(0) 44 | const increment = () => setCount(c => c + 1) 45 | return 46 | } 47 | ``` 48 | 49 | Here's one way you might access that `button` to click and assert on it: 50 | 51 | ```javascript 52 | const {container} = render() 53 | container.firstChild // <-- that's the button 54 | ``` 55 | 56 | However, what if we changed it a bit: 57 | 58 | ```javascript 59 | function Counter() { 60 | const [count, setCount] = React.useState(0) 61 | const increment = () => setCount(c => c + 1) 62 | return ( 63 | 64 | 65 | 66 | ) 67 | } 68 | ``` 69 | 70 | Our tests would break! 71 | 72 | The only difference between these implementations is one wraps the button in a 73 | `span` and the other does not. The user does not observe or care about this 74 | difference, so we should write our tests in a way that passes in either case. 75 | 76 | So here's a better way to search for that button in our test that's 77 | implementation detail free and refactor friendly: 78 | 79 | ```javascript 80 | render() 81 | screen.getByText('0') // <-- that's the button 82 | // or (even better) you can do this: 83 | screen.getByRole('button', {name: '0'}) // <-- that's the button 84 | ``` 85 | 86 | 📜 Read up on `screen` here: 87 | https://testing-library.com/docs/dom-testing-library/api-queries#screen 88 | 89 | Both of those resembles the way the user will search for our increment button. 90 | 91 | 📜 Read more about 92 | [Testing Implementation Details](https://kentcdodds.com/blog/testing-implementation-details) 93 | and how to 94 | [Avoid the Test User](https://kentcdodds.com/blog/avoid-the-test-user) 95 | 96 | 📜 Learn more about the queries built-into React Testing Library from 97 | [the query docs](https://testing-library.com/docs/dom-testing-library/api-queries). 98 | 99 | ## Exercise 100 | 101 | Our current tests rely on implementation details. You can tell whether tests 102 | rely on implementation details if they're written in a way that would fail if 103 | the implementation changes. For example, what if we wrapped our counter 104 | component in another `div` or swapped our message from a `div` to a `span` or 105 | `p`? Or what if we added another button for `reset`? Or what if instead of a 106 | `button` we switched to a clickable (and accessible) `div`? (That's not an easy 107 | thing to do, so I recommend just using a button, but the point is hopefully 108 | clear). 109 | 110 | Each of these things are implementation details that none of our users should 111 | know or care about, so this exercise is intended to help you learn to avoid 112 | implementation details by querying for and interacting with the elements in a 113 | way that is implementation detail free and refactor friendly. 114 | 115 | ## Extra Credit 116 | 117 | ### 1. 💯 use userEvent 118 | 119 | As it turns out, clicking these buttons is also a bit of an implementation 120 | detail. We're firing a single event, when we actually should be firing several 121 | other events like the user does. When a user clicks a button, they first have to 122 | move their mouse over the button which will fire some mouse events. They'll also 123 | mouse down and mouse up on the input and focus it! Lots of events! 124 | 125 | If we want to be truly implementation detail free, then we should probably fire 126 | all those same events too. Luckily for us, Testing Library has us covered with 127 | `@testing-library/user-event`. This may one-day be baked directly into 128 | `@testing-library/dom`, but for now it's in a separate package. 129 | 130 | For this extra credit, swap out `fireEvent` for `userEvent` which you can get 131 | like so: 132 | 133 | ```javascript 134 | import userEvent from '@testing-library/user-event' 135 | ``` 136 | 137 | Once you're done, look around in the code of `@testing-library/user-event`'s 138 | [`click` method](https://github.com/testing-library/user-event/blob/1af67066f57377c5ab758a1215711dddabad2d83/src/index.js#L109-L131). 139 | It's pretty interesting! 140 | 141 | NOTE: In the latest version of `@testing-library/user-event`, all methods return 142 | a promise, so make sure you `await` the result of `userEvent.click`! 143 | 144 | ## 🦉 Elaboration and Feedback 145 | 146 | After the instruction, if you want to remember what you've just learned, then 147 | fill out the elaboration and feedback form: 148 | 149 | https://ws.kcd.im/?ws=Testing%20React%20Applications%20%F0%9F%A7%90&e=03%3A%20Avoid%20implementation%20details&em= 150 | -------------------------------------------------------------------------------- /src/__tests__/exercise/04.js: -------------------------------------------------------------------------------- 1 | // form testing 2 | // http://localhost:3000/login 3 | 4 | import * as React from 'react' 5 | import {render, screen} from '@testing-library/react' 6 | import userEvent from '@testing-library/user-event' 7 | import Login from '../../components/login' 8 | 9 | test('submitting the form calls onSubmit with username and password', () => { 10 | // 🐨 create a variable called "submittedData" and a handleSubmit function that 11 | // accepts the data and assigns submittedData to the data that was submitted 12 | // 💰 if you need a hand, here's what the handleSubmit function should do: 13 | // const handleSubmit = data => (submittedData = data) 14 | // 15 | // 🐨 render the login with your handleSubmit function as the onSubmit prop 16 | // 17 | // 🐨 get the username and password fields via `getByLabelText` 18 | // 🐨 use `await userEvent.type...` to change the username and password fields to 19 | // whatever you want 20 | // 21 | // 🐨 click on the button with the text "Submit" 22 | // 23 | // assert that submittedData is correct 24 | // 💰 use `toEqual` from Jest: 📜 https://jestjs.io/docs/en/expect#toequalvalue 25 | }) 26 | 27 | /* 28 | eslint 29 | no-unused-vars: "off", 30 | */ 31 | -------------------------------------------------------------------------------- /src/__tests__/exercise/04.md: -------------------------------------------------------------------------------- 1 | # form testing 2 | 3 | ## Background 4 | 5 | Our users spend a lot of time interacting with forms and many of our forms are 6 | among the most important parts of our application (like the "checkout" form of 7 | an e-commerce app or the "login" form of most apps). Because of this, it's 8 | pretty critical to have confidence that those continue to work over time. 9 | 10 | You need to ensure that the user can find inputs in the form, fill in their 11 | information, and validate that when they submit the form the submitted data is 12 | correct. 13 | 14 | ## Exercise 15 | 16 | In this exercise, we'll be testing a Login form that has a username and 17 | password. The Login form accepts an `onSubmit` handler which will be called with 18 | the form data when the user submits the form. Your job is to write a test for 19 | this form. 20 | 21 | Make sure to keep your test implementation detail free and refactor friendly! 22 | 23 | ## Extra Credit 24 | 25 | ### 1. 💯 use a jest mock function 26 | 27 | Jest has built-in "mock" function APIs. Rather than creating the `submittedData` 28 | variable, try to use a mock function and assert it was called correctly: 29 | 30 | - 📜 `jest.fn()`: https://jestjs.io/docs/en/mock-function-api 31 | - 📜 `toHaveBeenCalledWith`: 32 | https://jestjs.io/docs/en/expect#tohavebeencalledwitharg1-arg2- 33 | 34 | ### 2. 💯 generate test data 35 | 36 | An important thing to keep in mind when testing is simplifying the maintenance 37 | of the tests by reducing the amount of unrelated cruft in the test. You want to 38 | make it so the code for the test communicates what's important and what is not 39 | important. 40 | 41 | Specifically, in my solution I have these values: 42 | 43 | ```javascript 44 | const username = 'chucknorris' 45 | const password = 'i need no password' 46 | ``` 47 | 48 | Does my code behave differently when the username is `chucknorris`? Do I have 49 | special logic around that? Without looking at the implementation I cannot be 50 | completely sure. What would be better is if the code communicated that the 51 | actual value is irrelevant. But how do you communicate that? A code comment? 52 | Nah, let's generate the value! 53 | 54 | ```javascript 55 | const username = getRandomUsername() 56 | const password = getRandomPassword() 57 | ``` 58 | 59 | That communicates the intent really well. As a reader of the test I can think: 60 | "Oh, ok, great, so it doesn't matter what the username _is_, just that it's a 61 | typical username." 62 | 63 | Luckily, there's a package we can use for this called 64 | [faker](https://www.npmjs.com/package/@faker-js/faker). You can get a random username and 65 | password from `faker.internet.userName()` (note the capital `N`) and 66 | `faker.internet.password()`. We've already got it installed in this project, so 67 | go ahead and import that and generate the username and password. 68 | 69 | Even better, create a `buildLoginForm` function which allows me to call it like 70 | this: 71 | 72 | ```javascript 73 | const {username, password} = buildLoginForm() 74 | ``` 75 | 76 | ### 3. 💯 allow for overrides 77 | 78 | Sometimes you actually _do_ have some specific data that's important for the 79 | test. For example, if our form performed validation on the password being a 80 | certain strength, then we might not want a randomly generated password and we'd 81 | instead want a specific password. 82 | 83 | Try to make your `buildLoginForm` function accept overrides as well: 84 | 85 | ```javascript 86 | const {username, password} = buildLoginForm({password: 'abc'}) 87 | // password === 'abc' 88 | ``` 89 | 90 | That communicates the reader of the test: "We just need a normal login form, 91 | except the password needs to be something specific for this test." 92 | 93 | ### 4. 💯 use Test Data Bot 94 | 95 | There's a library I like to use for generating test data: 96 | [`@jackfranklin/test-data-bot`](https://www.npmjs.com/package/@jackfranklin/test-data-bot). 97 | It provides a few nice utilities. Check out the docs there and swap your custom 98 | `buildLoginForm` with one you create using the Test Data Bot. 99 | 100 | ## 🦉 Elaboration and Feedback 101 | 102 | After the instruction, if you want to remember what you've just learned, then 103 | fill out the elaboration and feedback form: 104 | 105 | https://ws.kcd.im/?ws=Testing%20React%20Applications%20%F0%9F%A7%90&e=04%3A%20form%20testing&em= 106 | -------------------------------------------------------------------------------- /src/__tests__/exercise/05.js: -------------------------------------------------------------------------------- 1 | // mocking HTTP requests 2 | // http://localhost:3000/login-submission 3 | 4 | import * as React from 'react' 5 | // 🐨 you'll need to grab waitForElementToBeRemoved from '@testing-library/react' 6 | import {render, screen} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import {build, fake} from '@jackfranklin/test-data-bot' 9 | // 🐨 you'll need to import rest from 'msw' and setupServer from msw/node 10 | import Login from '../../components/login-submission' 11 | 12 | const buildLoginForm = build({ 13 | fields: { 14 | username: fake(f => f.internet.userName()), 15 | password: fake(f => f.internet.password()), 16 | }, 17 | }) 18 | 19 | // 🐨 get the server setup with an async function to handle the login POST request: 20 | // 💰 here's something to get you started 21 | // rest.post( 22 | // 'https://auth-provider.example.com/api/login', 23 | // async (req, res, ctx) => {}, 24 | // ) 25 | // you'll want to respond with an JSON object that has the username. 26 | // 📜 https://mswjs.io/ 27 | 28 | // 🐨 before all the tests, start the server with `server.listen()` 29 | // 🐨 after all the tests, stop the server with `server.close()` 30 | 31 | test(`logging in displays the user's username`, async () => { 32 | render() 33 | const {username, password} = buildLoginForm() 34 | 35 | await userEvent.type(screen.getByLabelText(/username/i), username) 36 | await userEvent.type(screen.getByLabelText(/password/i), password) 37 | // 🐨 uncomment this and you'll start making the request! 38 | // await userEvent.click(screen.getByRole('button', {name: /submit/i})) 39 | 40 | // as soon as the user hits submit, we render a spinner to the screen. That 41 | // spinner has an aria-label of "loading" for accessibility purposes, so 42 | // 🐨 wait for the loading spinner to be removed using waitForElementToBeRemoved 43 | // 📜 https://testing-library.com/docs/dom-testing-library/api-async#waitforelementtoberemoved 44 | 45 | // once the login is successful, then the loading spinner disappears and 46 | // we render the username. 47 | // 🐨 assert that the username is on the screen 48 | }) 49 | -------------------------------------------------------------------------------- /src/__tests__/exercise/05.md: -------------------------------------------------------------------------------- 1 | # mocking HTTP requests 2 | 3 | ## Background 4 | 5 | Testing that our frontend code interacts with the backend is important. It's how 6 | the user uses our applications, so it's what our tests should do as well if we 7 | want the maximum confidence. However, there are several challenges that come 8 | with doing that. The setup required to make this work is non-trivial. It is 9 | definitely important that we test that integration, but we can do that with a 10 | suite of solid E2E tests using a tool like [Cypress](https://cypress.io). 11 | 12 | For our Integration and Unit component tests, we're going to trade-off some 13 | confidence for convenience and we'll make up for that with E2E tests. So for all 14 | of our Jest tests, we'll start up a mock server to handle all of the 15 | `window.fetch` requests we make during our tests. 16 | 17 | > Because window.fetch isn't supported in JSDOM/Node, we have the `whatwg-fetch` 18 | > module installed which will polyfill fetch in our testing environment 19 | > which will allow MSW to handle those requests for us. This is setup 20 | > automatically in our jest config thanks to `react-scripts`. 21 | 22 | To handle these fetch requests, we're going to start up a "server" which is not 23 | actually a server, but simply a request interceptor. This makes it really easy 24 | to get things setup (because we don't have to worry about finding an available 25 | port for the server to listen to and making sure we're making requests to the 26 | right port) and it also allows us to mock requests made to other domains. 27 | 28 | We'll be using a tool called [MSW](https://mswjs.io/) for this. Here's an 29 | example of how you can use msw for tests: 30 | 31 | ```javascript 32 | // __tests__/fetch.test.js 33 | import * as React from 'react' 34 | import {rest} from 'msw' 35 | import {setupServer} from 'msw/node' 36 | import {render, waitForElementToBeRemoved, screen} from '@testing-library/react' 37 | import {userEvent} from '@testing-library/user-event' 38 | import Fetch from '../fetch' 39 | 40 | const server = setupServer( 41 | rest.get('/greeting', (req, res, ctx) => { 42 | return res(ctx.json({greeting: 'hello there'})) 43 | }), 44 | ) 45 | 46 | beforeAll(() => server.listen()) 47 | afterEach(() => server.resetHandlers()) 48 | afterAll(() => server.close()) 49 | 50 | test('loads and displays greeting', async () => { 51 | render() 52 | 53 | await userEvent.click(screen.getByText('Load Greeting')) 54 | 55 | await waitForElementToBeRemoved(() => screen.getByText('Loading...')) 56 | 57 | expect(screen.getByRole('heading')).toHaveTextContent('hello there') 58 | expect(screen.getByRole('button')).toHaveAttribute('disabled') 59 | }) 60 | 61 | test('handles server error', async () => { 62 | server.use( 63 | rest.get('/greeting', (req, res, ctx) => { 64 | return res(ctx.status(500)) 65 | }), 66 | ) 67 | 68 | render() 69 | 70 | await userEvent.click(screen.getByText('Load Greeting')) 71 | 72 | await waitForElementToBeRemoved(() => screen.getByText('Loading...')) 73 | 74 | expect(screen.getByRole('alert')).toHaveTextContent('Oops, failed to fetch!') 75 | expect(screen.getByRole('button')).not.toHaveAttribute('disabled') 76 | }) 77 | ``` 78 | 79 | That should give you enough to go on, but if you'd like to check out the docs, 80 | please do! 81 | 82 | 📜 [MSW](https://mswjs.io/) 83 | 84 | ## Exercise 85 | 86 | In the last exercise you wrote a test for the Login form by itself, now you'll 87 | be writing a test that connects that login form with a backend request for when 88 | the user submits the form. 89 | 90 | We'll use `waitForElementToBeRemoved` to wait for the loading indicator to go 91 | away. 92 | 93 | ## Extra Credit 94 | 95 | ### 1. 💯 reuse server request handlers 96 | 97 | In my applications, I love having a mock server to use during development. It's 98 | often more reliable, works offline, doesn't require a lot of environment setup, 99 | and allows me to start writing UI for APIs that aren't finished yet. 100 | 101 | MSW was actually originally built for this use case and we've already 102 | implemented this server handler for our app in `test/server-handlers.js`, so for 103 | this extra credit, import that array of server handlers and send it along into 104 | the `setupServer` call. 105 | 106 | ### 2. 💯 test the unhappy path 107 | 108 | Add a test for what happens if the response to our login request is a failure. 109 | Our server handlers already handle situations where the username or password are 110 | not provided, so you can simply not fill one of those values in and then you'll 111 | want to make sure the error message is displayed. 112 | 113 | ### 3. 💯 use inline snapshots for error messages 114 | 115 | Copy and pasting output into your test assertion (like the error message in our 116 | last extra credit) is no fun. Especially if that error message were to change in 117 | the future. 118 | 119 | Instead, we can use a special assertion to take a "snapshot" of the error 120 | message and Jest will update our code for us. Use `toMatchInlineSnapshot` rather 121 | than an explicit assertion on that error element. 122 | 123 | 📜 [Snapshot Testing](https://jestjs.io/docs/en/snapshot-testing) 124 | 125 | ### 4. 💯 use one-off server handlers 126 | 127 | How would we test a situation where the server fails for some unknown reason? 128 | There are plenty of situations where we want to test what happens when the 129 | _server_ misbehaves. But we don't want to code those scenarios in our 130 | application-wide server handlers for two reasons: 131 | 132 | 1. It clutters our application-wide handlers. Lots of the same problems of CSS 133 | applies here: people are afraid to modify or delete any code because they're 134 | uncertain what other code will break as a result. 135 | 2. The indirection makes the tests harder to understand. 136 | 137 | [Read more about the benefits of colocation](https://kentcdodds.com/blog/colocation). 138 | 139 | So instead, we want one-off server handlers to be written directly in the test 140 | that needs it. This is what MSW's `server.use` API is for. It allows you to add 141 | server handlers after the server has already started. And the 142 | `server.resetHandlers()` allows you to remove those added handlers between tests 143 | to preserve test isolation and restore the original handlers. 144 | 145 | See if you can add another test to check a situation for when the server 146 | misbehaves and sends a status code 500 error. 147 | 148 | 💰 Here's something to get you started: 149 | 150 | ```javascript 151 | server.use( 152 | rest.post( 153 | // note that it's the same URL as our app-wide handler 154 | // so this will override the other. 155 | 'https://auth-provider.example.com/api/login', 156 | async (req, res, ctx) => { 157 | // your one-off handler here 158 | }, 159 | ), 160 | ) 161 | ``` 162 | 163 | ## 🦉 Elaboration and Feedback 164 | 165 | After the instruction, if you want to remember what you've just learned, then 166 | fill out the elaboration and feedback form: 167 | 168 | https://ws.kcd.im/?ws=Testing%20React%20Applications%20%F0%9F%A7%90&e=05%3A%20mocking%20HTTP%20requests&em= 169 | -------------------------------------------------------------------------------- /src/__tests__/exercise/06.js: -------------------------------------------------------------------------------- 1 | // mocking Browser APIs and modules 2 | // http://localhost:3000/location 3 | 4 | import * as React from 'react' 5 | import {render, screen, act} from '@testing-library/react' 6 | import Location from '../../examples/location' 7 | 8 | // 🐨 set window.navigator.geolocation to an object that has a getCurrentPosition mock function 9 | 10 | // 💰 I'm going to give you this handy utility function 11 | // it allows you to create a promise that you can resolve/reject on demand. 12 | function deferred() { 13 | let resolve, reject 14 | const promise = new Promise((res, rej) => { 15 | resolve = res 16 | reject = rej 17 | }) 18 | return {promise, resolve, reject} 19 | } 20 | // 💰 Here's an example of how you use this: 21 | // const {promise, resolve, reject} = deferred() 22 | // promise.then(() => {/* do something */}) 23 | // // do other setup stuff and assert on the pending state 24 | // resolve() 25 | // await promise 26 | // // assert on the resolved state 27 | 28 | test('displays the users current location', async () => { 29 | // 🐨 create a fakePosition object that has an object called "coords" with latitude and longitude 30 | // 📜 https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition 31 | // 32 | // 🐨 create a deferred promise here 33 | // 34 | // 🐨 Now we need to mock the geolocation's getCurrentPosition function 35 | // To mock something you need to know its API and simulate that in your mock: 36 | // 📜 https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition 37 | // 38 | // here's an example of the API: 39 | // function success(position) {} 40 | // function error(error) {} 41 | // navigator.geolocation.getCurrentPosition(success, error) 42 | // 43 | // 🐨 so call mockImplementation on getCurrentPosition 44 | // 🐨 the first argument of your mock should accept a callback 45 | // 🐨 you'll call the callback when the deferred promise resolves 46 | // 💰 promise.then(() => {/* call the callback with the fake position */}) 47 | // 48 | // 🐨 now that setup is done, render the Location component itself 49 | // 50 | // 🐨 verify the loading spinner is showing up 51 | // 💰 tip: try running screen.debug() to know what the DOM looks like at this point. 52 | // 53 | // 🐨 resolve the deferred promise 54 | // 🐨 wait for the promise to resolve 55 | // 💰 right around here, you'll probably notice you get an error log in the 56 | // test output. You can ignore that for now and just add this next line: 57 | // act(() => {}) 58 | // 59 | // If you'd like, learn about what this means and see if you can figure out 60 | // how to make the warning go away (tip, you'll need to use async act) 61 | // 📜 https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning 62 | // 63 | // 🐨 verify the loading spinner is no longer in the document 64 | // (💰 use queryByLabelText instead of getByLabelText) 65 | // 🐨 verify the latitude and longitude appear correctly 66 | }) 67 | 68 | /* 69 | eslint 70 | no-unused-vars: "off", 71 | */ 72 | -------------------------------------------------------------------------------- /src/__tests__/exercise/06.md: -------------------------------------------------------------------------------- 1 | # mocking Browser APIs and modules 2 | 3 | ## Background 4 | 5 | Mocking HTTP requests is one thing, but sometimes you have entire Browser APIs 6 | or modules that you need to mock. Every time you create a fake version of what 7 | your code actually uses, you're "poking a hole in reality" and you lose some 8 | confidence as a result (which is why E2E tests are critical). Remember, we're 9 | doing it and recognizing that we're trading confidence for some practicality or 10 | convenience in our testing. (Read more about this in my blog post: 11 | [The Merits of Mocking](https://kentcdodds.com/blog/the-merits-of-mocking)). 12 | 13 | To learn more about what "mocking" even is, take a look at my blog post 14 | [But really, what is a JavaScript mock?](https://kentcdodds.com/blog/but-really-what-is-a-javascript-mock) 15 | 16 | ### Mocking Browser APIs 17 | 18 | I need to tell you a little secret and I want you to promise me to not be mad... 19 | 20 | Our tests aren't running in the browser 😱😱😱😱😱 21 | 22 | It's true. They're running in a _simulated_ browser environment in Node. This is 23 | done thanks to a module called [jsdom](https://github.com/jsdom/jsdom). It does 24 | its best to simulate the browser and implement standards. But there are some 25 | things it's simply not capable of simulating today. One example is window resize 26 | and media queries. In my 27 | [Advanced React Hooks workshop](https://kentcdodds.com/workshops/advanced-react-hooks), 28 | I teach something using a custom `useMedia` hook and to test it, I have to mock 29 | out the browser `window.resizeTo` method and polyfill `window.matchMedia`. 30 | Here's how I go about doing that: 31 | 32 | ```javascript 33 | import matchMediaPolyfill from 'mq-polyfill' 34 | 35 | beforeAll(() => { 36 | matchMediaPolyfill(window) 37 | window.resizeTo = function resizeTo(width, height) { 38 | Object.assign(this, { 39 | innerWidth: width, 40 | innerHeight: height, 41 | outerWidth: width, 42 | outerHeight: height, 43 | }).dispatchEvent(new this.Event('resize')) 44 | } 45 | }) 46 | ``` 47 | 48 | This allows me to continue to test with Jest (in node) while not actually 49 | running in a browser. 50 | 51 | So why do we go through all the trouble? Because the tools we currently have for 52 | testing are WAY faster and WAY more capable when run in node. Most of the time, 53 | you can mock browser APIs for your tests without losing too much confidence. 54 | However, if you are testing something that really relies on browser APIs or 55 | layout (like drag-and-drop) then you may be better served by writing those tests 56 | in a real browser (using a tool like [Cypress](https://cypress.io)). 57 | 58 | ### Mocking Modules 59 | 60 | Sometimes, a module is doing something you don't want to actually do in tests. 61 | Jest makes it relatively simple to mock a module: 62 | 63 | ```javascript 64 | // math.js 65 | export const add = (a, b) => a + b 66 | export const subtract = (a, b) => a - b 67 | 68 | // __tests__/some-test.js 69 | import {add, subtract} from '../math' 70 | 71 | jest.mock('../math') 72 | 73 | // now all the function exports from the "math.js" module are jest mock functions 74 | // so we can call .mockImplementation(...) on them 75 | // and make assertions like .toHaveBeenCalledTimes(...) 76 | ``` 77 | 78 | Additionally, if you'd like to mock only _parts_ of a module, you can provide 79 | your own "mock module getter" function: 80 | 81 | ```javascript 82 | jest.mock('../math', () => { 83 | const actualMath = jest.requireActual('../math') 84 | return { 85 | ...actualMath, 86 | subtract: jest.fn(), 87 | } 88 | }) 89 | 90 | // now the `add` export is the normal function, 91 | // but the `subtract` export is a mock function. 92 | ``` 93 | 94 | To learn a bit about how this works, take a look at my repo 95 | [how-jest-mocking-works](https://github.com/kentcdodds/how-jest-mocking-works). 96 | It's pretty fascinating. 97 | 98 | There's a lot more to learn about the things you can do with Jest's module 99 | mocking capabilities. You can also read the docs about this here: 100 | 101 | 📜 [Manual Mocks](https://jestjs.io/docs/en/manual-mocks) 102 | 103 | ## Exercise 104 | 105 | We've got a `Location` component that will request the user's location and then 106 | display the latitude and longitude values on screen. And yup, you guessed it, 107 | `window.navigator.geolocation.getCurrentPosition` is not supported by jsdom, so 108 | we need to mock it out. We'll mock it with a jest mock function so we can call 109 | [`mockImplementation`](https://jestjs.io/docs/en/mock-function-api#mockfnmockimplementationfn) 110 | and mock what that function does for a particular test. 111 | 112 | We'll also bump into one of the few situations you need to use 113 | [`act`](https://reactjs.org/docs/test-utils.html#act) directly. 114 | [Learn more](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning). 115 | 116 | ## Extra Credit 117 | 118 | ### 1. 💯 mock the module 119 | 120 | Sometimes, the module is interacting with browser APIs that are just too hard to 121 | mock (like `canvas`) or you're comfortable relying on the module's own test 122 | suite to give you confidence that so long as you use the module properly 123 | everything should work. 124 | 125 | In that case, it's reasonable to mock the module directly. So for this extra 126 | credit, try to mock the module rather than the browser API it's using. 127 | 128 | 💰 tip, you're mocking a hook. Your mock implementation can also be a hook (so 129 | you can use `React.useState`!). 130 | 131 | ### 2. 💯 test the unhappy path 132 | 133 | > NOTE: A recording of me doing this extra credit is not on EpicReact.Dev yet, 134 | > but feel free to give it a try anyway! 135 | 136 | Add a test for what happens in the event of an error. You can try it with the 137 | module mocking approach, but in my solution, I go back to the function mocking 138 | version. 139 | 140 | ## 🦉 Elaboration and Feedback 141 | 142 | After the instruction, if you want to remember what you've just learned, then 143 | fill out the elaboration and feedback form: 144 | 145 | https://ws.kcd.im/?ws=Testing%20React%20Applications%20%F0%9F%A7%90&e=06%3A%20mocking%20Browser%20APIs%20and%20modules&em= 146 | -------------------------------------------------------------------------------- /src/__tests__/exercise/07.js: -------------------------------------------------------------------------------- 1 | // testing with context and a custom render method 2 | // http://localhost:3000/easy-button 3 | 4 | import * as React from 'react' 5 | import {render, screen} from '@testing-library/react' 6 | import {ThemeProvider} from '../../components/theme' 7 | import EasyButton from '../../components/easy-button' 8 | 9 | test('renders with the light styles for the light theme', () => { 10 | // 🐨 uncomment all of this code and your test will be busted on the next line: 11 | // render(Easy) 12 | // const button = screen.getByRole('button', {name: /easy/i}) 13 | // expect(button).toHaveStyle(` 14 | // background-color: white; 15 | // color: black; 16 | // `) 17 | // 18 | // 🐨 update the `render` call above to use the wrapper option using the 19 | // ThemeProvider 20 | }) 21 | 22 | /* eslint no-unused-vars:0 */ 23 | -------------------------------------------------------------------------------- /src/__tests__/exercise/07.md: -------------------------------------------------------------------------------- 1 | # testing with context and a custom render method 2 | 3 | ## Background 4 | 5 | A common question when testing React components is what to do with React 6 | components that use context values. If you take a step back and consider the 7 | guiding testing philosophy of writing tests that resemble the way our software 8 | is used, then you'll know that you want to render your component with the 9 | provider: 10 | 11 | ```javascript 12 | render( 13 | 14 | 15 | , 16 | ) 17 | ``` 18 | 19 | The one problem with this is if you want to re-render the `` 20 | (for example, to give it new props and test how it responds to updated props), 21 | then you have to include the context providers: 22 | 23 | ```javascript 24 | const {rerender} = render( 25 | 26 | 27 | , 28 | ) 29 | 30 | rerender( 31 | 32 | 33 | , 34 | ) 35 | ``` 36 | 37 | This is kind of annoying, so instead, you can provide a `wrapper` option and 38 | that will ensure that rerenders are wrapped as well: 39 | 40 | ```javascript 41 | function Wrapper({children}) { 42 | return {children} 43 | } 44 | 45 | const {rerender} = render(, {wrapper: Wrapper}) 46 | 47 | rerender() 48 | ``` 49 | 50 | 📜 https://testing-library.com/docs/react-testing-library/api#wrapper 51 | 52 | This `Wrapper` could include providers for all your context providers in your 53 | app: Router, Theme, Authentication, etc. 54 | 55 | To take it further, you could create your own custom render method that does 56 | this automatically: 57 | 58 | ```javascript 59 | import {render as rtlRender} from '@testing-library/react' 60 | // "rtl" is short for "react testing library" not "right-to-left" 😅 61 | 62 | function render(ui, options) { 63 | return rtlRender(ui, {wrapper: Wrapper, ...options}) 64 | } 65 | 66 | // then in your tests, you don't need to worry about context at all: 67 | const {rerender} = render() 68 | 69 | rerender() 70 | ``` 71 | 72 | From there, you can put that custom render function in your own module and use 73 | your custom render method instead of the built-in one from React Testing 74 | Library. Learn more about this from the docs: 75 | 76 | 📜 https://testing-library.com/docs/react-testing-library/setup 77 | 78 | ## Exercise 79 | 80 | In this exercise, we have an "Easy Button" that's styled differently based on 81 | the Theme context. Your job is to assert on the styles it has, but you first 82 | need to render the UI with the ThemeProvider (and set the `initialTheme` value). 83 | 84 | ## Extra Credit 85 | 86 | ### 1. 💯 add a test for the dark theme 87 | 88 | Should mostly be a copy/paste and change the `initialTheme` and assertion a bit. 89 | 90 | ### 2. 💯 create a custom render method 91 | 92 | The duplication is cramping my style. Create a custom render method that 93 | encapsulates this shared logic. It'll need to accept an option for the `theme` 94 | (dark or light). 95 | 96 | ### 3. 💯 swap @testing-library/react with app test utils 97 | 98 | We've actually already created a custom render method for this! So swap your 99 | `import` of `@testing-library/react` with `test/test-utils` which you can find 100 | in `./src/test/test-utils.js`. 101 | 102 | ## 🦉 Elaboration and Feedback 103 | 104 | After the instruction, if you want to remember what you've just learned, then 105 | fill out the elaboration and feedback form: 106 | 107 | https://ws.kcd.im/?ws=Testing%20React%20Applications%20%F0%9F%A7%90&e=07%3A%20testing%20with%20context%20and%20a%20custom%20render%20method&em= 108 | -------------------------------------------------------------------------------- /src/__tests__/exercise/08.js: -------------------------------------------------------------------------------- 1 | // testing custom hooks 2 | // http://localhost:3000/counter-hook 3 | 4 | import * as React from 'react' 5 | import {render, screen} from '@testing-library/react' 6 | import userEvent from '@testing-library/user-event' 7 | import useCounter from '../../components/use-counter' 8 | 9 | // 🐨 create a simple function component that uses the useCounter hook 10 | // and then exposes some UI that our test can interact with to test the 11 | // capabilities of this hook 12 | // 💰 here's how to use the hook: 13 | // const {count, increment, decrement} = useCounter() 14 | 15 | test('exposes the count and increment/decrement functions', () => { 16 | // 🐨 render the component 17 | // 🐨 get the elements you need using screen 18 | // 🐨 assert on the initial state of the hook 19 | // 🐨 interact with the UI using userEvent and assert on the changes in the UI 20 | }) 21 | 22 | /* eslint no-unused-vars:0 */ 23 | -------------------------------------------------------------------------------- /src/__tests__/exercise/08.md: -------------------------------------------------------------------------------- 1 | # testing custom hooks 2 | 3 | ## Background 4 | 5 | Testing custom hooks is a common question as well. Step back and think about how 6 | our guiding testing principle applies to this situation: the more your tests 7 | resemble the way your software is used, the more confidence they can give you. 8 | How is your custom hook used? It's used in a component! So that's how it should 9 | be tested. 10 | 11 | Often, the easiest and most straightforward way to test a custom hook is to 12 | create a component that uses it and then test that component instead. 13 | 14 | ## Exercise 15 | 16 | In this exercise, we have gone back to our simple counter, except now that logic 17 | is all in a custom hook and we need to test that functionality. To do that, 18 | we'll make a test component that uses the hook in the typical way that our hook 19 | will be used and then test that component, indirectly testing our hook. 20 | 21 | ## Extra Credit 22 | 23 | ### 1. 💯 fake component 24 | 25 | Sometimes it's hard to write a test component without making a pretty 26 | complicated "TestComponent." For those situations, you can try something like 27 | this: 28 | 29 | ```javascript 30 | let result 31 | function TestComponent(props) { 32 | result = useCustomHook(props) 33 | return null 34 | } 35 | 36 | // interact with and assert on results here 37 | ``` 38 | 39 | Learn more about this approach from my blog post: 40 | [How to test custom React hooks](https://kentcdodds.com/blog/how-to-test-custom-react-hooks) 41 | 42 | ### 2. 💯 setup function 43 | 44 | Add tests titled: 45 | 46 | 1. allows customization of the initial count 47 | 2. allows customization of the step 48 | 49 | And test those use cases. Then abstract away the common logic into a `setup` 50 | function. This one might be a little tricky thanks to variable references, but I 51 | know you can do it! 52 | 53 | 💰 Here's a little tip. Due to variable references, you'll need to change your 54 | test component a bit: 55 | 56 | ```javascript 57 | const results = {} 58 | function TestComponent(props) { 59 | Object.assign(results, useCustomHook()) 60 | return null 61 | } 62 | 63 | // interact with and assert on results here 64 | ``` 65 | 66 | ### 3. 💯 using react-hooks testing library 67 | 68 | Your `setup` function is very similar to the `renderHook` function from 69 | [`@testing-library/react`](https://github.com/testing-library/react-testing-library)! 70 | Swap your own `setup` function with that! 71 | 72 | > NOTE: Originally this exercise used `@testing-library/react-hooks` which was 73 | > similar, but that functionality was merged directly into 74 | > `@testing-library/react` so you're going to use that instead. 75 | 76 | ## 🦉 Elaboration and Feedback 77 | 78 | After the instruction, if you want to remember what you've just learned, then 79 | fill out the elaboration and feedback form: 80 | 81 | https://ws.kcd.im/?ws=Testing%20React%20Applications%20%F0%9F%A7%90&e=08%3A%20testing%20custom%20hooks&em= 82 | -------------------------------------------------------------------------------- /src/__tests__/final/01.extra-1.js: -------------------------------------------------------------------------------- 1 | // simple test with ReactDOM 2 | // 💯 use dispatchEvent 3 | // http://localhost:3000/counter 4 | 5 | import * as React from 'react' 6 | import {act} from 'react-dom/test-utils' 7 | import {createRoot} from 'react-dom/client' 8 | import Counter from '../../components/counter' 9 | 10 | // NOTE: this is a new requirement in React 18 11 | // https://react.dev/blog/2022/03/08/react-18-upgrade-guide#configuring-your-testing-environment 12 | // Luckily, it's handled for you by React Testing Library :) 13 | global.IS_REACT_ACT_ENVIRONMENT = true 14 | 15 | beforeEach(() => { 16 | document.body.innerHTML = '' 17 | }) 18 | 19 | test('counter increments and decrements when the buttons are clicked', () => { 20 | const div = document.createElement('div') 21 | document.body.append(div) 22 | 23 | const root = createRoot(div) 24 | act(() => root.render()) 25 | const [decrement, increment] = div.querySelectorAll('button') 26 | const message = div.firstChild.querySelector('div') 27 | 28 | expect(message.textContent).toBe('Current count: 0') 29 | const incrementClickEvent = new MouseEvent('click', { 30 | bubbles: true, 31 | cancelable: true, 32 | button: 0, 33 | }) 34 | act(() => increment.dispatchEvent(incrementClickEvent)) 35 | expect(message.textContent).toBe('Current count: 1') 36 | const decrementClickEvent = new MouseEvent('click', { 37 | bubbles: true, 38 | cancelable: true, 39 | button: 0, 40 | }) 41 | act(() => decrement.dispatchEvent(decrementClickEvent)) 42 | expect(message.textContent).toBe('Current count: 0') 43 | }) 44 | -------------------------------------------------------------------------------- /src/__tests__/final/01.js: -------------------------------------------------------------------------------- 1 | // simple test with ReactDOM 2 | // http://localhost:3000/counter 3 | 4 | import * as React from 'react' 5 | import {act} from 'react-dom/test-utils' 6 | import {createRoot} from 'react-dom/client' 7 | import Counter from '../../components/counter' 8 | 9 | // NOTE: this is a new requirement in React 18 10 | // https://react.dev/blog/2022/03/08/react-18-upgrade-guide#configuring-your-testing-environment 11 | // Luckily, it's handled for you by React Testing Library :) 12 | global.IS_REACT_ACT_ENVIRONMENT = true 13 | 14 | beforeEach(() => { 15 | document.body.innerHTML = '' 16 | }) 17 | 18 | test('counter increments and decrements when the buttons are clicked', () => { 19 | const div = document.createElement('div') 20 | document.body.append(div) 21 | 22 | const root = createRoot(div) 23 | act(() => root.render()) 24 | const [decrement, increment] = div.querySelectorAll('button') 25 | const message = div.firstChild.querySelector('div') 26 | 27 | expect(message.textContent).toBe('Current count: 0') 28 | act(() => increment.click()) 29 | expect(message.textContent).toBe('Current count: 1') 30 | act(() => decrement.click()) 31 | expect(message.textContent).toBe('Current count: 0') 32 | }) 33 | -------------------------------------------------------------------------------- /src/__tests__/final/02.extra-1.js: -------------------------------------------------------------------------------- 1 | // simple test with React Testing Library 2 | // 💯 use @testing-library/jest-dom 3 | // http://localhost:3000/counter 4 | 5 | import * as React from 'react' 6 | import {render, fireEvent} from '@testing-library/react' 7 | import Counter from '../../components/counter' 8 | 9 | test('counter increments and decrements when the buttons are clicked', () => { 10 | const {container} = render() 11 | const [decrement, increment] = container.querySelectorAll('button') 12 | const message = container.firstChild.querySelector('div') 13 | 14 | expect(message).toHaveTextContent('Current count: 0') 15 | fireEvent.click(increment) 16 | expect(message).toHaveTextContent('Current count: 1') 17 | fireEvent.click(decrement) 18 | expect(message).toHaveTextContent('Current count: 0') 19 | }) 20 | -------------------------------------------------------------------------------- /src/__tests__/final/02.js: -------------------------------------------------------------------------------- 1 | // simple test with React Testing Library 2 | // http://localhost:3000/counter 3 | 4 | import * as React from 'react' 5 | import {render, fireEvent} from '@testing-library/react' 6 | import Counter from '../../components/counter' 7 | 8 | test('counter increments and decrements when the buttons are clicked', () => { 9 | const {container} = render() 10 | const [decrement, increment] = container.querySelectorAll('button') 11 | const message = container.firstChild.querySelector('div') 12 | 13 | expect(message.textContent).toBe('Current count: 0') 14 | fireEvent.click(increment) 15 | expect(message.textContent).toBe('Current count: 1') 16 | fireEvent.click(decrement) 17 | expect(message.textContent).toBe('Current count: 0') 18 | }) 19 | -------------------------------------------------------------------------------- /src/__tests__/final/03.extra-1.js: -------------------------------------------------------------------------------- 1 | // Avoid implementation details 2 | // 💯 use userEvent 3 | // http://localhost:3000/counter 4 | 5 | import * as React from 'react' 6 | import {render, screen} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import Counter from '../../components/counter' 9 | 10 | test('counter increments and decrements when the buttons are clicked', async () => { 11 | render() 12 | const increment = screen.getByRole('button', {name: /increment/i}) 13 | const decrement = screen.getByRole('button', {name: /decrement/i}) 14 | const message = screen.getByText(/current count/i) 15 | 16 | expect(message).toHaveTextContent('Current count: 0') 17 | await userEvent.click(increment) 18 | expect(message).toHaveTextContent('Current count: 1') 19 | await userEvent.click(decrement) 20 | expect(message).toHaveTextContent('Current count: 0') 21 | }) 22 | -------------------------------------------------------------------------------- /src/__tests__/final/03.js: -------------------------------------------------------------------------------- 1 | // Avoid implementation details 2 | // http://localhost:3000/counter 3 | 4 | import * as React from 'react' 5 | import {render, screen, fireEvent} from '@testing-library/react' 6 | import Counter from '../../components/counter' 7 | 8 | test('counter increments and decrements when the buttons are clicked', () => { 9 | render() 10 | const increment = screen.getByRole('button', {name: /increment/i}) 11 | const decrement = screen.getByRole('button', {name: /decrement/i}) 12 | const message = screen.getByText(/current count/i) 13 | 14 | expect(message).toHaveTextContent('Current count: 0') 15 | fireEvent.click(increment) 16 | expect(message).toHaveTextContent('Current count: 1') 17 | fireEvent.click(decrement) 18 | expect(message).toHaveTextContent('Current count: 0') 19 | }) 20 | -------------------------------------------------------------------------------- /src/__tests__/final/04.extra-1.js: -------------------------------------------------------------------------------- 1 | // form testing 2 | // 💯 use a jest mock function 3 | // http://localhost:3000/login 4 | 5 | import * as React from 'react' 6 | import {render, screen} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import Login from '../../components/login' 9 | 10 | test('submitting the form calls onSubmit with username and password', async () => { 11 | const handleSubmit = jest.fn() 12 | render() 13 | const username = 'chucknorris' 14 | const password = 'i need no password' 15 | 16 | await userEvent.type(screen.getByLabelText(/username/i), username) 17 | await userEvent.type(screen.getByLabelText(/password/i), password) 18 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 19 | 20 | expect(handleSubmit).toHaveBeenCalledWith({ 21 | username, 22 | password, 23 | }) 24 | expect(handleSubmit).toHaveBeenCalledTimes(1) 25 | }) 26 | -------------------------------------------------------------------------------- /src/__tests__/final/04.extra-2.js: -------------------------------------------------------------------------------- 1 | // form testing 2 | // 💯 generate test data 3 | // http://localhost:3000/login 4 | 5 | import * as React from 'react' 6 | import {render, screen} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import faker from 'faker' 9 | import Login from '../../components/login' 10 | 11 | function buildLoginForm() { 12 | return { 13 | username: faker.internet.userName(), 14 | password: faker.internet.password(), 15 | } 16 | } 17 | 18 | test('submitting the form calls onSubmit with username and password', async () => { 19 | const handleSubmit = jest.fn() 20 | render() 21 | const {username, password} = buildLoginForm() 22 | 23 | await userEvent.type(screen.getByLabelText(/username/i), username) 24 | await userEvent.type(screen.getByLabelText(/password/i), password) 25 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 26 | 27 | expect(handleSubmit).toHaveBeenCalledWith({ 28 | username, 29 | password, 30 | }) 31 | expect(handleSubmit).toHaveBeenCalledTimes(1) 32 | }) 33 | -------------------------------------------------------------------------------- /src/__tests__/final/04.extra-3.js: -------------------------------------------------------------------------------- 1 | // form testing 2 | // 💯 allow for overrides 3 | // http://localhost:3000/login 4 | 5 | import * as React from 'react' 6 | import {render, screen} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import faker from 'faker' 9 | import Login from '../../components/login' 10 | 11 | function buildLoginForm(overrides) { 12 | return { 13 | username: faker.internet.userName(), 14 | password: faker.internet.password(), 15 | ...overrides, 16 | } 17 | } 18 | 19 | test('submitting the form calls onSubmit with username and password', async () => { 20 | const handleSubmit = jest.fn() 21 | render() 22 | const {username, password} = buildLoginForm() 23 | 24 | await userEvent.type(screen.getByLabelText(/username/i), username) 25 | await userEvent.type(screen.getByLabelText(/password/i), password) 26 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 27 | 28 | expect(handleSubmit).toHaveBeenCalledWith({ 29 | username, 30 | password, 31 | }) 32 | expect(handleSubmit).toHaveBeenCalledTimes(1) 33 | }) 34 | -------------------------------------------------------------------------------- /src/__tests__/final/04.extra-4.js: -------------------------------------------------------------------------------- 1 | // form testing 2 | // 💯 use Test Data Bot 3 | // http://localhost:3000/login 4 | 5 | import * as React from 'react' 6 | import {render, screen} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import {build, fake} from '@jackfranklin/test-data-bot' 9 | import Login from '../../components/login' 10 | 11 | const buildLoginForm = build({ 12 | fields: { 13 | username: fake(f => f.internet.userName()), 14 | password: fake(f => f.internet.password()), 15 | }, 16 | }) 17 | 18 | test('submitting the form calls onSubmit with username and password', async () => { 19 | const handleSubmit = jest.fn() 20 | render() 21 | const {username, password} = buildLoginForm() 22 | 23 | await userEvent.type(screen.getByLabelText(/username/i), username) 24 | await userEvent.type(screen.getByLabelText(/password/i), password) 25 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 26 | 27 | expect(handleSubmit).toHaveBeenCalledWith({ 28 | username, 29 | password, 30 | }) 31 | expect(handleSubmit).toHaveBeenCalledTimes(1) 32 | }) 33 | -------------------------------------------------------------------------------- /src/__tests__/final/04.js: -------------------------------------------------------------------------------- 1 | // form testing 2 | // http://localhost:3000/login 3 | 4 | import * as React from 'react' 5 | import {render, screen} from '@testing-library/react' 6 | import userEvent from '@testing-library/user-event' 7 | import Login from '../../components/login' 8 | 9 | test('submitting the form calls onSubmit with username and password', async () => { 10 | let submittedData 11 | const handleSubmit = data => (submittedData = data) 12 | render() 13 | const username = 'chucknorris' 14 | const password = 'i need no password' 15 | 16 | await userEvent.type(screen.getByLabelText(/username/i), username) 17 | await userEvent.type(screen.getByLabelText(/password/i), password) 18 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 19 | 20 | expect(submittedData).toEqual({ 21 | username, 22 | password, 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/__tests__/final/05.extra-1.js: -------------------------------------------------------------------------------- 1 | // mocking HTTP requests 2 | // 💯 reuse server request handlers 3 | // http://localhost:3000/login-submission 4 | 5 | import * as React from 'react' 6 | import {render, screen, waitForElementToBeRemoved} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import {build, fake} from '@jackfranklin/test-data-bot' 9 | import {setupServer} from 'msw/node' 10 | import {handlers} from 'test/server-handlers' 11 | import Login from '../../components/login-submission' 12 | 13 | const buildLoginForm = build({ 14 | fields: { 15 | username: fake(f => f.internet.userName()), 16 | password: fake(f => f.internet.password()), 17 | }, 18 | }) 19 | 20 | const server = setupServer(...handlers) 21 | 22 | beforeAll(() => server.listen()) 23 | afterAll(() => server.close()) 24 | 25 | test(`logging in displays the user's username`, async () => { 26 | render() 27 | const {username, password} = buildLoginForm() 28 | 29 | await userEvent.type(screen.getByLabelText(/username/i), username) 30 | await userEvent.type(screen.getByLabelText(/password/i), password) 31 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 32 | 33 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 34 | 35 | expect(screen.getByText(username)).toBeInTheDocument() 36 | }) 37 | -------------------------------------------------------------------------------- /src/__tests__/final/05.extra-2.js: -------------------------------------------------------------------------------- 1 | // mocking HTTP requests 2 | // 💯 test the unhappy path 3 | // http://localhost:3000/login-submission 4 | 5 | import * as React from 'react' 6 | import {render, screen, waitForElementToBeRemoved} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import {build, fake} from '@jackfranklin/test-data-bot' 9 | import {setupServer} from 'msw/node' 10 | import {handlers} from 'test/server-handlers' 11 | import Login from '../../components/login-submission' 12 | 13 | const buildLoginForm = build({ 14 | fields: { 15 | username: fake(f => f.internet.userName()), 16 | password: fake(f => f.internet.password()), 17 | }, 18 | }) 19 | 20 | const server = setupServer(...handlers) 21 | 22 | beforeAll(() => server.listen()) 23 | afterAll(() => server.close()) 24 | 25 | test(`logging in displays the user's username`, async () => { 26 | render() 27 | const {username, password} = buildLoginForm() 28 | 29 | await userEvent.type(screen.getByLabelText(/username/i), username) 30 | await userEvent.type(screen.getByLabelText(/password/i), password) 31 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 32 | 33 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 34 | 35 | expect(screen.getByText(username)).toBeInTheDocument() 36 | }) 37 | 38 | test('omitting the password results in an error', async () => { 39 | render() 40 | const {username} = buildLoginForm() 41 | 42 | await userEvent.type(screen.getByLabelText(/username/i), username) 43 | // don't type in the password 44 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 45 | 46 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 47 | 48 | expect(screen.getByRole('alert')).toHaveTextContent('password required') 49 | }) 50 | -------------------------------------------------------------------------------- /src/__tests__/final/05.extra-3.js: -------------------------------------------------------------------------------- 1 | // mocking HTTP requests 2 | // 💯 use inline snapshots for error messages 3 | // http://localhost:3000/login-submission 4 | 5 | import * as React from 'react' 6 | import {render, screen, waitForElementToBeRemoved} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import {build, fake} from '@jackfranklin/test-data-bot' 9 | import {setupServer} from 'msw/node' 10 | import {handlers} from 'test/server-handlers' 11 | import Login from '../../components/login-submission' 12 | 13 | const buildLoginForm = build({ 14 | fields: { 15 | username: fake(f => f.internet.userName()), 16 | password: fake(f => f.internet.password()), 17 | }, 18 | }) 19 | 20 | const server = setupServer(...handlers) 21 | 22 | beforeAll(() => server.listen()) 23 | afterAll(() => server.close()) 24 | 25 | test(`logging in displays the user's username`, async () => { 26 | render() 27 | const {username, password} = buildLoginForm() 28 | 29 | await userEvent.type(screen.getByLabelText(/username/i), username) 30 | await userEvent.type(screen.getByLabelText(/password/i), password) 31 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 32 | 33 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 34 | 35 | expect(screen.getByText(username)).toBeInTheDocument() 36 | }) 37 | 38 | test('omitting the password results in an error', async () => { 39 | render() 40 | const {username} = buildLoginForm() 41 | 42 | await userEvent.type(screen.getByLabelText(/username/i), username) 43 | // don't type in the password 44 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 45 | 46 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 47 | 48 | expect(screen.getByRole('alert').textContent).toMatchInlineSnapshot( 49 | `"password required"`, 50 | ) 51 | }) 52 | -------------------------------------------------------------------------------- /src/__tests__/final/05.extra-4.js: -------------------------------------------------------------------------------- 1 | // mocking HTTP requests 2 | // 💯 use one-off server handlers 3 | // http://localhost:3000/login-submission 4 | 5 | import * as React from 'react' 6 | import {render, screen, waitForElementToBeRemoved} from '@testing-library/react' 7 | import userEvent from '@testing-library/user-event' 8 | import {build, fake} from '@jackfranklin/test-data-bot' 9 | import {rest} from 'msw' 10 | import {setupServer} from 'msw/node' 11 | import {handlers} from 'test/server-handlers' 12 | import Login from '../../components/login-submission' 13 | 14 | const buildLoginForm = build({ 15 | fields: { 16 | username: fake(f => f.internet.userName()), 17 | password: fake(f => f.internet.password()), 18 | }, 19 | }) 20 | 21 | const server = setupServer(...handlers) 22 | 23 | beforeAll(() => server.listen()) 24 | afterAll(() => server.close()) 25 | afterEach(() => server.resetHandlers()) 26 | 27 | test(`logging in displays the user's username`, async () => { 28 | render() 29 | const {username, password} = buildLoginForm() 30 | 31 | await userEvent.type(screen.getByLabelText(/username/i), username) 32 | await userEvent.type(screen.getByLabelText(/password/i), password) 33 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 34 | 35 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 36 | 37 | expect(screen.getByText(username)).toBeInTheDocument() 38 | }) 39 | 40 | test('omitting the password results in an error', async () => { 41 | render() 42 | const {username} = buildLoginForm() 43 | 44 | await userEvent.type(screen.getByLabelText(/username/i), username) 45 | // don't type in the password 46 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 47 | 48 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 49 | 50 | expect(screen.getByRole('alert').textContent).toMatchInlineSnapshot( 51 | `"password required"`, 52 | ) 53 | }) 54 | 55 | test('unknown server error displays the error message', async () => { 56 | const testErrorMessage = 'Oh no, something bad happened' 57 | server.use( 58 | rest.post( 59 | 'https://auth-provider.example.com/api/login', 60 | async (req, res, ctx) => { 61 | return res(ctx.status(500), ctx.json({message: testErrorMessage})) 62 | }, 63 | ), 64 | ) 65 | render() 66 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 67 | 68 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 69 | 70 | expect(screen.getByRole('alert')).toHaveTextContent(testErrorMessage) 71 | }) 72 | -------------------------------------------------------------------------------- /src/__tests__/final/05.js: -------------------------------------------------------------------------------- 1 | // mocking HTTP requests 2 | // http://localhost:3000/login-submission 3 | 4 | import * as React from 'react' 5 | import {render, screen, waitForElementToBeRemoved} from '@testing-library/react' 6 | import userEvent from '@testing-library/user-event' 7 | import {build, fake} from '@jackfranklin/test-data-bot' 8 | import {rest} from 'msw' 9 | import {setupServer} from 'msw/node' 10 | import Login from '../../components/login-submission' 11 | 12 | const buildLoginForm = build({ 13 | fields: { 14 | username: fake(f => f.internet.userName()), 15 | password: fake(f => f.internet.password()), 16 | }, 17 | }) 18 | 19 | const server = setupServer( 20 | rest.post( 21 | 'https://auth-provider.example.com/api/login', 22 | async (req, res, ctx) => { 23 | if (!req.body.password) { 24 | return res(ctx.status(400), ctx.json({message: 'password required'})) 25 | } 26 | if (!req.body.username) { 27 | return res(ctx.status(400), ctx.json({message: 'username required'})) 28 | } 29 | return res(ctx.json({username: req.body.username})) 30 | }, 31 | ), 32 | ) 33 | 34 | beforeAll(() => server.listen()) 35 | afterAll(() => server.close()) 36 | 37 | test(`logging in displays the user's username`, async () => { 38 | render() 39 | const {username, password} = buildLoginForm() 40 | 41 | await userEvent.type(screen.getByLabelText(/username/i), username) 42 | await userEvent.type(screen.getByLabelText(/password/i), password) 43 | await userEvent.click(screen.getByRole('button', {name: /submit/i})) 44 | 45 | await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i)) 46 | 47 | expect(screen.getByText(username)).toBeInTheDocument() 48 | }) 49 | -------------------------------------------------------------------------------- /src/__tests__/final/06.extra-1.js: -------------------------------------------------------------------------------- 1 | // mocking Browser APIs and modules 2 | // 💯 mock the module 3 | // http://localhost:3000/location 4 | 5 | import * as React from 'react' 6 | import {render, screen, act} from '@testing-library/react' 7 | import {useCurrentPosition} from 'react-use-geolocation' 8 | import Location from '../../examples/location' 9 | 10 | jest.mock('react-use-geolocation') 11 | 12 | test('displays the users current location', async () => { 13 | const fakePosition = { 14 | coords: { 15 | latitude: 35, 16 | longitude: 139, 17 | }, 18 | } 19 | 20 | let setReturnValue 21 | function useMockCurrentPosition() { 22 | const state = React.useState([]) 23 | setReturnValue = state[1] 24 | return state[0] 25 | } 26 | useCurrentPosition.mockImplementation(useMockCurrentPosition) 27 | 28 | render() 29 | expect(screen.getByLabelText(/loading/i)).toBeInTheDocument() 30 | 31 | act(() => { 32 | setReturnValue([fakePosition]) 33 | }) 34 | 35 | expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument() 36 | expect(screen.getByText(/latitude/i)).toHaveTextContent( 37 | `Latitude: ${fakePosition.coords.latitude}`, 38 | ) 39 | expect(screen.getByText(/longitude/i)).toHaveTextContent( 40 | `Longitude: ${fakePosition.coords.longitude}`, 41 | ) 42 | }) 43 | -------------------------------------------------------------------------------- /src/__tests__/final/06.extra-2.js: -------------------------------------------------------------------------------- 1 | // mocking Browser APIs and modules 2 | // 💯 test the unhappy path 3 | // http://localhost:3000/location 4 | 5 | import React from 'react' 6 | import {render, screen, act} from '@testing-library/react' 7 | import Location from '../../examples/location' 8 | 9 | beforeAll(() => { 10 | window.navigator.geolocation = { 11 | getCurrentPosition: jest.fn(), 12 | } 13 | }) 14 | 15 | function deferred() { 16 | let resolve, reject 17 | const promise = new Promise((res, rej) => { 18 | resolve = res 19 | reject = rej 20 | }) 21 | return {promise, resolve, reject} 22 | } 23 | 24 | test('displays the users current location', async () => { 25 | const fakePosition = { 26 | coords: { 27 | latitude: 35, 28 | longitude: 139, 29 | }, 30 | } 31 | const {promise, resolve} = deferred() 32 | window.navigator.geolocation.getCurrentPosition.mockImplementation( 33 | callback => { 34 | promise.then(() => callback(fakePosition)) 35 | }, 36 | ) 37 | 38 | render() 39 | 40 | expect(screen.getByLabelText(/loading/i)).toBeInTheDocument() 41 | 42 | await act(async () => { 43 | resolve() 44 | await promise 45 | }) 46 | 47 | expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument() 48 | 49 | expect(screen.getByText(/latitude/i)).toHaveTextContent( 50 | `Latitude: ${fakePosition.coords.latitude}`, 51 | ) 52 | expect(screen.getByText(/longitude/i)).toHaveTextContent( 53 | `Longitude: ${fakePosition.coords.longitude}`, 54 | ) 55 | }) 56 | 57 | test('displays error message when geolocation is not supported', async () => { 58 | const fakeError = new Error( 59 | 'Geolocation is not supported or permission denied', 60 | ) 61 | const {promise, reject} = deferred() 62 | 63 | window.navigator.geolocation.getCurrentPosition.mockImplementation( 64 | (successCallback, errorCallback) => { 65 | promise.catch(() => errorCallback(fakeError)) 66 | }, 67 | ) 68 | 69 | render() 70 | 71 | expect(screen.getByLabelText(/loading/i)).toBeInTheDocument() 72 | 73 | await act(async () => { 74 | reject() 75 | }) 76 | 77 | expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument() 78 | 79 | expect(screen.getByRole('alert')).toHaveTextContent(fakeError.message) 80 | }) 81 | -------------------------------------------------------------------------------- /src/__tests__/final/06.js: -------------------------------------------------------------------------------- 1 | // mocking Browser APIs and modules 2 | // http://localhost:3000/location 3 | 4 | import * as React from 'react' 5 | import {render, screen, act} from '@testing-library/react' 6 | import Location from '../../examples/location' 7 | 8 | beforeAll(() => { 9 | window.navigator.geolocation = { 10 | getCurrentPosition: jest.fn(), 11 | } 12 | }) 13 | 14 | function deferred() { 15 | let resolve, reject 16 | const promise = new Promise((res, rej) => { 17 | resolve = res 18 | reject = rej 19 | }) 20 | return {promise, resolve, reject} 21 | } 22 | 23 | test('displays the users current location', async () => { 24 | const fakePosition = { 25 | coords: { 26 | latitude: 35, 27 | longitude: 139, 28 | }, 29 | } 30 | const {promise, resolve} = deferred() 31 | window.navigator.geolocation.getCurrentPosition.mockImplementation( 32 | callback => { 33 | promise.then(() => callback(fakePosition)) 34 | }, 35 | ) 36 | 37 | render() 38 | 39 | expect(screen.getByLabelText(/loading/i)).toBeInTheDocument() 40 | 41 | await act(async () => { 42 | resolve() 43 | await promise 44 | }) 45 | 46 | expect(screen.queryByLabelText(/loading/i)).not.toBeInTheDocument() 47 | 48 | expect(screen.getByText(/latitude/i)).toHaveTextContent( 49 | `Latitude: ${fakePosition.coords.latitude}`, 50 | ) 51 | expect(screen.getByText(/longitude/i)).toHaveTextContent( 52 | `Longitude: ${fakePosition.coords.longitude}`, 53 | ) 54 | }) 55 | -------------------------------------------------------------------------------- /src/__tests__/final/07.extra-1.js: -------------------------------------------------------------------------------- 1 | // testing with context and a custom render method 2 | // 💯 add a test for the dark theme 3 | // http://localhost:3000/easy-button 4 | 5 | import * as React from 'react' 6 | import {render, screen} from '@testing-library/react' 7 | import {ThemeProvider} from '../../components/theme' 8 | import EasyButton from '../../components/easy-button' 9 | 10 | test('renders with the light styles for the light theme', () => { 11 | const Wrapper = ({children}) => ( 12 | {children} 13 | ) 14 | render(Easy, {wrapper: Wrapper}) 15 | const button = screen.getByRole('button', {name: /easy/i}) 16 | expect(button).toHaveStyle(` 17 | background-color: white; 18 | color: black; 19 | `) 20 | }) 21 | 22 | test('renders with the dark styles for the dark theme', () => { 23 | const Wrapper = ({children}) => ( 24 | {children} 25 | ) 26 | render(Easy, {wrapper: Wrapper}) 27 | const button = screen.getByRole('button', {name: /easy/i}) 28 | expect(button).toHaveStyle(` 29 | background-color: black; 30 | color: white; 31 | `) 32 | }) 33 | -------------------------------------------------------------------------------- /src/__tests__/final/07.extra-2.js: -------------------------------------------------------------------------------- 1 | // testing with context and a custom render method 2 | // 💯 create a custom render method 3 | // http://localhost:3000/easy-button 4 | 5 | import * as React from 'react' 6 | import {render, screen} from '@testing-library/react' 7 | import {ThemeProvider} from '../../components/theme' 8 | import EasyButton from '../../components/easy-button' 9 | 10 | function renderWithProviders(ui, {theme = 'light', ...options} = {}) { 11 | const Wrapper = ({children}) => ( 12 | {children} 13 | ) 14 | return render(ui, {wrapper: Wrapper, ...options}) 15 | } 16 | 17 | test('renders with the light styles for the light theme', () => { 18 | renderWithProviders(Easy) 19 | const button = screen.getByRole('button', {name: /easy/i}) 20 | expect(button).toHaveStyle(` 21 | background-color: white; 22 | color: black; 23 | `) 24 | }) 25 | 26 | test('renders with the dark styles for the dark theme', () => { 27 | renderWithProviders(Easy, { 28 | theme: 'dark', 29 | }) 30 | const button = screen.getByRole('button', {name: /easy/i}) 31 | expect(button).toHaveStyle(` 32 | background-color: black; 33 | color: white; 34 | `) 35 | }) 36 | -------------------------------------------------------------------------------- /src/__tests__/final/07.extra-3.js: -------------------------------------------------------------------------------- 1 | // testing with context and a custom render method 2 | // 💯 swap @testing-library/react with app test utils 3 | // http://localhost:3000/easy-button 4 | 5 | import * as React from 'react' 6 | import {render, screen} from 'test/test-utils' 7 | import EasyButton from '../../components/easy-button' 8 | 9 | test('renders with the light styles for the light theme', () => { 10 | render(Easy, {theme: 'light'}) 11 | const button = screen.getByRole('button', {name: /easy/i}) 12 | expect(button).toHaveStyle(` 13 | background-color: white; 14 | color: black; 15 | `) 16 | }) 17 | 18 | test('renders with the dark styles for the dark theme', () => { 19 | render(Easy, {theme: 'dark'}) 20 | const button = screen.getByRole('button', {name: /easy/i}) 21 | expect(button).toHaveStyle(` 22 | background-color: black; 23 | color: white; 24 | `) 25 | }) 26 | -------------------------------------------------------------------------------- /src/__tests__/final/07.js: -------------------------------------------------------------------------------- 1 | // testing with context and a custom render method 2 | // http://localhost:3000/easy-button 3 | 4 | import * as React from 'react' 5 | import {render, screen} from '@testing-library/react' 6 | import {ThemeProvider} from '../../components/theme' 7 | import EasyButton from '../../components/easy-button' 8 | 9 | test('renders with the light styles for the light theme', () => { 10 | const Wrapper = ({children}) => ( 11 | {children} 12 | ) 13 | render(Easy, {wrapper: Wrapper}) 14 | const button = screen.getByRole('button', {name: /easy/i}) 15 | expect(button).toHaveStyle(` 16 | background-color: white; 17 | color: black; 18 | `) 19 | }) 20 | -------------------------------------------------------------------------------- /src/__tests__/final/08.extra-1.js: -------------------------------------------------------------------------------- 1 | // testing custom hooks 2 | // 💯 fake component 3 | // http://localhost:3000/counter-hook 4 | 5 | import * as React from 'react' 6 | import {render, act} from '@testing-library/react' 7 | import useCounter from '../../components/use-counter' 8 | 9 | test('exposes the count and increment/decrement functions', () => { 10 | let result 11 | function TestComponent() { 12 | result = useCounter() 13 | return null 14 | } 15 | render() 16 | expect(result.count).toBe(0) 17 | act(() => result.increment()) 18 | expect(result.count).toBe(1) 19 | act(() => result.decrement()) 20 | expect(result.count).toBe(0) 21 | }) 22 | -------------------------------------------------------------------------------- /src/__tests__/final/08.extra-2.js: -------------------------------------------------------------------------------- 1 | // testing custom hooks 2 | // 💯 setup function 3 | // http://localhost:3000/counter-hook 4 | 5 | import * as React from 'react' 6 | import {render, act} from '@testing-library/react' 7 | import useCounter from '../../components/use-counter' 8 | 9 | function setup({initialProps} = {}) { 10 | const result = {} 11 | function TestComponent() { 12 | result.current = useCounter(initialProps) 13 | return null 14 | } 15 | render() 16 | return result 17 | } 18 | 19 | test('exposes the count and increment/decrement functions', () => { 20 | const result = setup() 21 | expect(result.current.count).toBe(0) 22 | act(() => result.current.increment()) 23 | expect(result.current.count).toBe(1) 24 | act(() => result.current.decrement()) 25 | expect(result.current.count).toBe(0) 26 | }) 27 | 28 | test('allows customization of the initial count', () => { 29 | const result = setup({initialProps: {initialCount: 3}}) 30 | expect(result.current.count).toBe(3) 31 | }) 32 | 33 | test('allows customization of the step', () => { 34 | const result = setup({initialProps: {step: 2}}) 35 | expect(result.current.count).toBe(0) 36 | act(() => result.current.increment()) 37 | expect(result.current.count).toBe(2) 38 | act(() => result.current.decrement()) 39 | expect(result.current.count).toBe(0) 40 | }) 41 | -------------------------------------------------------------------------------- /src/__tests__/final/08.extra-3.js: -------------------------------------------------------------------------------- 1 | // testing custom hooks 2 | // 💯 using react-hooks testing library 3 | // http://localhost:3000/counter-hook 4 | 5 | import {renderHook, act} from '@testing-library/react' 6 | import useCounter from '../../components/use-counter' 7 | 8 | test('exposes the count and increment/decrement functions', () => { 9 | const {result} = renderHook(useCounter) 10 | expect(result.current.count).toBe(0) 11 | act(() => result.current.increment()) 12 | expect(result.current.count).toBe(1) 13 | act(() => result.current.decrement()) 14 | expect(result.current.count).toBe(0) 15 | }) 16 | 17 | test('allows customization of the initial count', () => { 18 | const {result} = renderHook(useCounter, {initialProps: {initialCount: 3}}) 19 | expect(result.current.count).toBe(3) 20 | }) 21 | 22 | test('allows customization of the step', () => { 23 | const {result} = renderHook(useCounter, {initialProps: {step: 2}}) 24 | expect(result.current.count).toBe(0) 25 | act(() => result.current.increment()) 26 | expect(result.current.count).toBe(2) 27 | act(() => result.current.decrement()) 28 | expect(result.current.count).toBe(0) 29 | }) 30 | 31 | test('the step can be changed', () => { 32 | const {result, rerender} = renderHook(useCounter, { 33 | initialProps: {step: 3}, 34 | }) 35 | expect(result.current.count).toBe(0) 36 | act(() => result.current.increment()) 37 | expect(result.current.count).toBe(3) 38 | rerender({step: 2}) 39 | act(() => result.current.decrement()) 40 | expect(result.current.count).toBe(1) 41 | }) 42 | -------------------------------------------------------------------------------- /src/__tests__/final/08.js: -------------------------------------------------------------------------------- 1 | // testing custom hooks 2 | // http://localhost:3000/counter-hook 3 | 4 | import * as React from 'react' 5 | import {render, screen} from '@testing-library/react' 6 | import userEvent from '@testing-library/user-event' 7 | import useCounter from '../../components/use-counter' 8 | 9 | function UseCounterHookExample() { 10 | const {count, increment, decrement} = useCounter() 11 | return ( 12 |
13 |
Current count: {count}
14 | 15 | 16 |
17 | ) 18 | } 19 | 20 | test('exposes the count and increment/decrement functions', async () => { 21 | render() 22 | const increment = screen.getByRole('button', {name: /increment/i}) 23 | const decrement = screen.getByRole('button', {name: /decrement/i}) 24 | const message = screen.getByText(/current count/i) 25 | 26 | expect(message).toHaveTextContent('Current count: 0') 27 | await userEvent.click(increment) 28 | expect(message).toHaveTextContent('Current count: 1') 29 | await userEvent.click(decrement) 30 | expect(message).toHaveTextContent('Current count: 0') 31 | }) 32 | -------------------------------------------------------------------------------- /src/components/counter.js: -------------------------------------------------------------------------------- 1 | // http://localhost:3000/counter 2 | 3 | import * as React from 'react' 4 | 5 | function Counter() { 6 | const [count, setCount] = React.useState(0) 7 | const increment = () => setCount(c => c + 1) 8 | const decrement = () => setCount(c => c - 1) 9 | return ( 10 |
11 |
Current count: {count}
12 | 13 | 14 |
15 | ) 16 | } 17 | 18 | export default Counter 19 | -------------------------------------------------------------------------------- /src/components/easy-button.js: -------------------------------------------------------------------------------- 1 | // http://localhost:3000/easy-button 2 | // NOTE: this component wont work by itself, so we have the example :) 3 | 4 | import * as React from 'react' 5 | import {useTheme} from './theme' 6 | 7 | const styles = { 8 | dark: { 9 | backgroundColor: 'black', 10 | color: 'white', 11 | }, 12 | light: { 13 | color: 'black', 14 | backgroundColor: 'white', 15 | }, 16 | } 17 | 18 | function EasyButton(props) { 19 | const [theme] = useTheme() 20 | return 29 | 30 | 31 | ) 32 | } 33 | 34 | export default Login 35 | -------------------------------------------------------------------------------- /src/components/spinner.js: -------------------------------------------------------------------------------- 1 | // http://localhost:3000/spinner 2 | 3 | import * as React from 'react' 4 | 5 | function Spinner() { 6 | return ( 7 |
8 |
9 |
10 |
11 | ) 12 | } 13 | 14 | export default Spinner 15 | -------------------------------------------------------------------------------- /src/components/theme.js: -------------------------------------------------------------------------------- 1 | // this one doesn't really make sense to render on its own, so don't bother. 2 | 3 | import * as React from 'react' 4 | 5 | const ThemeContext = React.createContext() 6 | 7 | function useTheme() { 8 | const context = React.useContext(ThemeContext) 9 | if (!context) { 10 | throw new Error('useTheme should be used within a ThemeProvider') 11 | } 12 | return context 13 | } 14 | 15 | function ThemeProvider({initialTheme = 'light', ...props}) { 16 | const [theme, setTheme] = React.useState(initialTheme) 17 | return 18 | } 19 | 20 | export {useTheme, ThemeProvider} 21 | -------------------------------------------------------------------------------- /src/components/use-counter.js: -------------------------------------------------------------------------------- 1 | // http://localhost:3000/counter-hook 2 | 3 | import * as React from 'react' 4 | 5 | function useCounter({initialCount = 0, step = 1} = {}) { 6 | const [count, setCount] = React.useState(initialCount) 7 | const increment = () => setCount(c => c + step) 8 | const decrement = () => setCount(c => c - step) 9 | return {count, increment, decrement} 10 | } 11 | 12 | export default useCounter 13 | -------------------------------------------------------------------------------- /src/examples/counter-hook.js: -------------------------------------------------------------------------------- 1 | // http://localhost:3000/counter-hook 2 | 3 | import * as React from 'react' 4 | import useCounter from '../components/use-counter' 5 | 6 | function Counter() { 7 | const {count, increment, decrement} = useCounter() 8 | return ( 9 |
10 |
Current count: {count}
11 | 12 | 13 |
14 | ) 15 | } 16 | 17 | export default Counter 18 | -------------------------------------------------------------------------------- /src/examples/counter.js: -------------------------------------------------------------------------------- 1 | import Counter from '../components/counter' 2 | export default Counter 3 | -------------------------------------------------------------------------------- /src/examples/easy-button.js: -------------------------------------------------------------------------------- 1 | // http://localhost:3000/easy-button 2 | 3 | import * as React from 'react' 4 | import EasyButton from '../components/easy-button' 5 | import {ThemeProvider, useTheme} from '../components/theme' 6 | 7 | function App() { 8 | return ( 9 | 10 |

Hit the easy button!

11 |
12 | alert('that was easy')}>Easy! 13 |
14 | 15 |
16 | ) 17 | } 18 | 19 | function ThemeToggler() { 20 | const [theme, setTheme] = useTheme() 21 | return ( 22 | 25 | ) 26 | } 27 | 28 | export default App 29 | -------------------------------------------------------------------------------- /src/examples/location.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {useCurrentPosition} from 'react-use-geolocation' 3 | import Spinner from '../components/spinner' 4 | 5 | function Location() { 6 | const [position, error] = useCurrentPosition() 7 | 8 | if (!position && !error) { 9 | return 10 | } 11 | 12 | if (error) { 13 | return ( 14 |
15 | {error.message} 16 |
17 | ) 18 | } 19 | 20 | return ( 21 |
22 |

Latitude: {position.coords.latitude}

23 |

Longitude: {position.coords.longitude}

24 |
25 | ) 26 | } 27 | 28 | export default Location 29 | -------------------------------------------------------------------------------- /src/examples/login-submission.js: -------------------------------------------------------------------------------- 1 | import LoginSubmission from '../components/login-submission' 2 | export default LoginSubmission 3 | -------------------------------------------------------------------------------- /src/examples/login.js: -------------------------------------------------------------------------------- 1 | import Login from '../components/login' 2 | export default Login 3 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './test/server' 2 | import './styles.css' 3 | 4 | import * as React from 'react' 5 | import ReactDOM from 'react-dom' 6 | import {ErrorBoundary} from 'react-error-boundary' 7 | import importAll from 'import-all.macro' 8 | 9 | const allDynamicImports = importAll.deferred('./examples/*.js') 10 | const lazyComponents = {} 11 | 12 | for (const modulePath in allDynamicImports) { 13 | if (allDynamicImports.hasOwnProperty(modulePath)) { 14 | lazyComponents[ 15 | modulePath.replace('./examples', '').replace(/.js$/, '') 16 | ] = React.lazy(allDynamicImports[modulePath]) 17 | } 18 | } 19 | 20 | function DefaultComponent() { 21 | return ( 22 |
23 |
Please go to the URL for one of the examples:
24 |
25 |
    26 | {Object.keys(lazyComponents).map(key => ( 27 |
  • 28 | {key} 29 |
  • 30 | ))} 31 |
32 |
33 |
34 | ) 35 | } 36 | 37 | function App() { 38 | const Component = React.useState(() => { 39 | if (window.location.pathname.length > 1) { 40 | return lazyComponents[window.location.pathname] 41 | } else { 42 | return DefaultComponent 43 | } 44 | })[0] 45 | return ( 46 |
56 | 57 | 58 |
59 | 60 |
61 |
62 |
63 |
64 | ) 65 | } 66 | 67 | ReactDOM.render(, document.getElementById('⚛')) 68 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* loading spinner from https://loading.io/css/ */ 2 | .lds-ripple { 3 | display: inline-block; 4 | position: relative; 5 | width: 64px; 6 | height: 64px; 7 | } 8 | .lds-ripple div { 9 | position: absolute; 10 | border: 4px solid #bad; 11 | opacity: 1; 12 | border-radius: 50%; 13 | animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite; 14 | } 15 | .lds-ripple div:nth-child(2) { 16 | animation-delay: -0.5s; 17 | } 18 | @keyframes lds-ripple { 19 | 0% { 20 | top: 28px; 21 | left: 28px; 22 | width: 0; 23 | height: 0; 24 | opacity: 1; 25 | } 26 | 100% { 27 | top: -1px; 28 | left: -1px; 29 | width: 58px; 30 | height: 58px; 31 | opacity: 0; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/server-handlers.js: -------------------------------------------------------------------------------- 1 | import {rest} from 'msw' 2 | 3 | const delay = process.env.NODE_ENV === 'test' ? 0 : 1500 4 | 5 | const handlers = [ 6 | rest.post( 7 | 'https://auth-provider.example.com/api/login', 8 | async (req, res, ctx) => { 9 | if (!req.body.password) { 10 | return res( 11 | ctx.delay(delay), 12 | ctx.status(400), 13 | ctx.json({message: 'password required'}), 14 | ) 15 | } 16 | if (!req.body.username) { 17 | return res( 18 | ctx.delay(delay), 19 | ctx.status(400), 20 | ctx.json({message: 'username required'}), 21 | ) 22 | } 23 | return res(ctx.delay(delay), ctx.json({username: req.body.username})) 24 | }, 25 | ), 26 | ] 27 | 28 | export {handlers} 29 | -------------------------------------------------------------------------------- /src/test/server.js: -------------------------------------------------------------------------------- 1 | import {setupWorker} from 'msw' 2 | import {handlers} from './server-handlers' 3 | import pkg from '../../package.json' 4 | 5 | const fullUrl = new URL(pkg.homepage) 6 | 7 | const server = setupWorker(...handlers) 8 | 9 | server.start({ 10 | quiet: true, 11 | serviceWorker: { 12 | url: fullUrl.pathname + 'mockServiceWorker.js', 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /src/test/test-utils.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render as rtlRender} from '@testing-library/react' 3 | import {ThemeProvider} from 'components/theme' 4 | 5 | function render(ui, {theme = 'light', ...options} = {}) { 6 | const Wrapper = ({children}) => ( 7 | {children} 8 | ) 9 | return rtlRender(ui, {wrapper: Wrapper, ...options}) 10 | } 11 | 12 | export * from '@testing-library/react' 13 | // override React Testing Library's render with our own 14 | export {render} 15 | --------------------------------------------------------------------------------