├── .all-contributorsrc ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── LIVE_INSTRUCTIONS.md ├── README.md ├── dev.js ├── index.js ├── jest.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── sandbox.config.json ├── scripts ├── autofill-feedback-email.js ├── codesandbox-page.js └── setup.js ├── setup.js ├── src ├── __tests__ │ ├── auth.exercise.js │ ├── auth.final.extra-1.js │ ├── auth.final.extra-2.js │ ├── auth.final.extra-3.js │ ├── auth.final.extra-4.js │ ├── auth.final.js │ ├── auth.md │ ├── list-items.exercise.js │ ├── list-items.final.extra-1.js │ ├── list-items.final.js │ └── list-items.md ├── db │ ├── README.md │ ├── books.js │ ├── list-items.js │ ├── users.js │ └── utils.js ├── index.js ├── routes │ ├── __tests__ │ │ ├── list-items-controller.exercise.js │ │ ├── list-items-controller.final.extra-1.js │ │ ├── list-items-controller.final.extra-2.js │ │ ├── list-items-controller.final.js │ │ └── list-items-controller.md │ ├── auth-controller.js │ ├── auth.js │ ├── index.js │ ├── list-items-controller.js │ └── list-items.js ├── start.js └── utils │ ├── __tests__ │ ├── auth.exercise.js │ ├── auth.final.extra-1.js │ ├── auth.final.extra-2.js │ ├── auth.final.extra-3.js │ ├── auth.final.js │ ├── auth.md │ ├── error-middleware.exercise.js │ ├── error-middleware.final.extra-1.js │ ├── error-middleware.final.extra-2.js │ ├── error-middleware.final.js │ └── error-middleware.md │ ├── auth.js │ └── error-middleware.js └── test ├── jest.config.exercises.js ├── jest.config.final.js ├── jest.config.projects.js ├── setup-env.js └── utils ├── async.js ├── db-utils.js └── generate.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "testing-node-apps", 3 | "projectOwner": "kentcdodds", 4 | "repoType": "github", 5 | "files": [ 6 | "README.md" 7 | ], 8 | "imageSize": 100, 9 | "commit": false, 10 | "repoHost": "https://github.com", 11 | "contributorsPerLine": 7, 12 | "contributors": [ 13 | { 14 | "login": "kentcdodds", 15 | "name": "Kent C. Dodds", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=3", 17 | "profile": "https://kentcdodds.com", 18 | "contributions": [ 19 | "code", 20 | "doc", 21 | "infra", 22 | "test" 23 | ] 24 | }, 25 | { 26 | "login": "jdorfman", 27 | "name": "Justin Dorfman", 28 | "avatar_url": "https://avatars1.githubusercontent.com/u/398230?v=4", 29 | "profile": "https://stackshare.io/jdorfman/decisions", 30 | "contributions": [ 31 | "fundingFinding" 32 | ] 33 | }, 34 | { 35 | "login": "andrewmcodes", 36 | "name": "Andrew Mason", 37 | "avatar_url": "https://avatars1.githubusercontent.com/u/18423853?v=4", 38 | "profile": "https://www.andrewm.codes", 39 | "contributions": [ 40 | "doc" 41 | ] 42 | } 43 | ], 44 | "skipCi": true 45 | } 46 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | build 4 | scripts/setup.js 5 | dist 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // TODO: figure out why import/no-unresolved doesn't work on windows... 2 | const isWindows = 3 | process.platform === 'win32' || /^(msys|cygwin)$/.test(process.env.OSTYPE) 4 | 5 | module.exports = { 6 | extends: ['kentcdodds', 'kentcdodds/jest'], 7 | rules: { 8 | 'no-console': 'off', 9 | 'import/no-cycle': 'off', 10 | 'import/no-extraneous-dependencies': 'off', 11 | 'babel/new-cap': 'off', 12 | 'require-await': 'warn', 13 | 'import/no-unresolved': isWindows ? 'off' : 'error', 14 | }, 15 | overrides: [ 16 | { 17 | files: ['**/__tests__/**'], 18 | settings: { 19 | 'import/resolver': { 20 | jest: { 21 | jestConfigFile: require.resolve('./test/jest.config.projects.js'), 22 | }, 23 | }, 24 | }, 25 | }, 26 | { 27 | files: ['**/__tests__/*.exercise.*'], 28 | rules: { 29 | 'jest/prefer-todo': 'off', 30 | }, 31 | }, 32 | ], 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: validate 2 | on: [push, pull_request] 3 | jobs: 4 | setup: 5 | strategy: 6 | matrix: 7 | os: [ubuntu-latest, windows-latest, macos-latest] 8 | node: [12, 14] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - name: ⬇️ Checkout repo 12 | uses: actions/checkout@v2 13 | 14 | - name: ⎔ Setup node 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node }} 18 | 19 | - name: ▶️ Run setup script 20 | run: npm run setup 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | coverage-exercises 5 | dist/ 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | build 4 | scripts/workshop-setup.js 5 | src/babel.js 6 | src/react-dom.development.js 7 | src/react.development.js 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": false, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always" 11 | } 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please refer to [kentcdodds.com/conduct/](https://kentcdodds.com/conduct/) 2 | -------------------------------------------------------------------------------- /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 `master` branch pointing at the original repository and make 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ``` 18 | > git remote add upstream https://github.com/kentcdodds/testing-node-apps.git 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/master master 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 `master` 25 | > branch to use the upstream master branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `master` branch. 27 | > Whenever you want to update your version of `master`, 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://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 44 | [issues]: https://github.com/kentcdodds/testing-node-apps/issues 45 | -------------------------------------------------------------------------------- /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 | [TestingJavaScript.com](https://testingjavascript.com). This document explains a 23 | few things you'll need to know if you're attending a live version of this 24 | workshop. 25 | 26 | ## Pre-Workshop Instructions/Requirements 27 | 28 | Please watch the 29 | [Setup and Logistics for KCD Workshops](https://egghead.io/lessons/egghead-setup-and-logistics-for-kcd-workshops) 30 | (~24 minutes). If you follow along with this repo, you should be all set up by 31 | the end of it and you'll be ready to go. 32 | 33 | **NOTE: I will assume you know how to work through the exercises.** There will 34 | be _no time_ given for troubleshooting setup issues or answering questions about 35 | exercise logistics. 36 | 37 | Here are the basic things you need to make sure you do: 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 | - [ ] 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 | - 🏋 Testing Pure Functions 57 | - 😴 10 Minutes 58 | - 🏋 Testing Middleware 59 | - 🌮 30 Minutes 60 | - 🏋 Testing Controllers 61 | - 😴 10 Minutes 62 | - 🏋 Testing Authentication API Routes 63 | - 😴 10 Minutes 64 | - 🏋 Testing CRUD API Routes 65 | - ❓ Q&A 66 | 67 | ### Questions 68 | 69 | Please do ask! **Interrupt me.** If you have an unrelated question, please save 70 | them for [my office hours](https://kcd.im/office-hours). 71 | 72 | ### For remote workshops: 73 | 74 | - Help us make this more human by keeping your video on if possible 75 | - Keep microphone muted unless speaking 76 | - Make the most of breakout rooms during exercises 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Testing Node.js Backends 3 |

4 | 5 | 👋 hi there! My name is [Kent C. Dodds](https://kentcdodds.com)! This is a 6 | workshop repo to teach you how to test your Node.js Apps! 7 | 8 |
9 |

TestingJavaScript.com

10 | 11 | Learn the smart, efficient way to test any JavaScript application. 16 | 17 |
18 | 19 |
20 | 21 | [![Build Status][build-badge]][build] 22 | [![Code Coverage][coverage-badge]][coverage] 23 | [![GPL 3.0 License][license-badge]][license] 24 | [![All Contributors][all-contributors-badge]](#contributors) 25 | [![PRs Welcome][prs-badge]][prs] [![Code of Conduct][coc-badge]][coc] 26 | 27 | ## Prerequisites 28 | 29 | - Have fundamental understanding and experience with automated testing and 30 | tools. (Additional learning material: 31 | [But really, what is a JavaScript test?](https://kentcdodds.com/blog/but-really-what-is-a-javascript-test) 32 | and 33 | [But really, what is a JavaScript mock?](https://kentcdodds.com/blog/but-really-what-is-a-javascript-mock)) 34 | - Have experience with modern JavaScript APIs and features. 35 | 36 | ## System Requirements 37 | 38 | - [git][git] v2 or greater 39 | - [NodeJS][node] v12 or greater 40 | - [yarn][yarn] v1 or greater (or [npm][npm] v6 or greater) 41 | 42 | All of these must be available in your `PATH`. To verify things are set up 43 | properly, you can run this: 44 | 45 | ```shell 46 | git --version 47 | node --version 48 | yarn --version # or npm --version 49 | ``` 50 | 51 | If you have trouble with any of these, learn more about the PATH environment 52 | variable and how to fix it here for [windows][win-path] or 53 | [mac/linux][mac-path]. 54 | 55 | ## Setup 56 | 57 | > If you want to commit and push your work as you go, you'll want to 58 | > [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) 59 | > first and then clone your fork rather than this repo directly. 60 | 61 | After you've made sure to have the correct things (and versions) installed, you 62 | should be able to just run a few commands to get set up: 63 | 64 | ``` 65 | git clone https://github.com/kentcdodds/testing-node-apps.git 66 | cd testing-node-apps 67 | node setup 68 | ``` 69 | 70 | This may take a few minutes. **It will ask you for your email.** This is 71 | optional and just automatically adds your email to the links in the project to 72 | make filling out some forms easier. 73 | 74 | If you get any errors, please read through them and see if you can find out what 75 | the problem is. If you can't work it out on your own then please [file an 76 | issue][issue] and provide _all_ the output from the commands you ran (even if 77 | it's a lot). 78 | 79 | If you can't get the setup script to work, then just make sure you have the 80 | right versions of the requirements listed above, and run the following commands: 81 | 82 | ``` 83 | npm install 84 | npm run validate 85 | ``` 86 | 87 | It's recommended you run everything locally in the same environment you work in 88 | every day, but if you're having issues getting things set up, you can also set 89 | this up using [GitHub Codespaces](https://github.com/features/codespaces) 90 | ([video demo](https://www.youtube.com/watch?v=gCoVJm3hGk4)) or 91 | [Codesandbox](https://codesandbox.io/s/github/kentcdodds/testing-node-apps). 92 | 93 | ## App Intro 94 | 95 | ### App Demo 96 | 97 | This is the backend for [Bookshelf](https://bookshelf.lol). I recommend you play 98 | around with it a little bit to get an idea of the kind of data we're dealing 99 | with here. 100 | 101 | ### Data Model 102 | 103 | - User 104 | 105 | - id: string 106 | - username: string 107 | 108 | - List Item 109 | 110 | - id: string 111 | - bookId: string 112 | - ownerId: string 113 | - rating: number (-1 is no rating, otherwise it's 1-5) 114 | - notes: string 115 | - startDate: number (`Date.now()`) 116 | - finishDate: number (`Date.now()`) 117 | 118 | > For convenience, our we return a `book` object on each list item which is the 119 | > book it's associated to. You're welcome frontend folks! 120 | 121 | > /me wishes we could use GraphQL 122 | 123 | - Book 124 | 125 | - id: string 126 | - title: string 127 | - author: string 128 | - coverImageUrl: string 129 | - pageCount: number 130 | - publisher: string 131 | - synopsis: string 132 | 133 | ## Running the tests 134 | 135 | ```shell 136 | npm test 137 | ``` 138 | 139 | This will start [Jest](https://jestjs.io/) in watch mode. Read the output and 140 | play around with it. You'll be working in the `.exercise` files. 141 | 142 | ### Exercises 143 | 144 | - `src/**/__tests__/[title].md`: Background, Exercise Instructions, Extra Credit 145 | - `src/**/__tests__/[title].exercise.js`: Exercise with Emoji helpers 146 | - `src/**/__tests__/[title].final.js`: Final version 147 | - `src/**/__tests__/[title].final.extra-#.js`: Final version of extra credit 148 | - `src/**/[title].js`: The source file that you'll be testing 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 | Here's the order of exercises we'll be doing as well as where you can find the 155 | markdown file associated with each. 156 | 157 | 1. 🏋 Testing Pure Functions: `src/utils/__tests__/auth.md` 158 | 2. 🏋 Testing Middleware: `src/utils/__tests__/error-middleware.md` 159 | 3. 🏋 Testing Controllers: `src/routes/__tests__/list-items-controller.md` 160 | 4. 🏋 Testing Authentication API Routes: `src/__tests__/auth.md` 161 | 5. 🏋 Testing CRUD API Routes: `src/__tests__/list-items.md` 162 | 163 | ### Helpful Emoji 🐨 💪 🏁 💰 💯 🦉 📜 💣 👨‍💼 🚨 164 | 165 | Each exercise has comments in it to help you get through the exercise. These fun 166 | emoji characters are here to help you. 167 | 168 | - **Kody the Koala** 🐨 will tell you when there's something specific you should 169 | do 170 | - **Matthew the Muscle** 💪 will indicate what you're working with an exercise 171 | - **Chuck the Checkered Flag** 🏁 will indicate that you're working with a final 172 | version 173 | - **Marty the Money Bag** 💰 will give you specific tips (and sometimes code) 174 | along the way 175 | - **Hannah the Hundred** 💯 will give you extra challenges you can do if you 176 | finish the exercises early. 177 | - **Olivia the Owl** 🦉 will give you useful tidbits/best practice notes and a 178 | link for elaboration and feedback. 179 | - **Dominic the Document** 📜 will give you links to useful documentation 180 | - **Berry the Bomb** 💣 will be hanging around anywhere you need to blow stuff 181 | up (delete code) 182 | - **Peter the Product Manager** 👨‍💼 helps us know what our users want 183 | - **Alfred the Alert** 🚨 will occasionally show up in the test failures with 184 | potential explanations for why the tests are failing. 185 | 186 | ## Troubleshooting 187 | 188 |
189 | 190 | "node setup" not working 191 | 192 | If you're confident that your system meets the system requirements above, then 193 | you can skip the system validation and manually setup the project: 194 | 195 | ``` 196 | npm install 197 | npm run validate 198 | ``` 199 | 200 | If those scripts fail, please try to work out what went wrong by the error 201 | message you get. If you still can't work it out, feel free to [open an 202 | issue][issue] with _all_ the output from that script. I will try to help if I 203 | can. 204 | 205 |
206 | 207 | ## Contributors 208 | 209 | Thanks goes to these wonderful people 210 | ([emoji key](https://github.com/all-contributors/all-contributors#emoji-key)): 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 |

Kent C. Dodds

💻 📖 🚇 ⚠️

Justin Dorfman

🔍

Andrew Mason

📖
222 | 223 | 224 | 225 | 226 | 227 | 228 | This project follows the 229 | [all-contributors](https://github.com/all-contributors/all-contributors) 230 | specification. Contributions of any kind welcome! 231 | 232 | ## License 233 | 234 | This material is available for private, non-commercial use under the 235 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you 236 | would like to use this material to conduct your own workshop, please contact me 237 | at me@kentcdodds.com 238 | 239 | ## Workshop Feedback 240 | 241 | Each exercise has an Elaboration and Feedback link. Please fill that out after 242 | the exercise and instruction. 243 | 244 | At the end of the workshop, please go to this URL to give overall feedback. 245 | Thank you! https://kcd.im/tna-ws-feedback 246 | 247 | 248 | 249 | [npm]: https://www.npmjs.com/ 250 | [node]: https://nodejs.org 251 | [git]: https://git-scm.com/ 252 | [yarn]: https://yarnpkg.com/ 253 | [build-badge]: https://img.shields.io/github/workflow/status/kentcdodds/testing-node-apps/validate/main?logo=github&style=flat-square 254 | [build]: https://github.com/kentcdodds/testing-node-apps/actions?query=workflow%3Avalidate 255 | [license-badge]: https://img.shields.io/badge/license-GPL%203.0%20License-blue.svg?style=flat-square 256 | [license]: https://github.com/kentcdodds/testing-node-apps/blob/master/README.md#license 257 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 258 | [prs]: http://makeapullrequest.com 259 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 260 | [coc]: https://github.com/kentcdodds/testing-node-apps/blob/master/CODE_OF_CONDUCT.md 261 | [github-watch-badge]: https://img.shields.io/github/watchers/kentcdodds/testing-node-apps.svg?style=social 262 | [github-watch]: https://github.com/kentcdodds/testing-node-apps/watchers 263 | [github-star-badge]: https://img.shields.io/github/stars/kentcdodds/testing-node-apps.svg?style=social 264 | [github-star]: https://github.com/kentcdodds/testing-node-apps/stargazers 265 | [twitter]: https://twitter.com/intent/tweet?text=Check%20out%20testing-node-apps%20by%20@kentcdodds%20https://github.com/kentcdodds/testing-node-apps%20%F0%9F%91%8D 266 | [twitter-badge]: https://img.shields.io/twitter/url/https/github.com/kentcdodds/testing-node-apps.svg?style=social 267 | [emojis]: https://github.com/all-contributors/all-contributors#emoji-key 268 | [all-contributors]: https://github.com/all-contributors/all-contributors 269 | [all-contributors-badge]: https://img.shields.io/github/all-contributors/kentcdodds/testing-node-apps?color=orange&style=flat-square 270 | [win-path]: https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/ 271 | [mac-path]: http://stackoverflow.com/a/24322978/971592 272 | [issue]: https://github.com/kentcdodds/testing-node-apps/issues/new 273 | [win-build-badge]: https://img.shields.io/appveyor/ci/kentcdodds/testing-node-apps.svg?style=flat-square&logo=appveyor 274 | [win-build]: https://ci.appveyor.com/project/kentcdodds/testing-node-apps 275 | [coverage-badge]: https://img.shields.io/codecov/c/github/kentcdodds/testing-node-apps.svg?style=flat-square 276 | [coverage]: https://codecov.io/github/kentcdodds/testing-node-apps 277 | [watchman]: https://facebook.github.io/watchman/docs/install.html 278 | 279 | 280 | -------------------------------------------------------------------------------- /dev.js: -------------------------------------------------------------------------------- 1 | ;(async () => { 2 | require('@babel/register') 3 | const dbUtils = require('./test/utils/db-utils') 4 | await dbUtils.initDb() 5 | await dbUtils.insertTestUser() 6 | require('./src') 7 | })() 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV === 'production') { 2 | require('./dist') 3 | } else { 4 | require('nodemon')({script: 'dev.js'}) 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./test/jest.config.projects') 2 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "*": ["test/*", "src/*"] 6 | } 7 | }, 8 | "include": ["src", "test"] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testing-node-apps", 3 | "version": "1.0.0", 4 | "description": "The material for learning Testing Node.js Apps", 5 | "keywords": [], 6 | "homepage": "https://github.com/kentcdodds/testing-node-apps", 7 | "license": "GPL-3.0-only", 8 | "engines": { 9 | "node": ">=12", 10 | "npm": ">=6" 11 | }, 12 | "dependencies": { 13 | "@babel/core": "^7.12.9", 14 | "@babel/preset-env": "^7.12.7", 15 | "axios": "^0.21.0", 16 | "body-parser": "^1.19.0", 17 | "cors": "^2.8.5", 18 | "cpy": "^8.1.1", 19 | "express": "^4.17.1", 20 | "express-async-errors": "^3.1.1", 21 | "express-jwt": "^6.0.0", 22 | "jsonwebtoken": "^8.5.1", 23 | "lodash": "^4.17.20", 24 | "loglevel": "^1.7.1", 25 | "nodemon": "^2.0.6", 26 | "passport": "^0.4.1", 27 | "passport-local": "^1.0.0" 28 | }, 29 | "devDependencies": { 30 | "@babel/cli": "^7.12.8", 31 | "@babel/register": "^7.12.1", 32 | "cross-spawn": "^7.0.3", 33 | "eslint": "^7.14.0", 34 | "eslint-config-kentcdodds": "^17.3.0", 35 | "eslint-import-resolver-jest": "^3.0.0", 36 | "faker": "^5.1.0", 37 | "husky": "^4.3.0", 38 | "inquirer": "^7.3.3", 39 | "is-ci": "^2.0.0", 40 | "is-ci-cli": "^2.1.2", 41 | "jest": "^26.6.3", 42 | "jest-in-case": "^1.0.2", 43 | "jest-watch-select-projects": "^2.0.0", 44 | "jest-watch-typeahead": "^0.6.1", 45 | "npm-run-all": "^4.1.5", 46 | "prettier": "^2.2.1", 47 | "replace-in-file": "^6.1.0" 48 | }, 49 | "scripts": { 50 | "build": "babel --delete-dir-on-start --out-dir dist --copy-files --ignore \"**/__tests__/**,**/__mocks__/**\" --no-copy-ignored src", 51 | "start": "node ./scripts/codesandbox-page.js", 52 | "test": "is-ci \"test:final:coverage\" \"test:exercise:watch\"", 53 | "test:exercise": "jest --config test/jest.config.exercises.js", 54 | "test:exercise:watch": "npm run test:exercise -- --watchAll", 55 | "test:exercise:coverage": "npm run test:exercise -- --coverage", 56 | "test:final": "jest --config test/jest.config.final.js", 57 | "test:final:watch": "npm run test:final -- --watchAll", 58 | "test:final:watch:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --config test/jest.config.final.js --watchAll --runInBand", 59 | "test:final:coverage": "npm run test:final -- --coverage", 60 | "format": "prettier --write \"**/*.+(js|json|css|md|mdx|html)\"", 61 | "lint": "eslint .", 62 | "setup": "node setup", 63 | "validate": "npm-run-all --parallel test:final:coverage lint" 64 | }, 65 | "husky": { 66 | "hooks": { 67 | "pre-commit": "npm run validate" 68 | } 69 | }, 70 | "babel": { 71 | "presets": [ 72 | [ 73 | "@babel/preset-env", 74 | { 75 | "targets": { 76 | "node": "current" 77 | } 78 | } 79 | ] 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "node" 3 | } 4 | -------------------------------------------------------------------------------- /scripts/autofill-feedback-email.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | const path = require('path') 3 | const inquirer = require('inquirer') 4 | const replace = require('replace-in-file') 5 | const isCI = require('is-ci') 6 | const spawn = require('cross-spawn') 7 | 8 | if (isCI) { 9 | console.log(`Not running autofill feedback as we're on CI`) 10 | } else { 11 | const prompt = inquirer.prompt([ 12 | { 13 | name: 'email', 14 | message: `what's your email address?`, 15 | validate(val) { 16 | if (!val) { 17 | // they don't want to do this... 18 | return true 19 | } else if (!val.includes('@')) { 20 | return 'email requires an @ sign...' 21 | } 22 | return true 23 | }, 24 | }, 25 | ]) 26 | const timeoutId = setTimeout(() => { 27 | console.log( 28 | `\n\nprompt timed out. No worries. Run \`node ./scripts/autofill-feedback-email.js\` if you'd like to try again`, 29 | ) 30 | prompt.ui.close() 31 | }, 15000) 32 | 33 | prompt.then(({email} = {}) => { 34 | clearTimeout(timeoutId) 35 | if (!email) { 36 | console.log(`Not autofilling email because none was provided`) 37 | return 38 | } 39 | const options = { 40 | files: [path.join(__dirname, '..', 'src/**/*.js')], 41 | from: /&em=\r?\n/, 42 | to: `&em=${email}\n`, 43 | } 44 | 45 | replace(options).then( 46 | changedFiles => { 47 | console.log(`Updated ${changedFiles.length} with the email ${email}`) 48 | console.log( 49 | 'committing changes for you so your jest watch mode works nicely', 50 | ) 51 | spawn.sync('git', ['commit', '-am', 'email autofill', '--no-verify'], { 52 | stdio: 'inherit', 53 | }) 54 | }, 55 | error => { 56 | console.error('Failed to update files') 57 | console.error(error.stack) 58 | }, 59 | ) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /scripts/codesandbox-page.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | 3 | const message = 'Go ahead and ignore this. Open the terminal and run "npm test"' 4 | 5 | console.log(message) 6 | 7 | http 8 | .createServer((req, res) => { 9 | res.write(message) 10 | res.end() 11 | }) 12 | .listen(8080) 13 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | var spawnSync = require('child_process').spawnSync 2 | 3 | var styles = { 4 | blue: {open: '\u001b[34m', close: '\u001b[39m'}, 5 | dim: {open: '\u001b[2m', close: '\u001b[22m'}, 6 | red: {open: '\u001b[31m', close: '\u001b[39m'}, 7 | green: {open: '\u001b[32m', close: '\u001b[39m'}, 8 | } 9 | 10 | function color(modifier, string) { 11 | return styles[modifier].open + string + styles[modifier].close 12 | } 13 | 14 | console.log(color('blue', '▶️ Starting workshop setup...')) 15 | 16 | var error = spawnSync('npx --version', {shell: true}) 17 | .stderr.toString() 18 | .trim() 19 | if (error) { 20 | console.error( 21 | color( 22 | 'red', 23 | '🚨 npx is not available on this computer. Please install npm@5.2.0 or greater', 24 | ), 25 | ) 26 | throw error 27 | } 28 | 29 | var command = 30 | 'npx "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q' 31 | console.log(color('dim', ' Running the following command: ' + command)) 32 | 33 | var result = spawnSync(command, {stdio: 'inherit', shell: true}) 34 | 35 | if (result.status === 0) { 36 | console.log(color('green', '✅ Workshop setup complete...')) 37 | } else { 38 | process.exit(result.status) 39 | } 40 | 41 | /* 42 | eslint 43 | no-var: "off", 44 | "vars-on-top": "off", 45 | */ 46 | -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | require('./scripts/setup') 2 | -------------------------------------------------------------------------------- /src/__tests__/auth.exercise.js: -------------------------------------------------------------------------------- 1 | // Testing Authentication API Routes 2 | 3 | // 🐨 import the things you'll need 4 | // 💰 here, I'll just give them to you. You're welcome 5 | // import axios from 'axios' 6 | // import {resetDb} from 'utils/db-utils' 7 | // import * as generate from 'utils/generate' 8 | // import startServer from '../start' 9 | 10 | // 🐨 you'll need to start/stop the server using beforeAll and afterAll 11 | // 💰 This might be helpful: server = await startServer({port: 8000}) 12 | 13 | // 🐨 beforeEach test in this file we want to reset the database 14 | 15 | test('auth flow', async () => { 16 | // 🐨 get a username and password from generate.loginForm() 17 | // 18 | // register 19 | // 🐨 use axios.post to post the username and password to the registration endpoint 20 | // 💰 http://localhost:8000/api/auth/register 21 | // 22 | // 🐨 assert that the result you get back is correct 23 | // 💰 it'll have an id and a token that will be random every time. 24 | // You can either only check that `result.data.user.username` is correct, or 25 | // for a little extra credit 💯 you can try using `expect.any(String)` 26 | // (an asymmetric matcher) with toEqual. 27 | // 📜 https://jestjs.io/docs/en/expect#expectanyconstructor 28 | // 📜 https://jestjs.io/docs/en/expect#toequalvalue 29 | // 30 | // login 31 | // 🐨 use axios.post to post the username and password again, but to the login endpoint 32 | // 💰 http://localhost:8000/api/auth/login 33 | // 34 | // 🐨 assert that the result you get back is correct 35 | // 💰 tip: the data you get back is exactly the same as the data you get back 36 | // from the registration call, so this can be done really easily by comparing 37 | // the data of those results with toEqual 38 | // 39 | // authenticated request 40 | // 🐨 use axios.get(url, config) to GET the user's information 41 | // 💰 http://localhost:8000/api/auth/me 42 | // 💰 This request must be authenticated via the Authorization header which 43 | // you can add to the config object: {headers: {Authorization: `Bearer ${token}`}} 44 | // Remember that you have the token from the registration and login requests. 45 | // 46 | // 🐨 assert that the result you get back is correct 47 | // 💰 (again, this should be the same data you get back in the other requests, 48 | // so you can compare it with that). 49 | }) 50 | -------------------------------------------------------------------------------- /src/__tests__/auth.final.extra-1.js: -------------------------------------------------------------------------------- 1 | // Testing Authentication API Routes 2 | // 💯 Create a pre-configured axios client 3 | 4 | import axios from 'axios' 5 | import {resetDb} from 'utils/db-utils' 6 | import * as generate from 'utils/generate' 7 | import {getData, handleRequestFailure} from 'utils/async' 8 | import startServer from '../start' 9 | 10 | let server 11 | 12 | beforeAll(async () => { 13 | // NOTE: I set the port here to 8001 so we don't conflict with the other 14 | // files. We'll solve this problem in another extra credit 15 | server = await startServer({port: 8001}) 16 | }) 17 | 18 | afterAll(() => server.close()) 19 | 20 | beforeEach(() => resetDb()) 21 | 22 | const baseURL = 'http://localhost:8001/api' 23 | const api = axios.create({baseURL}) 24 | api.interceptors.response.use(getData, handleRequestFailure) 25 | 26 | test('auth flow', async () => { 27 | const {username, password} = generate.loginForm() 28 | 29 | // register 30 | const rData = await api.post('auth/register', {username, password}) 31 | expect(rData.user).toEqual({ 32 | token: expect.any(String), 33 | id: expect.any(String), 34 | username, 35 | }) 36 | 37 | // login 38 | const lData = await api.post('auth/login', {username, password}) 39 | expect(lData.user).toEqual(rData.user) 40 | 41 | // authenticated request 42 | const mData = await api.get('auth/me', { 43 | headers: { 44 | Authorization: `Bearer ${lData.user.token}`, 45 | }, 46 | }) 47 | expect(mData.user).toEqual(lData.user) 48 | }) 49 | -------------------------------------------------------------------------------- /src/__tests__/auth.final.extra-2.js: -------------------------------------------------------------------------------- 1 | // Testing Authentication API Routes 2 | // 💯 Ensure a unique server port 3 | 4 | import axios from 'axios' 5 | import {resetDb} from 'utils/db-utils' 6 | import * as generate from 'utils/generate' 7 | import {getData, handleRequestFailure} from 'utils/async' 8 | import startServer from '../start' 9 | 10 | let api, server 11 | 12 | beforeAll(async () => { 13 | server = await startServer() 14 | const baseURL = `http://localhost:${server.address().port}/api` 15 | api = axios.create({baseURL}) 16 | api.interceptors.response.use(getData, handleRequestFailure) 17 | }) 18 | 19 | afterAll(() => server.close()) 20 | 21 | beforeEach(() => resetDb()) 22 | 23 | test('auth flow', async () => { 24 | const {username, password} = generate.loginForm() 25 | 26 | // register 27 | const rData = await api.post('auth/register', {username, password}) 28 | expect(rData.user).toEqual({ 29 | token: expect.any(String), 30 | id: expect.any(String), 31 | username, 32 | }) 33 | 34 | // login 35 | const lData = await api.post('auth/login', {username, password}) 36 | expect(lData.user).toEqual(rData.user) 37 | 38 | // authenticated request 39 | const mData = await api.get('auth/me', { 40 | headers: { 41 | Authorization: `Bearer ${lData.user.token}`, 42 | }, 43 | }) 44 | expect(mData.user).toEqual(lData.user) 45 | }) 46 | -------------------------------------------------------------------------------- /src/__tests__/auth.final.extra-3.js: -------------------------------------------------------------------------------- 1 | // Testing Authentication API Routes 2 | // 💯 Interact directly with the database 3 | 4 | import axios from 'axios' 5 | import {resetDb} from 'utils/db-utils' 6 | import * as generate from 'utils/generate' 7 | import {getData, handleRequestFailure, resolve} from 'utils/async' 8 | import * as usersDB from '../db/users' 9 | import startServer from '../start' 10 | 11 | let api, server 12 | 13 | beforeAll(async () => { 14 | server = await startServer() 15 | const baseURL = `http://localhost:${server.address().port}/api` 16 | api = axios.create({baseURL}) 17 | api.interceptors.response.use(getData, handleRequestFailure) 18 | }) 19 | 20 | afterAll(() => server.close()) 21 | 22 | beforeEach(() => resetDb()) 23 | 24 | test('auth flow', async () => { 25 | const {username, password} = generate.loginForm() 26 | 27 | // register 28 | const rData = await api.post('auth/register', {username, password}) 29 | expect(rData.user).toEqual({ 30 | token: expect.any(String), 31 | id: expect.any(String), 32 | username, 33 | }) 34 | 35 | // login 36 | const lData = await api.post('auth/login', {username, password}) 37 | expect(lData.user).toEqual(rData.user) 38 | 39 | // authenticated request 40 | const mData = await api.get('auth/me', { 41 | headers: { 42 | Authorization: `Bearer ${lData.user.token}`, 43 | }, 44 | }) 45 | expect(mData.user).toEqual({ 46 | token: expect.any(String), 47 | id: expect.any(String), 48 | username, 49 | }) 50 | }) 51 | 52 | test('username must be unique', async () => { 53 | const username = generate.username() 54 | await usersDB.insert(generate.buildUser({username})) 55 | const error = await api 56 | .post('auth/register', { 57 | username, 58 | password: 'Nancy-is-#1', 59 | }) 60 | .catch(resolve) 61 | expect(error).toMatchInlineSnapshot( 62 | `[Error: 400: {"message":"username taken"}]`, 63 | ) 64 | }) 65 | -------------------------------------------------------------------------------- /src/__tests__/auth.final.extra-4.js: -------------------------------------------------------------------------------- 1 | // Testing Authentication API Routes 2 | // 💯 Test all the edge cases 3 | 4 | import axios from 'axios' 5 | import {resetDb} from 'utils/db-utils' 6 | import * as generate from 'utils/generate' 7 | import {getData, handleRequestFailure, resolve} from 'utils/async' 8 | import * as usersDB from '../db/users' 9 | import startServer from '../start' 10 | 11 | let api, server 12 | 13 | beforeAll(async () => { 14 | server = await startServer() 15 | const baseURL = `http://localhost:${server.address().port}/api` 16 | api = axios.create({baseURL}) 17 | api.interceptors.response.use(getData, handleRequestFailure) 18 | }) 19 | 20 | afterAll(() => server.close()) 21 | 22 | beforeEach(() => resetDb()) 23 | 24 | test('auth flow', async () => { 25 | const {username, password} = generate.loginForm() 26 | 27 | // register 28 | const rData = await api.post('auth/register', {username, password}) 29 | expect(rData.user).toEqual({ 30 | token: expect.any(String), 31 | id: expect.any(String), 32 | username, 33 | }) 34 | 35 | // login 36 | const lData = await api.post('auth/login', {username, password}) 37 | expect(lData.user).toEqual(rData.user) 38 | 39 | // authenticated request 40 | const mData = await api.get('auth/me', { 41 | headers: { 42 | Authorization: `Bearer ${lData.user.token}`, 43 | }, 44 | }) 45 | expect(mData.user).toEqual({ 46 | token: expect.any(String), 47 | id: expect.any(String), 48 | username, 49 | }) 50 | }) 51 | 52 | test('username must be unique', async () => { 53 | const username = generate.username() 54 | await usersDB.insert(generate.buildUser({username})) 55 | const error = await api 56 | .post('auth/register', { 57 | username, 58 | password: 'Nancy-is-#1', 59 | }) 60 | .catch(resolve) 61 | expect(error).toMatchInlineSnapshot( 62 | `[Error: 400: {"message":"username taken"}]`, 63 | ) 64 | }) 65 | 66 | test('get me unauthenticated returns error', async () => { 67 | const error = await api.get('auth/me').catch(resolve) 68 | expect(error).toMatchInlineSnapshot( 69 | `[Error: 401: {"code":"credentials_required","message":"No authorization token was found"}]`, 70 | ) 71 | }) 72 | 73 | test('username required to register', async () => { 74 | const error = await api 75 | .post('auth/register', {password: generate.password()}) 76 | .catch(resolve) 77 | expect(error).toMatchInlineSnapshot( 78 | `[Error: 400: {"message":"username can't be blank"}]`, 79 | ) 80 | }) 81 | 82 | test('password required to register', async () => { 83 | const error = await api 84 | .post('auth/register', {username: generate.username()}) 85 | .catch(resolve) 86 | expect(error).toMatchInlineSnapshot( 87 | `[Error: 400: {"message":"password can't be blank"}]`, 88 | ) 89 | }) 90 | 91 | test('username required to login', async () => { 92 | const error = await api 93 | .post('auth/login', {password: generate.password()}) 94 | .catch(resolve) 95 | expect(error).toMatchInlineSnapshot( 96 | `[Error: 400: {"message":"username can't be blank"}]`, 97 | ) 98 | }) 99 | 100 | test('password required to login', async () => { 101 | const error = await api 102 | .post('auth/login', {username: generate.username()}) 103 | .catch(resolve) 104 | expect(error).toMatchInlineSnapshot( 105 | `[Error: 400: {"message":"password can't be blank"}]`, 106 | ) 107 | }) 108 | 109 | test('user must exist to login', async () => { 110 | const error = await api 111 | .post('auth/login', generate.loginForm({username: '__will_never_exist__'})) 112 | .catch(resolve) 113 | expect(error).toMatchInlineSnapshot( 114 | `[Error: 400: {"message":"username or password is invalid"}]`, 115 | ) 116 | }) 117 | -------------------------------------------------------------------------------- /src/__tests__/auth.final.js: -------------------------------------------------------------------------------- 1 | // Testing Authentication API Routes 2 | 3 | import axios from 'axios' 4 | import {resetDb} from 'utils/db-utils' 5 | import * as generate from 'utils/generate' 6 | import startServer from '../start' 7 | 8 | let server 9 | 10 | beforeAll(async () => { 11 | server = await startServer({port: 8000}) 12 | }) 13 | 14 | afterAll(() => server.close()) 15 | 16 | beforeEach(() => resetDb()) 17 | 18 | test('auth flow', async () => { 19 | const {username, password} = generate.loginForm() 20 | 21 | // register 22 | const rResult = await axios.post('http://localhost:8000/api/auth/register', { 23 | username, 24 | password, 25 | }) 26 | expect(rResult.data.user).toEqual({ 27 | token: expect.any(String), 28 | id: expect.any(String), 29 | username, 30 | }) 31 | 32 | // login 33 | const lResult = await axios.post('http://localhost:8000/api/auth/login', { 34 | username, 35 | password, 36 | }) 37 | expect(lResult.data.user).toEqual(rResult.data.user) 38 | 39 | // authenticated request 40 | const mResult = await axios.get('http://localhost:8000/api/auth/me', { 41 | headers: { 42 | Authorization: `Bearer ${lResult.data.user.token}`, 43 | }, 44 | }) 45 | expect(mResult.data.user).toEqual(lResult.data.user) 46 | }) 47 | -------------------------------------------------------------------------------- /src/__tests__/auth.md: -------------------------------------------------------------------------------- 1 | # Testing Authentication API Routes 2 | 3 | ## Background 4 | 5 | It's a great idea to have some tests that do as minimal mocking/faking as 6 | possible. Tests which actually interact with the full server via HTTP and 7 | communicate with a backend and any other services your app relies on. 8 | 9 | Every app is a bit different, but the optimal situation is to have the backend 10 | and other services running locally in a docker container. If that's not 11 | possible, then you need to decide between mocking that service/database or 12 | running against a "test environment." The problem with the test environment is 13 | that they're typically pretty flaky and there's network latency which will slow 14 | down your tests, so a docker container is best. 15 | 16 | To simplify the setup for this workshop, our database is actually running in 17 | memory, but for all intents and purposes the tests that you'll be writing here 18 | would look just like tests you would be writing whether the database is running 19 | in docker or even in a test environment. You'll just need to make sure that the 20 | container/environment is running before you start running your tests, that's 21 | really the only difference. 22 | 23 | However you end up doing this, having the full server running and interacting 24 | with it the way your client-side application will be (via HTTP) is a crucial 25 | piece to getting the confidence you need out of your app. In fact, I argue that 26 | most of your codebase coverage should come from these kinds of tests. 27 | 28 | > 📜 Read more about this on my blog: 29 | > [Write tests. Not too many. Mostly integration.](https://kentcdodds.com/blog/write-tests) 30 | 31 | The biggest challenge you face with tests like this are: 32 | 33 | 1. They take more setup 34 | 2. They have more points of failure 35 | 36 | That said, they give you a huge amount of confidence for the effort and give you 37 | fewer false negatives and false positives, which is why I recommend focusing on 38 | them. (For more on this, read 39 | [Testing Implementation Details](https://kentcdodds.com/blog/testing-implementation-details)). 40 | 41 | The setup required includes getting the server started. The way I've structured 42 | it for this project is the way I recommend structuring it for all Node apps as 43 | it's a great way to run tests in isolation from one another but still in 44 | parallel. As mentioned, you'll also need to make sure that your database and any 45 | other services your app depends on are running or properly mocked. 46 | 47 | Another piece to the setup often includes making certain that the database is in 48 | a clean state. Sometimes you can get away with a database that has a lot of old 49 | test data in it, but it's better if you can have the database cleared out for 50 | every test. **Tests like these are often more comprehensive** in part for this 51 | reason as well as the fact that it makes writing the tests a little easier and 52 | more useful. 53 | 54 | ## Exercise 55 | 56 | In the exercises, you'll have a single test that runs through the whole 57 | authentication flow: register -> login -> GET /api/auth/me 58 | 59 | To get to this test though, there's quite a bit of setup you have to do: 60 | 61 | ### beforeAll/afterAll/beforeEach 62 | 63 | One of [the globals](https://jestjs.io/docs/en/api) exposed by Jest is a 64 | `beforeAll` hook. This allows you to run some code before any of the tests in 65 | the file run. 66 | 67 | > Note, there's also a configuration option called 68 | > [`globalSetup`](https://jestjs.io/docs/en/configuration#globalsetup-string) 69 | > which you may find useful for starting up your docker container/database 70 | > before _any_ of your test suites in your whole project run). 71 | 72 | The `beforeAll` hook is useful here for starting up the server and creating an 73 | `axios` client that's been configured to interact directly with that server. 74 | 75 | When we startup the server, it returns a promise which resolves to the `server` 76 | object. This has a `close` function on it to close down the server. So we'll 77 | reference that in an `afterAll` hook so that when our tests are done we close 78 | the server so it stops waiting on incoming requests. 79 | 80 | Here's a quick example: 81 | 82 | ```javascript 83 | let server 84 | 85 | beforeAll(async () => { 86 | server = await startServer() 87 | }) 88 | 89 | afterAll(async () => { 90 | await server.close() 91 | }) 92 | ``` 93 | 94 | We'll also be using 95 | [`beforeEach`](https://jestjs.io/docs/en/api#beforeeachfn-timeout) to reset the 96 | database between each test. It functions similar to `beforeAll` except it's run 97 | before every test in the file. 98 | 99 | ### axios: https://github.com/axios/axios 100 | 101 | There are a lot of abstractions for testing with HTTP. I've personally found 102 | them all to be too much of an abstraction and instead prefer something a little 103 | more lightweight. So in our tests we'll be using `axios` to make HTTP requests. 104 | 105 | > 🦉 We will be using axios's `interceptors` feature to simplify some things for 106 | > our tests. 107 | 108 | In your test, you'll be making multiple requests and waiting for multiple 109 | responses. 110 | 111 | Here's an example of the API: 112 | 113 | ```javascript 114 | const result = await axios.post('http://example.com/api/endpoint', data) 115 | ``` 116 | 117 | You'll be using both of these APIs in this exercise. 118 | 119 | ## Extra Credit 120 | 121 | ### 💯 Create a pre-configured axios client 122 | 123 | Having the full URL for each of those axios calls is kinda annoying. Luckily, 124 | axios allows you to create a pre-configured client: 125 | 126 | ```javascript 127 | const api = axios.create({baseURL: 'http://example.com/api'}) 128 | const result = await api.post('endpoint', data) 129 | ``` 130 | 131 | Make that work so you don't have to repeat the URL all over the place. 132 | 133 | To take it step further, you can also use the `interceptors` API to modify every 134 | response as it comes in. This allows us to make more helpful error messages, and 135 | automatically resolve to the `data` property of the response. 136 | 137 | ```js 138 | // ... 139 | import {getData, handleRequestFailure} from 'utils/async' 140 | 141 | // ... 142 | 143 | api.interceptors.response.use(getData, handleRequestFailure) 144 | 145 | // ... 146 | ``` 147 | 148 | For more info on how the `handleRequestFailure` works, read: 149 | [Improve test error messages of your abstractions](https://kentcdodds.com/blog/improve-test-error-messages-of-your-abstractions). 150 | 151 | Give that a try! 152 | 153 | ### 💯 Ensure a unique server port 154 | 155 | We need to make sure that every test has a unique port for the server, otherwise 156 | we can't run our tests in parallel without servers trying to bind to the same 157 | port. 158 | 159 | To solve this, we can let the operating system set the port dynamically by 160 | passing `0` as the port to listen to. 161 | 162 | 📜 163 | [learn more](https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback) 164 | 165 | Our `startServer` implementation will default to `process.env.PORT` if it's 166 | available. Well guess what! It is! Checkout `test/setup-env.js` and you'll 167 | notice that we're setting the PORT environment variable to `0`. 168 | 169 | The trick will be getting and using the port that the server is listening on so 170 | you make the request to the right place. Here's how you do that: 171 | 172 | ```javascript 173 | server.address().port 174 | ``` 175 | 176 | So now try to make things work without providing a port to the `server` and 177 | letting the default port be used. 178 | 179 | ### 💯 Interact directly with the database 180 | 181 | Sometimes you'll find it easier to side-step the server's API and interact 182 | directly with the database or other services as part of the setup for your test. 183 | This can have performance benefits as well as reduced code duplication. You are 184 | trading off confidence that things can _get_ into that state however, so I'd 185 | recommend ensuring you have some test that gives you confidence the 186 | services/database can get into that state before side-stepping them like we'll 187 | do in this extra credit. 188 | 189 | For this extra credit, the username must be unique and we want to ensure that 190 | our API enforces that. Start your test off by using `usersDB.insert` (from 191 | `../db/users`) to insert a user into the database with a specific username, then 192 | try to hit the API to register a new user with that same username and verify 193 | that the promise is rejected with the correct error message and status code. 194 | 195 | ### 💯 Test all the edge cases 196 | 197 | If you've made it this far, go ahead and test all the edge cases you can think 198 | of to make sure that errors are handled properly (like no password/username, an 199 | invalid password, etc.). 200 | 201 | > 🦉 This is where things get a little squishy. You might argue that it'd be 202 | > better to test these kinds of edge cases closer to the authentication code, 203 | > and I would have a hard time arguing for or against that approach. Do what 204 | > feels best for you. 205 | 206 | ## 🦉 Elaboration & Feedback 207 | 208 | After the instruction, copy the URL below into your browser: 209 | http://ws.kcd.im/?ws=Testing%20Node%20Apps&e=Testing%20Authentication%20API%20Routes&em= 210 | -------------------------------------------------------------------------------- /src/__tests__/list-items.exercise.js: -------------------------------------------------------------------------------- 1 | // Testing CRUD API Routes 2 | 3 | import axios from 'axios' 4 | import {resetDb, insertTestUser} from 'utils/db-utils' 5 | import {getData, handleRequestFailure, resolve} from 'utils/async' 6 | import * as generate from 'utils/generate' 7 | import * as booksDB from '../db/books' 8 | import startServer from '../start' 9 | 10 | let baseURL, server 11 | 12 | beforeAll(async () => { 13 | server = await startServer() 14 | baseURL = `http://localhost:${server.address().port}/api` 15 | }) 16 | 17 | afterAll(() => server.close()) 18 | 19 | beforeEach(() => resetDb()) 20 | 21 | async function setup() { 22 | // 💰 this bit isn't as important as the rest of what you'll be learning today 23 | // so I'm going to give it to you, but don't just skip over it. Try to figure 24 | // out what's going on here. 25 | const testUser = await insertTestUser() 26 | const authAPI = axios.create({baseURL}) 27 | authAPI.defaults.headers.common.authorization = `Bearer ${testUser.token}` 28 | authAPI.interceptors.response.use(getData, handleRequestFailure) 29 | return {testUser, authAPI} 30 | } 31 | 32 | test('listItem CRUD', async () => { 33 | const {testUser, authAPI} = await setup() 34 | 35 | // 🐨 create a book object and insert it into the database 36 | // 💰 use generate.buildBook and await booksDB.insert 37 | 38 | // CREATE 39 | // 🐨 create a new list-item by posting to the list-items endpoint with a bookId 40 | // 💰 the data you send should be: {bookId: book.id} 41 | 42 | // 🐨 assert that the data you get back is correct 43 | // 💰 it should have an ownerId (testUser.id) and a bookId (book.id) 44 | // 💰 if you don't want to assert on all the other properties, you can use 45 | // toMatchObject: https://jestjs.io/docs/en/expect#tomatchobjectobject 46 | 47 | // 💰 you might find this useful for the future requests: 48 | // const listItemId = cData.listItem.id 49 | // const listItemIdUrl = `list-items/${listItemId}` 50 | 51 | // READ 52 | // 🐨 make a GET to the `listItemIdUrl` 53 | // 🐨 assert that this returns the same thing you got when you created the list item 54 | 55 | // UPDATE 56 | // 🐨 make a PUT request to the `listItemIdUrl` with some updates 57 | // 💰 const updates = {notes: generate.notes()} 58 | // 🐨 assert that this returns the right stuff (should be the same as the READ except with the updated notes) 59 | 60 | // DELETE 61 | // 🐨 make a DELETE request to the `listItemIdUrl` 62 | // 🐨 assert that this returns the right stuff (💰 {success: true}) 63 | 64 | // 🐨 try to make a GET request to the `listItemIdUrl` again. 65 | // 💰 this promise should reject. You can do a try/catch if you want, or you 66 | // can use the `resolve` utility from utils/async: 67 | // 💰 const error = await authAPI.get(listItemIdUrl).catch(resolve) 68 | // 🐨 assert that the status is 404 and the error.data is correct 69 | }) 70 | 71 | /* eslint no-unused-vars:0 */ 72 | -------------------------------------------------------------------------------- /src/__tests__/list-items.final.extra-1.js: -------------------------------------------------------------------------------- 1 | // Testing CRUD API Routes 2 | // 💯 snapshot the error message with dynamic data 3 | 4 | import axios from 'axios' 5 | import {resetDb, insertTestUser} from 'utils/db-utils' 6 | import {getData, handleRequestFailure, resolve} from 'utils/async' 7 | import * as generate from 'utils/generate' 8 | import * as booksDB from '../db/books' 9 | import startServer from '../start' 10 | 11 | let baseURL, server 12 | 13 | beforeAll(async () => { 14 | server = await startServer() 15 | baseURL = `http://localhost:${server.address().port}/api` 16 | }) 17 | 18 | afterAll(() => server.close()) 19 | 20 | beforeEach(() => resetDb()) 21 | 22 | async function setup() { 23 | const testUser = await insertTestUser() 24 | const authAPI = axios.create({baseURL}) 25 | authAPI.defaults.headers.common.authorization = `Bearer ${testUser.token}` 26 | authAPI.interceptors.response.use(getData, handleRequestFailure) 27 | return {testUser, authAPI} 28 | } 29 | 30 | test('listItem CRUD', async () => { 31 | const {testUser, authAPI} = await setup() 32 | const book = generate.buildBook() 33 | await booksDB.insert(book) 34 | 35 | // CREATE 36 | const cData = await authAPI.post('list-items', {bookId: book.id}) 37 | 38 | expect(cData.listItem).toMatchObject({ 39 | ownerId: testUser.id, 40 | bookId: book.id, 41 | }) 42 | const listItemId = cData.listItem.id 43 | const listItemIdUrl = `list-items/${listItemId}` 44 | 45 | // READ 46 | const rData = await authAPI.get(listItemIdUrl) 47 | expect(rData.listItem).toEqual(cData.listItem) 48 | 49 | // UPDATE 50 | const updates = {notes: generate.notes()} 51 | const uResult = await authAPI.put(listItemIdUrl, updates) 52 | expect(uResult.listItem).toEqual({...rData.listItem, ...updates}) 53 | 54 | // DELETE 55 | const dData = await authAPI.delete(listItemIdUrl) 56 | expect(dData).toEqual({success: true}) 57 | const error = await authAPI.get(listItemIdUrl).catch(resolve) 58 | expect(error.status).toBe(404) 59 | 60 | // because the ID is generated, we need to replace it in the error message 61 | // so our snapshot remains consistent 62 | const idlessMessage = error.data.message.replace(listItemId, 'LIST_ITEM_ID') 63 | expect(idlessMessage).toMatchInlineSnapshot( 64 | `"No list item was found with the id of LIST_ITEM_ID"`, 65 | ) 66 | }) 67 | -------------------------------------------------------------------------------- /src/__tests__/list-items.final.js: -------------------------------------------------------------------------------- 1 | // Testing CRUD API Routes 2 | 3 | import axios from 'axios' 4 | import {resetDb, insertTestUser} from 'utils/db-utils' 5 | import {getData, handleRequestFailure, resolve} from 'utils/async' 6 | import * as generate from 'utils/generate' 7 | import * as booksDB from '../db/books' 8 | import startServer from '../start' 9 | 10 | let baseURL, server 11 | 12 | beforeAll(async () => { 13 | server = await startServer() 14 | baseURL = `http://localhost:${server.address().port}/api` 15 | }) 16 | 17 | afterAll(() => server.close()) 18 | 19 | beforeEach(() => resetDb()) 20 | 21 | async function setup() { 22 | const testUser = await insertTestUser() 23 | const authAPI = axios.create({baseURL}) 24 | authAPI.defaults.headers.common.authorization = `Bearer ${testUser.token}` 25 | authAPI.interceptors.response.use(getData, handleRequestFailure) 26 | return {testUser, authAPI} 27 | } 28 | 29 | test('listItem CRUD', async () => { 30 | const {testUser, authAPI} = await setup() 31 | const book = generate.buildBook() 32 | await booksDB.insert(book) 33 | 34 | // CREATE 35 | const cData = await authAPI.post('list-items', {bookId: book.id}) 36 | 37 | expect(cData.listItem).toMatchObject({ 38 | ownerId: testUser.id, 39 | bookId: book.id, 40 | }) 41 | const listItemId = cData.listItem.id 42 | const listItemIdUrl = `list-items/${listItemId}` 43 | 44 | // READ 45 | const rData = await authAPI.get(listItemIdUrl) 46 | expect(rData.listItem).toEqual(cData.listItem) 47 | 48 | // UPDATE 49 | const updates = {notes: generate.notes()} 50 | const uResult = await authAPI.put(listItemIdUrl, updates) 51 | expect(uResult.listItem).toEqual({...rData.listItem, ...updates}) 52 | 53 | // DELETE 54 | const dData = await authAPI.delete(listItemIdUrl) 55 | expect(dData).toEqual({success: true}) 56 | const error = await authAPI.get(listItemIdUrl).catch(resolve) 57 | expect(error.status).toBe(404) 58 | expect(error.data).toEqual({ 59 | message: `No list item was found with the id of ${listItemId}`, 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/__tests__/list-items.md: -------------------------------------------------------------------------------- 1 | # Testing CRUD API Routes 2 | 3 | ## Background 4 | 5 | Create, Read, Update, Delete. These are the foundational operations of our 6 | servers. There's a lot going on here too. Is the request valid? Do we have the 7 | data that's being requested/updated? Is the user authorized to update that data? 8 | 9 | Having a single integration-level test for the whole lifecycle of a unit of data 10 | can be a great way to maintain confidence that our end-users will be able to 11 | operate on that data properly. 12 | 13 | ## Exercise 14 | 15 | This exercise will do basically the same kinds of things that our authentication 16 | tests do. The biggest difference is that our axios client will need to be 17 | authenticated. So we'll want to have a test 18 | 19 | ## Extra Credit 20 | 21 | ### 💯 snapshot the error message with dynamic data 22 | 23 | As with a previous example, we have an error message and it'd be nice to take a 24 | snapshot of it. Unfortunately, the server is generating the ID when we create 25 | the list item, so a snapshot would fail every time we run this. 26 | 27 | See if you can figure out how to still take a snapshot of the error so we don't 28 | have to worry about hard-coding the error message in our assertion. 29 | 30 | > 🔥 REMEMBER: If you're using codesandbox to go through this, the code update 31 | > will not appear automatically, but it has happened! Just close and re-open the 32 | > file and you'll get the update. 33 | 34 | ## 🦉 Elaboration & Feedback 35 | 36 | After the instruction, copy the URL below into your browser: 37 | http://ws.kcd.im/?ws=Testing%20Node%20Apps&e=Testing%20CRUD%20API%20Routes&em= 38 | -------------------------------------------------------------------------------- /src/db/README.md: -------------------------------------------------------------------------------- 1 | # DB 2 | 3 | This is our "database" abstraction. Every app is so different at this level that 4 | it's much easier for your learning if we don't bring in an opinionated ORM or 5 | real database here, so all the data is just stored in memory. Most of the tests 6 | mock out these and that's something that many of your tests will do as well. 7 | 8 | For the integration tests that you write (which will not mock whatever database 9 | abstraction you're using), you will need to have the database up and running (in 10 | a Docker container for example) and your app will connect to that database like 11 | it does normally and you'll be operating with a real database. This will make 12 | those integration tests a bit slower, but it's worth the confidence they 13 | provide. 14 | -------------------------------------------------------------------------------- /src/db/books.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | let books = [] 4 | 5 | async function query(queryObj) { 6 | return _.filter(books, queryObj) 7 | } 8 | 9 | async function readById(id) { 10 | return _.find(books, {id}) 11 | } 12 | 13 | async function readManyById(ids) { 14 | return _.filter(books, b => ids.includes(b.id)) 15 | } 16 | 17 | async function insertMany(manyBooks) { 18 | books = [...books, ...manyBooks] 19 | } 20 | 21 | async function insert(book) { 22 | books = [...books, book] 23 | } 24 | 25 | async function drop() { 26 | books = [] 27 | } 28 | 29 | export {readById, readManyById, insertMany, query, insert, drop} 30 | 31 | /* eslint require-await:0 */ 32 | -------------------------------------------------------------------------------- /src/db/list-items.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import {generateUUID} from './utils' 3 | 4 | let listItems = [] 5 | 6 | async function query(queryObj) { 7 | return _.filter(listItems, queryObj) 8 | } 9 | 10 | async function create(listItemData) { 11 | const {bookId, ownerId} = listItemData 12 | if (!bookId) { 13 | throw new Error(`New listItems must have a bookId`) 14 | } 15 | if (!ownerId) { 16 | throw new Error(`New listItems must have an ownerId`) 17 | } 18 | 19 | const newListItem = { 20 | id: generateUUID(), 21 | rating: -1, 22 | notes: '', 23 | finishDate: null, 24 | startDate: Date.now(), 25 | ...listItemData, 26 | } 27 | listItems = [...listItems, newListItem] 28 | return newListItem 29 | } 30 | 31 | async function readById(id) { 32 | return _.find(listItems, {id}) 33 | } 34 | 35 | async function update(listItemId, updates) { 36 | const listItem = await readById(listItemId) 37 | if (!listItem) { 38 | return null 39 | } 40 | const updatedListItem = { 41 | ...listItem, 42 | ...updates, 43 | } 44 | listItems[listItems.indexOf(listItem)] = updatedListItem 45 | return updatedListItem 46 | } 47 | 48 | async function remove(id) { 49 | listItems = listItems.filter(li => li.id !== id) 50 | } 51 | 52 | async function insertMany(manyListItems) { 53 | listItems = [...listItems, ...manyListItems] 54 | } 55 | 56 | async function drop() { 57 | listItems = [] 58 | } 59 | 60 | export {query, create, readById, update, remove, insertMany, drop} 61 | 62 | /* eslint require-await:0 */ 63 | -------------------------------------------------------------------------------- /src/db/users.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import {generateUUID} from './utils' 3 | 4 | let users = [] 5 | 6 | async function query(queryObj) { 7 | return _.filter(users, queryObj) 8 | } 9 | 10 | async function readById(id) { 11 | return query({id})[0] 12 | } 13 | 14 | async function readByUsername(username) { 15 | return (await query({username}))[0] 16 | } 17 | 18 | async function insertMany(manyUsers) { 19 | users = [...users, ...manyUsers] 20 | } 21 | 22 | async function insert(user) { 23 | const newUser = {id: generateUUID(), ...user} 24 | users = [...users, newUser] 25 | return newUser 26 | } 27 | 28 | async function drop() { 29 | users = [] 30 | } 31 | 32 | export {readById, readByUsername, insertMany, insert, query, drop} 33 | 34 | /* eslint require-await:0 */ 35 | -------------------------------------------------------------------------------- /src/db/utils.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | 3 | export const generateUUID = () => faker.random.uuid() 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import logger from 'loglevel' 2 | import startServer from './start' 3 | 4 | const isTest = process.env.NODE_ENV !== 'test' 5 | const logLevel = process.env.LOG_LEVEL || (isTest ? 'warn' : 'info') 6 | 7 | logger.setLevel(logLevel) 8 | 9 | startServer() 10 | -------------------------------------------------------------------------------- /src/routes/__tests__/list-items-controller.exercise.js: -------------------------------------------------------------------------------- 1 | // Testing Controllers 2 | 3 | // 🐨 you'll need a few of the generaters from test/utils/generate.js 4 | // 💰 remember, you can import files in the test/utils directory as if they're node_modules 5 | // 💰 import * as generate from 'utils/generate' 6 | 7 | // 🐨 getListItem calls `expandBookData` which calls `booksDB.readById` 8 | // so you'll need to import the booksDB from '../../db/books' 9 | // 💰 import * as booksDB from '../../db/books' 10 | 11 | // 🐨 don't forget to import the listItemsController from '../list-items-controller' 12 | // here, that's the thing we're testing afterall :) 13 | // 💰 import * as listItemsController from '../list-items-controller' 14 | 15 | // 🐨 use jest.mock to mock '../../db/books' because we don't actually want to make 16 | // database calls in this test file. 17 | 18 | // 🐨 ensure that all mock functions have their call history cleared using 19 | // jest.resetAllMocks here as in the example. 20 | 21 | test('getListItem returns the req.listItem', async () => { 22 | // 🐨 create a user 23 | // 24 | // 🐨 create a book 25 | // 26 | // 🐨 create a listItem that has the user as the owner and the book 27 | // 💰 const listItem = buildListItem({ownerId: user.id, bookId: book.id}) 28 | // 29 | // 🐨 mock booksDB.readById to resolve to the book 30 | // 💰 use mockResolvedValueOnce 31 | // 32 | // 🐨 make a request object that has properties for the user and listItem 33 | // 💰 checkout the implementation of getListItem in ../list-items-controller 34 | // to see how the request object is used and what properties it needs. 35 | // 💰 and you can use buildReq from utils/generate 36 | // 37 | // 🐨 make a response object 38 | // 💰 just use buildRes from utils/generate 39 | // 40 | // 🐨 make a call to getListItem with the req and res (`await` the result) 41 | // 42 | // 🐨 assert that booksDB.readById was called correctly 43 | // 44 | //🐨 assert that res.json was called correctly 45 | }) 46 | -------------------------------------------------------------------------------- /src/routes/__tests__/list-items-controller.final.extra-1.js: -------------------------------------------------------------------------------- 1 | // Testing Controllers 2 | 3 | import { 4 | buildRes, 5 | buildReq, 6 | buildUser, 7 | buildBook, 8 | buildListItem, 9 | } from 'utils/generate' 10 | import * as booksDB from '../../db/books' 11 | import * as listItemsController from '../list-items-controller' 12 | 13 | jest.mock('../../db/books') 14 | 15 | beforeEach(() => { 16 | jest.resetAllMocks() 17 | }) 18 | 19 | test('getListItem returns the req.listItem', async () => { 20 | const user = buildUser() 21 | const book = buildBook() 22 | const listItem = buildListItem({ownerId: user.id, bookId: book.id}) 23 | 24 | booksDB.readById.mockResolvedValueOnce(book) 25 | 26 | const req = buildReq({user, listItem}) 27 | const res = buildRes() 28 | 29 | await listItemsController.getListItem(req, res) 30 | 31 | expect(booksDB.readById).toHaveBeenCalledWith(book.id) 32 | expect(booksDB.readById).toHaveBeenCalledTimes(1) 33 | 34 | expect(res.json).toHaveBeenCalledWith({ 35 | listItem: {...listItem, book}, 36 | }) 37 | expect(res.json).toHaveBeenCalledTimes(1) 38 | }) 39 | 40 | test('createListItem returns a 400 error if no bookId is provided', async () => { 41 | const req = buildReq() 42 | const res = buildRes() 43 | 44 | await listItemsController.createListItem(req, res) 45 | 46 | expect(res.status).toHaveBeenCalledWith(400) 47 | expect(res.status).toHaveBeenCalledTimes(1) 48 | expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` 49 | Array [ 50 | Object { 51 | "message": "No bookId provided", 52 | }, 53 | ] 54 | `) 55 | expect(res.json).toHaveBeenCalledTimes(1) 56 | }) 57 | -------------------------------------------------------------------------------- /src/routes/__tests__/list-items-controller.final.extra-2.js: -------------------------------------------------------------------------------- 1 | // Testing Controllers 2 | // 💯 Test everything else 3 | 4 | import { 5 | buildRes, 6 | buildReq, 7 | buildNext, 8 | buildUser, 9 | buildBook, 10 | buildListItem, 11 | notes, 12 | } from 'utils/generate' 13 | import * as listItemsDB from '../../db/list-items' 14 | import * as booksDB from '../../db/books' 15 | import * as listItemsController from '../list-items-controller' 16 | 17 | jest.mock('../../db/list-items') 18 | jest.mock('../../db/books') 19 | 20 | beforeEach(() => { 21 | jest.resetAllMocks() 22 | }) 23 | 24 | test('getListItem returns the req.listItem', async () => { 25 | const user = buildUser() 26 | const book = buildBook() 27 | const listItem = buildListItem({ownerId: user.id, bookId: book.id}) 28 | 29 | booksDB.readById.mockResolvedValueOnce(book) 30 | 31 | const req = buildReq({user, listItem}) 32 | const res = buildRes() 33 | 34 | await listItemsController.getListItem(req, res) 35 | 36 | expect(booksDB.readById).toHaveBeenCalledWith(book.id) 37 | expect(booksDB.readById).toHaveBeenCalledTimes(1) 38 | 39 | expect(res.json).toHaveBeenCalledWith({ 40 | listItem: {...listItem, book}, 41 | }) 42 | expect(res.json).toHaveBeenCalledTimes(1) 43 | }) 44 | 45 | test('createListItem returns a 400 error if no bookId is provided', async () => { 46 | const req = buildReq() 47 | const res = buildRes() 48 | 49 | await listItemsController.createListItem(req, res) 50 | 51 | expect(res.status).toHaveBeenCalledWith(400) 52 | expect(res.status).toHaveBeenCalledTimes(1) 53 | expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` 54 | Array [ 55 | Object { 56 | "message": "No bookId provided", 57 | }, 58 | ] 59 | `) 60 | expect(res.json).toHaveBeenCalledTimes(1) 61 | }) 62 | 63 | test('setListItem sets the listItem on the req', async () => { 64 | const user = buildUser() 65 | const listItem = buildListItem({ownerId: user.id}) 66 | 67 | listItemsDB.readById.mockResolvedValueOnce(listItem) 68 | 69 | const req = buildReq({user, params: {id: listItem.id}}) 70 | const res = buildRes() 71 | const next = buildNext() 72 | 73 | await listItemsController.setListItem(req, res, next) 74 | 75 | expect(listItemsDB.readById).toHaveBeenCalledWith(listItem.id) 76 | expect(listItemsDB.readById).toHaveBeenCalledTimes(1) 77 | 78 | expect(next).toHaveBeenCalledWith(/* nothing */) 79 | expect(next).toHaveBeenCalledTimes(1) 80 | 81 | expect(req.listItem).toBe(listItem) 82 | }) 83 | test('setListItem returns a 404 error if the list item does not exit', async () => { 84 | listItemsDB.readById.mockResolvedValueOnce(null) 85 | 86 | const fakeListItemId = 'FAKE_LIST_ITEM_ID' 87 | const req = buildReq({params: {id: fakeListItemId}}) 88 | const res = buildRes() 89 | const next = buildNext() 90 | 91 | await listItemsController.setListItem(req, res, next) 92 | 93 | expect(listItemsDB.readById).toHaveBeenCalledWith(fakeListItemId) 94 | expect(listItemsDB.readById).toHaveBeenCalledTimes(1) 95 | 96 | expect(next).not.toHaveBeenCalled() 97 | 98 | expect(res.status).toHaveBeenCalledWith(404) 99 | expect(res.status).toHaveBeenCalledTimes(1) 100 | expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` 101 | Array [ 102 | Object { 103 | "message": "No list item was found with the id of FAKE_LIST_ITEM_ID", 104 | }, 105 | ] 106 | `) 107 | expect(res.json).toHaveBeenCalledTimes(1) 108 | }) 109 | 110 | test('setListItem returns a 403 error if the list item does not belong to the user', async () => { 111 | const user = buildUser({id: 'FAKE_USER_ID'}) 112 | const listItem = buildListItem({ 113 | ownerId: 'SOMEONE_ELSE', 114 | id: 'FAKE_LIST_ITEM_ID', 115 | }) 116 | listItemsDB.readById.mockResolvedValueOnce(listItem) 117 | 118 | const req = buildReq({user, params: {id: listItem.id}}) 119 | const res = buildRes() 120 | const next = buildNext() 121 | 122 | await listItemsController.setListItem(req, res, next) 123 | 124 | expect(listItemsDB.readById).toHaveBeenCalledWith(listItem.id) 125 | expect(listItemsDB.readById).toHaveBeenCalledTimes(1) 126 | 127 | expect(next).not.toHaveBeenCalled() 128 | 129 | expect(res.status).toHaveBeenCalledWith(403) 130 | expect(res.status).toHaveBeenCalledTimes(1) 131 | expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` 132 | Array [ 133 | Object { 134 | "message": "User with id FAKE_USER_ID is not authorized to access the list item FAKE_LIST_ITEM_ID", 135 | }, 136 | ] 137 | `) 138 | expect(res.json).toHaveBeenCalledTimes(1) 139 | }) 140 | 141 | test(`getListItems returns a user's list items`, async () => { 142 | const user = buildUser() 143 | const books = [buildBook(), buildBook()] 144 | const userListItems = [ 145 | buildListItem({ 146 | ownerId: user.id, 147 | bookId: books[0].id, 148 | }), 149 | buildListItem({ 150 | ownerId: user.id, 151 | bookId: books[1].id, 152 | }), 153 | ] 154 | 155 | booksDB.readManyById.mockResolvedValueOnce(books) 156 | listItemsDB.query.mockResolvedValueOnce(userListItems) 157 | 158 | const req = buildReq({user}) 159 | const res = buildRes() 160 | 161 | await listItemsController.getListItems(req, res) 162 | 163 | expect(booksDB.readManyById).toHaveBeenCalledWith([books[0].id, books[1].id]) 164 | expect(booksDB.readManyById).toHaveBeenCalledTimes(1) 165 | 166 | expect(listItemsDB.query).toHaveBeenCalledWith({ownerId: user.id}) 167 | expect(listItemsDB.query).toHaveBeenCalledTimes(1) 168 | 169 | expect(res.json).toHaveBeenCalledWith({ 170 | listItems: [ 171 | {...userListItems[0], book: books[0]}, 172 | {...userListItems[1], book: books[1]}, 173 | ], 174 | }) 175 | expect(res.json).toHaveBeenCalledTimes(1) 176 | }) 177 | 178 | test('createListItem creates and returns a list item', async () => { 179 | const user = buildUser() 180 | const book = buildBook() 181 | const createdListItem = buildListItem({ownerId: user.id, bookId: book.id}) 182 | listItemsDB.query.mockResolvedValueOnce([]) 183 | listItemsDB.create.mockResolvedValueOnce(createdListItem) 184 | booksDB.readById.mockResolvedValueOnce(book) 185 | 186 | const req = buildReq({user, body: {bookId: book.id}}) 187 | const res = buildRes() 188 | 189 | await listItemsController.createListItem(req, res) 190 | 191 | expect(listItemsDB.query).toHaveBeenCalledWith({ 192 | ownerId: user.id, 193 | bookId: book.id, 194 | }) 195 | expect(listItemsDB.query).toHaveBeenCalledTimes(1) 196 | 197 | expect(listItemsDB.create).toHaveBeenCalledWith({ 198 | ownerId: user.id, 199 | bookId: book.id, 200 | }) 201 | expect(listItemsDB.create).toHaveBeenCalledTimes(1) 202 | 203 | expect(booksDB.readById).toHaveBeenCalledWith(book.id) 204 | expect(booksDB.readById).toHaveBeenCalledTimes(1) 205 | 206 | expect(res.json).toHaveBeenCalledWith({listItem: {...createdListItem, book}}) 207 | expect(res.json).toHaveBeenCalledTimes(1) 208 | }) 209 | 210 | test('createListItem returns a 400 error if the user already has a list item for the given book', async () => { 211 | const user = buildUser({id: 'FAKE_USER_ID'}) 212 | const book = buildBook({id: 'FAKE_BOOK_ID'}) 213 | const existingListItem = buildListItem({ownerId: user.id, bookId: book.id}) 214 | listItemsDB.query.mockResolvedValueOnce([existingListItem]) 215 | 216 | const req = buildReq({user, body: {bookId: book.id}}) 217 | const res = buildRes() 218 | 219 | await listItemsController.createListItem(req, res) 220 | expect(listItemsDB.query).toHaveBeenCalledWith({ 221 | ownerId: user.id, 222 | bookId: book.id, 223 | }) 224 | expect(listItemsDB.query).toHaveBeenCalledTimes(1) 225 | 226 | expect(res.status).toHaveBeenCalledWith(400) 227 | expect(res.status).toHaveBeenCalledTimes(1) 228 | expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` 229 | Array [ 230 | Object { 231 | "message": "User FAKE_USER_ID already has a list item for the book with the ID FAKE_BOOK_ID", 232 | }, 233 | ] 234 | `) 235 | expect(res.json).toHaveBeenCalledTimes(1) 236 | }) 237 | 238 | test('updateListItem updates an existing list item', async () => { 239 | const user = buildUser() 240 | const book = buildBook() 241 | const listItem = buildListItem({ownerId: user.id, bookId: book.id}) 242 | const updates = {notes: notes()} 243 | 244 | const mergedListItemAndUpdates = {...listItem, ...updates} 245 | 246 | listItemsDB.update.mockResolvedValueOnce(mergedListItemAndUpdates) 247 | booksDB.readById.mockResolvedValueOnce(book) 248 | 249 | const req = buildReq({ 250 | user, 251 | listItem, 252 | body: updates, 253 | }) 254 | const res = buildRes() 255 | 256 | await listItemsController.updateListItem(req, res) 257 | 258 | expect(listItemsDB.update).toHaveBeenCalledWith(listItem.id, updates) 259 | expect(listItemsDB.update).toHaveBeenCalledTimes(1) 260 | 261 | expect(booksDB.readById).toHaveBeenCalledWith(book.id) 262 | expect(booksDB.readById).toHaveBeenCalledTimes(1) 263 | 264 | expect(res.json).toHaveBeenCalledWith({ 265 | listItem: {...mergedListItemAndUpdates, book}, 266 | }) 267 | expect(res.json).toHaveBeenCalledTimes(1) 268 | }) 269 | 270 | test('deleteListItem deletes an existing list item', async () => { 271 | const user = buildUser() 272 | const listItem = buildListItem({ownerId: user.id}) 273 | 274 | const req = buildReq({ 275 | user, 276 | listItem, 277 | }) 278 | const res = buildRes() 279 | 280 | await listItemsController.deleteListItem(req, res) 281 | 282 | expect(listItemsDB.remove).toHaveBeenCalledWith(listItem.id) 283 | expect(listItemsDB.remove).toHaveBeenCalledTimes(1) 284 | 285 | expect(res.json).toHaveBeenCalledWith({success: true}) 286 | expect(res.json).toHaveBeenCalledTimes(1) 287 | }) 288 | -------------------------------------------------------------------------------- /src/routes/__tests__/list-items-controller.final.js: -------------------------------------------------------------------------------- 1 | // Testing Controllers 2 | 3 | import { 4 | buildRes, 5 | buildReq, 6 | buildUser, 7 | buildBook, 8 | buildListItem, 9 | } from 'utils/generate' 10 | import * as booksDB from '../../db/books' 11 | import * as listItemsController from '../list-items-controller' 12 | 13 | jest.mock('../../db/books') 14 | 15 | beforeEach(() => { 16 | jest.resetAllMocks() 17 | }) 18 | 19 | test('getListItem returns the req.listItem', async () => { 20 | const user = buildUser() 21 | const book = buildBook() 22 | const listItem = buildListItem({ownerId: user.id, bookId: book.id}) 23 | 24 | booksDB.readById.mockResolvedValueOnce(book) 25 | 26 | const req = buildReq({user, listItem}) 27 | const res = buildRes() 28 | 29 | await listItemsController.getListItem(req, res) 30 | 31 | expect(booksDB.readById).toHaveBeenCalledWith(book.id) 32 | expect(booksDB.readById).toHaveBeenCalledTimes(1) 33 | 34 | expect(res.json).toHaveBeenCalledWith({ 35 | listItem: {...listItem, book}, 36 | }) 37 | expect(res.json).toHaveBeenCalledTimes(1) 38 | }) 39 | -------------------------------------------------------------------------------- /src/routes/__tests__/list-items-controller.md: -------------------------------------------------------------------------------- 1 | # Testing Controllers 2 | 3 | ## Background 4 | 5 | The term "controllers" in this context is really just a collection of middleware 6 | that applies some business logic specific to your domain. They can involve 7 | really simple or incredibly complex business rules resulting in code that can be 8 | fairly difficult to test. Often controller code will also interact with a 9 | database or other services. 10 | 11 | They are tested in basically the same way that a regular middleware is tested, 12 | except because our controller middleware is interacting with the database we're 13 | going to mock out those interactions. We'll do this for a few reasons: 14 | 15 | 1. Test speed: Database/Service interactions will make our tests run slower. 16 | 2. Test simplicity: Database/Service interactions will make our tests require 17 | more complex setup/teardown logic. 18 | 3. Test stability: Database/Service interactions will make our tests more flaky 19 | by relying on services that may be outside our control. 20 | 21 | > 🦉 While we get benefits by mocking databases and services, it's wise to not 22 | > forget what we're giving up. Read 23 | > [Testing Implementation Details](https://kentcdodds.com/blog/testing-implementation-details) 24 | > and [The Merits of Mocking](https://kentcdodds.com/blog/the-merits-of-mocking) 25 | > for more info. 26 | 27 | ### Mocking with Jest 28 | 29 | Jest's mocking capabilities are second to none. The API is pretty simple. At the 30 | top of your test file (just below the imports), you add: 31 | 32 | ```javascript 33 | jest.mock('../path-to/the-module') 34 | ``` 35 | 36 | And Jest will read that module and determine its exports and create an automatic 37 | mock function for function that file exports. Any file that imports that module 38 | will receive the mock from Jest instead of the actual implementation. 39 | 40 | Here's a quick example: 41 | 42 | ```javascript 43 | // get-user-repos.js 44 | import * as github from './github' 45 | 46 | async function getUserRepos(user) { 47 | const result = await github.getRepos(user.username) 48 | return result.data 49 | } 50 | 51 | export {getUserRepos} 52 | 53 | // __tests__/get-user-repos.js 54 | import {buildUser, buildRepo} from 'utils/generate' 55 | import * as github from '../github' 56 | import {getUserRepos} from '../get-user-repos' 57 | 58 | jest.mock('../github') 59 | // because we've mocked `../github.js`, any other file which imports it 60 | // will get Jest's mocked version of that module which is basically: 61 | // { getRepos: jest.fn() } 62 | // so we can treat it like a mock function in our tests 63 | // 📜 https://jestjs.io/docs/en/mock-function-api 64 | 65 | beforeEach(() => { 66 | // this will make sure our tests start in a clean state, clearing all mock 67 | // functions so they don't have record of having been called before. 68 | // This is important for test isolation. 69 | // 📜 Related blog post: https://kentcdodds.com/blog/test-isolation-with-react 70 | jest.resetAllMocks() 71 | }) 72 | 73 | test(`gets the user's repositories`, async () => { 74 | // learn more about `buildUser()` and `buildRepo()` in the next section 75 | // "Generating Test Data" 76 | const user = buildUser() 77 | 78 | const fakeRepos = [ 79 | buildRepo({ownerId: user.id}), 80 | buildRepo({ownerId: user.id}), 81 | ] 82 | // 🦉 here's the important bit. Because getRepos is a Jest mock function, 83 | // we can tell Jest to make it return a promise that resolves with the 84 | // object that we want our code to use instead of calling the real function. 85 | github.getRepos.mockResolvedValueOnce({data: fakeRepos}) 86 | 87 | const repos = await getUserRepos(user) 88 | 89 | // because we're mocking getRepos, we want to make sure that it's being 90 | // called properly, so we'll add some assertions for that. 91 | expect(github.getRepos).toHaveBeenCalledWith(user.username) 92 | expect(github.getRepos).toHaveBeenCalledTimes(1) 93 | 94 | expect(repos).toEqual(fakeRepos) 95 | }) 96 | ``` 97 | 98 | The important bits above are the calls to `jest.mock` and 99 | `mockResolvedValueOnce`. This is all you need to know for the exercise. 100 | 101 | `jest.mock` also allows a bit more customization of the mock that's created 102 | which you can learn about from these docs 📜 103 | 104 | - https://jestjs.io/docs/en/jest-object#jestmockmodulename-factory-options 105 | - https://jestjs.io/docs/en/manual-mocks 106 | - https://jestjs.io/docs/en/bypassing-module-mocks 107 | 108 | ### Generating Test Data 109 | 110 | We've talked about Test Object Factories, but this is a little different. In the 111 | example test above you may have noticed the `buildUser()` and `buildRepo()` 112 | function calls. These functions generate randomized data for the given type of 113 | object. It's a way to communicate through code: "All that matters is that you're 114 | passing a user to this function and the repos that come back should be owned by 115 | that user." So it's not only nice from a DRY perspective, but also through a 116 | communication through code perspective. And we definitely do this for our, 117 | books, list items, and users in this project, so be on the lookout for that. 118 | 119 | ## Exercise 120 | 121 | In this project, we have an abstraction over the database which comes in really 122 | handy for our controller tests. Because we don't want to make actual database 123 | calls, we can instead mock the database abstraction (don't worry, that 124 | abstraction will get coverage in the router-level tests). 125 | 126 | We need to mock the database so we don't make calls to it in our tests. 127 | 128 | > 🦉 Note that we're using `express-async-errors` in this project. That means 129 | > that we can use `async/await` in our middleware and we don't have to call 130 | > `next`. So you can call the middleware function with and `await` in your test. 131 | 132 | ## Extra Credit 133 | 134 | ### 💯 Use `toMatchInlineSnapshot` for errors 135 | 136 | [Snapshot testing](https://jestjs.io/docs/en/snapshot-testing) is a way to write 137 | and update assertions more easily. It's fantastic, but does have a number of 138 | drawbacks (so 139 | [learn to use it effectively](https://kentcdodds.com/blog/effective-snapshot-testing)). 140 | One of my favorite places to use snapshot testing is for error messages. This is 141 | because making an assertion that matches the error message usually involves 142 | copy/paste and keeping that updated as things change is a bit of a pain. So 143 | snapshot testing is perfect for that. 144 | 145 | Here's a quick example of how you can use snapshot testing based on a similar 146 | example that we had above: 147 | 148 | ```javascript 149 | test(`rejects with an error if no username is provided`, async () => { 150 | const userWithoutAUsername = {} 151 | const error = await getUserRepos(userWithoutAUsername).catch((err) => err) 152 | 153 | // make sure that the getRepos mock was not called 154 | expect(github.getRepos).not.toHaveBeenCalled() 155 | 156 | expect(error.message).toMatchInlineSnapshot() 157 | }) 158 | ``` 159 | 160 | Here's the cool thing. The first time that runs, Jest will _update your code_ to 161 | include a serialized version of `error.message`! 162 | 163 | ```javascript 164 | expect(error.message).toMatchInlineSnapshot(`"No username provided"`) 165 | ``` 166 | 167 | > 🔥 NOTE: If you're using codesandbox to go through this, the code update will 168 | > not appear automatically, but it has happened! Just close and re-open the file 169 | > and you'll get the update. 170 | 171 | I love this feature. 172 | 173 | > Note, there's also `toMatchSnapshot` which places the snapshot in a separate 174 | > file, but I much prefer the inline snapshots because it encourages me to keep 175 | > the snapshots as small as possible and avoid one of the pitfalls with using 176 | > snapshot testing. 177 | 178 | In this extra credit, create and implement a test called: 179 | `createListItem returns a 400 error if no bookId is provided` 180 | 181 | And use a snapshot assertion on `res.json.mock.calls[0]` 182 | 183 | > 📜 what is this `.mock.calls` thing? 184 | > https://jestjs.io/docs/en/mock-function-api#mockfnmockcalls 185 | 186 | ### 💯 Test everything else 187 | 188 | This controller has quite a few middleware and branches within those middleware 189 | that you can test. See how much of it you can get covered. 190 | 191 | You'll need to mock `../../db/list-items` and import it for a few of the tests, 192 | so watch out for that. 193 | 194 | ## 🦉 Elaboration & Feedback 195 | 196 | After the instruction, copy the URL below into your browser: 197 | http://ws.kcd.im/?ws=Testing%20Node%20Apps&e=Testing%20Controllers&em= 198 | -------------------------------------------------------------------------------- /src/routes/auth-controller.js: -------------------------------------------------------------------------------- 1 | import passport from 'passport' 2 | import { 3 | getSaltAndHash, 4 | userToJSON, 5 | getUserToken, 6 | isPasswordAllowed, 7 | } from '../utils/auth' 8 | import * as usersDB from '../db/users' 9 | 10 | const authUserToJSON = user => ({ 11 | ...userToJSON(user), 12 | token: getUserToken(user), 13 | }) 14 | 15 | async function register(req, res) { 16 | const {username, password} = req.body 17 | if (!username) { 18 | return res.status(400).json({message: `username can't be blank`}) 19 | } 20 | 21 | if (!password) { 22 | return res.status(400).json({message: `password can't be blank`}) 23 | } 24 | if (!isPasswordAllowed(password)) { 25 | return res.status(400).json({message: `password is not strong enough`}) 26 | } 27 | const existingUser = await usersDB.readByUsername(username) 28 | if (existingUser) { 29 | return res.status(400).json({message: `username taken`}) 30 | } 31 | const newUser = await usersDB.insert({ 32 | username, 33 | ...getSaltAndHash(password), 34 | }) 35 | return res.json({user: authUserToJSON(newUser)}) 36 | } 37 | 38 | async function login(req, res, next) { 39 | if (!req.body.username) { 40 | return res.status(400).json({message: `username can't be blank`}) 41 | } 42 | 43 | if (!req.body.password) { 44 | return res.status(400).json({message: `password can't be blank`}) 45 | } 46 | 47 | const {user, info} = await authenticate(req, res, next) 48 | 49 | if (user) { 50 | return res.json({user: authUserToJSON(user)}) 51 | } else { 52 | return res.status(400).json(info) 53 | } 54 | } 55 | 56 | function authenticate(req, res, next) { 57 | return new Promise((resolve, reject) => { 58 | passport.authenticate('local', {session: false}, (err, user, info) => { 59 | if (err) { 60 | reject(err) 61 | } else { 62 | resolve({user, info}) 63 | } 64 | })(req, res, next) 65 | }) 66 | } 67 | 68 | function me(req, res) { 69 | if (req.user) { 70 | return res.json({user: authUserToJSON(req.user)}) 71 | } else { 72 | return res.status(404).send() 73 | } 74 | } 75 | 76 | export {me, login, register} 77 | -------------------------------------------------------------------------------- /src/routes/auth.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import {authMiddleware} from '../utils/auth' 3 | import * as authController from './auth-controller' 4 | 5 | function getAuthRoutes() { 6 | const router = express.Router() 7 | 8 | router.post('/register', authController.register) 9 | router.post('/login', authController.login) 10 | router.get('/me', authMiddleware, authController.me) 11 | 12 | return router 13 | } 14 | 15 | export default getAuthRoutes 16 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import getAuthRouter from './auth' 3 | import getListItemsRoutes from './list-items' 4 | 5 | function getRouter() { 6 | const router = express.Router() 7 | router.use('/auth', getAuthRouter()) 8 | router.use('/list-items', getListItemsRoutes()) 9 | return router 10 | } 11 | 12 | export default getRouter 13 | -------------------------------------------------------------------------------- /src/routes/list-items-controller.js: -------------------------------------------------------------------------------- 1 | import * as listItemsDB from '../db/list-items' 2 | import * as booksDB from '../db/books' 3 | 4 | async function setListItem(req, res, next) { 5 | const {id} = req.params 6 | const listItem = await listItemsDB.readById(id) 7 | if (!listItem) { 8 | res 9 | .status(404) 10 | .json({message: `No list item was found with the id of ${id}`}) 11 | return 12 | } 13 | if (req.user.id === listItem.ownerId) { 14 | req.listItem = listItem 15 | next() 16 | } else { 17 | res.status(403).json({ 18 | message: `User with id ${req.user.id} is not authorized to access the list item ${id}`, 19 | }) 20 | } 21 | } 22 | 23 | async function getListItems(req, res) { 24 | const listItems = await listItemsDB.query({ownerId: req.user.id}) 25 | res.json({listItems: await expandBookDataMultiple(listItems)}) 26 | } 27 | 28 | async function getListItem(req, res) { 29 | res.json({listItem: await expandBookData(req.listItem)}) 30 | } 31 | 32 | async function createListItem(req, res) { 33 | const { 34 | user: {id: ownerId}, 35 | } = req 36 | const {bookId} = req.body 37 | if (!bookId) { 38 | res.status(400).json({message: `No bookId provided`}) 39 | return 40 | } 41 | const [existingListItem] = await listItemsDB.query({ownerId, bookId}) 42 | if (existingListItem) { 43 | res.status(400).json({ 44 | message: `User ${ownerId} already has a list item for the book with the ID ${bookId}`, 45 | }) 46 | return 47 | } 48 | 49 | const listItem = await listItemsDB.create({ownerId, bookId}) 50 | res.json({listItem: await expandBookData(listItem)}) 51 | } 52 | 53 | async function updateListItem(req, res) { 54 | const updatedListItem = await listItemsDB.update(req.listItem.id, req.body) 55 | res.json({listItem: await expandBookData(updatedListItem)}) 56 | } 57 | 58 | async function deleteListItem(req, res) { 59 | await listItemsDB.remove(req.listItem.id) 60 | res.json({success: true}) 61 | } 62 | 63 | async function expandBookData(listItem) { 64 | const book = await booksDB.readById(listItem.bookId) 65 | return {...listItem, book} 66 | } 67 | 68 | async function expandBookDataMultiple(listItems) { 69 | const books = await booksDB.readManyById(listItems.map(li => li.bookId)) 70 | return listItems.map(listItem => ({ 71 | ...listItem, 72 | book: books.find(book => book.id === listItem.bookId), 73 | })) 74 | } 75 | 76 | export { 77 | setListItem, 78 | getListItems, 79 | getListItem, 80 | createListItem, 81 | updateListItem, 82 | deleteListItem, 83 | } 84 | -------------------------------------------------------------------------------- /src/routes/list-items.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import {authMiddleware} from '../utils/auth' 3 | import * as listItemsController from './list-items-controller' 4 | 5 | function getListItemsRoutes() { 6 | const router = express.Router() 7 | 8 | router.get('/', authMiddleware, listItemsController.getListItems) 9 | 10 | router.get( 11 | '/:id', 12 | authMiddleware, 13 | listItemsController.setListItem, 14 | listItemsController.getListItem, 15 | ) 16 | 17 | router.post('/', authMiddleware, listItemsController.createListItem) 18 | 19 | router.put( 20 | '/:id', 21 | authMiddleware, 22 | listItemsController.setListItem, 23 | listItemsController.updateListItem, 24 | ) 25 | 26 | router.delete( 27 | '/:id', 28 | authMiddleware, 29 | listItemsController.setListItem, 30 | listItemsController.deleteListItem, 31 | ) 32 | 33 | return router 34 | } 35 | 36 | export default getListItemsRoutes 37 | -------------------------------------------------------------------------------- /src/start.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import bodyParser from 'body-parser' 3 | import cors from 'cors' 4 | import passport from 'passport' 5 | import logger from 'loglevel' 6 | import 'express-async-errors' 7 | import {getLocalStrategy} from './utils/auth' 8 | import errorMiddleware from './utils/error-middleware' 9 | import getRouter from './routes' 10 | 11 | function startServer({port = process.env.PORT} = {}) { 12 | const app = express() 13 | app.use(cors()) 14 | app.use(bodyParser.json()) 15 | app.use(passport.initialize()) 16 | passport.use(getLocalStrategy()) 17 | 18 | const router = getRouter() 19 | app.use('/api', router) 20 | app.use(errorMiddleware) 21 | 22 | return new Promise(resolve => { 23 | const server = app.listen(port, () => { 24 | logger.info(`Listening on port ${server.address().port}`) 25 | const originalClose = server.close.bind(server) 26 | server.close = () => { 27 | return new Promise(resolveClose => { 28 | originalClose(resolveClose) 29 | }) 30 | } 31 | resolve(server) 32 | }) 33 | }) 34 | } 35 | 36 | export default startServer 37 | -------------------------------------------------------------------------------- /src/utils/__tests__/auth.exercise.js: -------------------------------------------------------------------------------- 1 | // Testing Pure Functions 2 | 3 | // 💣 remove this todo test (it's only here so you don't get an error about missing tests) 4 | test.todo('remove me') 5 | 6 | // 🐨 import the function that we're testing 7 | // 💰 import {isPasswordAllowed} from '../auth' 8 | 9 | // 🐨 write tests for valid and invalid passwords 10 | // 💰 here are some you can use: 11 | // 12 | // valid: 13 | // - !aBc123 14 | // 15 | // invalid: 16 | // - a2c! // too short 17 | // - 123456! // no alphabet characters 18 | // - ABCdef! // no numbers 19 | // - abc123! // no uppercase letters 20 | // - ABC123! // no lowercase letters 21 | // - ABCdef123 // no non-alphanumeric characters 22 | -------------------------------------------------------------------------------- /src/utils/__tests__/auth.final.extra-1.js: -------------------------------------------------------------------------------- 1 | // Testing Pure Functions 2 | // 💯 reduce duplication 3 | 4 | import {isPasswordAllowed} from '../auth' 5 | 6 | describe('isPasswordAllowed only allows some passwords', () => { 7 | const allowedPasswords = ['!aBc123'] 8 | const disallowedPasswords = [ 9 | 'a2c!', 10 | '123456!', 11 | 'ABCdef!', 12 | 'abc123!', 13 | 'ABC123!', 14 | 'ABCdef123', 15 | ] 16 | 17 | allowedPasswords.forEach(password => { 18 | test(`allows ${password}`, () => { 19 | expect(isPasswordAllowed(password)).toBe(true) 20 | }) 21 | }) 22 | 23 | disallowedPasswords.forEach(password => { 24 | test(`disallows ${password}`, () => { 25 | expect(isPasswordAllowed(password)).toBe(false) 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/utils/__tests__/auth.final.extra-2.js: -------------------------------------------------------------------------------- 1 | // Testing Pure Functions 2 | // 💯 jest-in-case 3 | 4 | import cases from 'jest-in-case' 5 | import {isPasswordAllowed} from '../auth' 6 | 7 | cases( 8 | 'isPasswordAllowed: valid passwords', 9 | ({password}) => { 10 | expect(isPasswordAllowed(password)).toBe(true) 11 | }, 12 | { 13 | 'valid password': { 14 | password: '!aBc123', 15 | }, 16 | }, 17 | ) 18 | 19 | cases( 20 | 'isPasswordAllowed: invalid passwords', 21 | ({password}) => { 22 | expect(isPasswordAllowed(password)).toBe(false) 23 | }, 24 | { 25 | 'too short': { 26 | password: 'a2c!', 27 | }, 28 | 'no letters': { 29 | password: '123456!', 30 | }, 31 | 'no numbers': { 32 | password: 'ABCdef!', 33 | }, 34 | 'no uppercase letters': { 35 | password: 'abc123!', 36 | }, 37 | 'no lowercase letters': { 38 | password: 'ABC123!', 39 | }, 40 | 'no non-alphanumeric characters': { 41 | password: 'ABCdef123', 42 | }, 43 | }, 44 | ) 45 | -------------------------------------------------------------------------------- /src/utils/__tests__/auth.final.extra-3.js: -------------------------------------------------------------------------------- 1 | // Testing Pure Functions 2 | // 💯 improved titles for jest-in-case 3 | 4 | import cases from 'jest-in-case' 5 | import {isPasswordAllowed} from '../auth' 6 | 7 | function casify(obj) { 8 | return Object.entries(obj).map(([name, password]) => ({ 9 | name: `${password} - ${name}`, 10 | password, 11 | })) 12 | } 13 | 14 | cases( 15 | 'isPasswordAllowed: valid passwords', 16 | ({password}) => { 17 | expect(isPasswordAllowed(password)).toBe(true) 18 | }, 19 | casify({'valid password': '!aBc123'}), 20 | ) 21 | 22 | cases( 23 | 'isPasswordAllowed: invalid passwords', 24 | ({password}) => { 25 | expect(isPasswordAllowed(password)).toBe(false) 26 | }, 27 | casify({ 28 | 'too short': 'a2c!', 29 | 'no letters': '123456!', 30 | 'no numbers': 'ABCdef!', 31 | 'no uppercase letters': 'abc123!', 32 | 'no lowercase letters': 'ABC123!', 33 | 'no non-alphanumeric characters': 'ABCdef123', 34 | }), 35 | ) 36 | -------------------------------------------------------------------------------- /src/utils/__tests__/auth.final.js: -------------------------------------------------------------------------------- 1 | // Testing Pure Functions 2 | 3 | import {isPasswordAllowed} from '../auth' 4 | 5 | test('isPasswordAllowed returns true for valid passwords', () => { 6 | expect(isPasswordAllowed('!aBc123')).toBe(true) 7 | }) 8 | 9 | test('isPasswordAllowed returns false for invalid passwords', () => { 10 | expect(isPasswordAllowed('a2c!')).toBe(false) 11 | expect(isPasswordAllowed('123456!')).toBe(false) 12 | expect(isPasswordAllowed('ABCdef!')).toBe(false) 13 | expect(isPasswordAllowed('abc123!')).toBe(false) 14 | expect(isPasswordAllowed('ABC123!')).toBe(false) 15 | expect(isPasswordAllowed('ABCdef123')).toBe(false) 16 | }) 17 | -------------------------------------------------------------------------------- /src/utils/__tests__/auth.md: -------------------------------------------------------------------------------- 1 | # Testing Pure Functions 2 | 3 | ## Background 4 | 5 | Here's what a Jest test looks like: 6 | 7 | ```javascript 8 | // add.js 9 | export default (a, b) => a + b 10 | 11 | // __tests__/add.js 12 | import add from '../add' 13 | 14 | test('add returns the sum of the two given numbers', () => { 15 | const result = add(1, 2) 16 | expect(result).toBe(3) 17 | }) 18 | ``` 19 | 20 | The `test` and `expect` functions are global variables provided to us from Jest. 21 | (📜 find all the global variables here: https://jestjs.io/docs/en/api). 22 | 23 | `expect` is an assertion library that's built into Jest and has a lot of 24 | assertions you can use: (📜 find out about those assertions here: 25 | https://jestjs.io/docs/en/expect, 🦉 you may want to keep that page open for 26 | reference). 27 | 28 | A "pure function" is a function with the following properties: 29 | 30 | 1. Its return value is the same for the same arguments (no variation with local 31 | static variables, non-local variables, mutable reference arguments or input 32 | streams from I/O devices). 33 | 2. Its evaluation has no side effects (no mutation of local static variables, 34 | non-local variables, mutable reference arguments or I/O streams). 35 | 36 | > 📜 Read more on wikipedia: https://en.wikipedia.org/wiki/Pure_function 37 | 38 | Thanks to these properties, pure functions are typically the easiest thing to 39 | test. And because of _this_, it's often advisable to place as much of your 40 | complex logic in pure functions as possible. That way you can write tests for 41 | that logic very easily, giving you confidence that it continues to work as 42 | intended over time. 43 | 44 | ## Exercise 45 | 46 | In this exercise, we have a `isPasswordAllowed` function we can call with a 47 | string and it returns `true` or `false` based on whether that password is strong 48 | enough. The implementation of `isPasswordAllowed` enforces that the password is 49 | at least 6 characters long and has at least one of the following characters: 50 | 51 | 1. non-alphanumeric 52 | 2. digit 53 | 3. uppercase letter 54 | 4. lowercase letter 55 | 56 | For us to be confident that `isPasswordAllowed` will continue to function as 57 | expected, we should have a test case for each of these (as well as one which 58 | does pass the requirements). 59 | 60 | Your job is to completely test the `isPasswordAllowed` function in 61 | `src/utils/__tests__/auth.exercise.js`. 62 | 63 | ## Extra Credit 64 | 65 | ### 💯 reduce duplication 66 | 67 | Depending on how this is solved we could have one of two problems: 68 | 69 | 1. We have an individual test for every assertion: This results in a lot of 70 | duplication, which can lead to typo mistakes 71 | 2. We have one giant test: This results in less helpful error messages. 72 | 73 | See if you can figure out a way to programmatically generate the tests (using 74 | arrays/loops) so we get both good error messages as well as avoiding 75 | duplication. 76 | 77 | ### 💯 jest-in-case 78 | 79 | Unfortunately for these generated tests, the error messages may still not be 80 | quite what we're looking for. In addition, we may actually be writing more code 81 | for this abstraction than before. So instead, let's use a module called 82 | `jest-in-case` to test this. 83 | 84 | 📜 https://github.com/atlassian/jest-in-case 85 | 86 | If I were to use `jest-in-case` for the `add` function as above, it would look 87 | like this: 88 | 89 | ```javascript 90 | import cases from 'jest-in-case' 91 | 92 | cases( 93 | 'add', 94 | opts => { 95 | const result = add(opts.a, opts.b) 96 | expect(result).toBe(opts.result) 97 | }, 98 | { 99 | 'sum of the two given numbers': { 100 | a: 1, 101 | b: 2, 102 | result: 3, 103 | }, 104 | }, 105 | ) 106 | ``` 107 | 108 | Clearly using `jest-in-case` for this simple function is going a bit overboard, 109 | but doing this kind of thing for more complex pure functions with lots of cases 110 | can simplify maintenance big time. 111 | 112 | Here's an example of an open source project where I applied this similar kind of 113 | pattern (before `jest-in-case` was a thing): 114 | [`rtl-css-js` tests](https://github.com/kentcdodds/rtl-css-js/blob/25cb86e411c0c0177307bbf66246740c4d5e5adf/src/__tests__/index.js). 115 | And here's another: 116 | [`match-sorter` tests](https://github.com/kentcdodds/match-sorter/blob/master/src/__tests__/index.js) 117 | (also wrote this before `jest-in-case` was a thing). 118 | 119 | I've been the maintainer of these projects for years and people have commented 120 | on how easy it is to contribute to because the tests are so easy to add to, and 121 | once you've added a test case, you can go implement that feature easily. This is 122 | a perfect tool for Test-Driven Development. 123 | 124 | > Note, as mentioned, both of these were written before `jest-in-case` was 125 | > created and I haven't taken the time to upgrade them. If I were actively 126 | > working on these projects then I would definitely migrate them. I have 127 | > definitely used `jest-in-case` in products I've worked on and I love it. 128 | 129 | ### 💯 improved titles for jest-in-case 130 | 131 | The naive implementation of `jest-in-case` here may result in good, but still 132 | imperfect test names which has an impact on the error messages you'll get. Try 133 | to make it so the test name includes not only the reason a given password is 134 | valid/invalid, but also what the password is. 135 | 136 | ## 🦉 Elaboration & Feedback 137 | 138 | After the instruction, copy the URL below into your browser: 139 | http://ws.kcd.im/?ws=Testing%20Node%20Apps&e=Testing%20Pure%20Functions&em= 140 | -------------------------------------------------------------------------------- /src/utils/__tests__/error-middleware.exercise.js: -------------------------------------------------------------------------------- 1 | // Testing Middleware 2 | 3 | // 💣 remove this todo test (it's only here so you don't get an error about missing tests) 4 | test.todo('remove me') 5 | 6 | // 🐨 you'll need both of these: 7 | // import {UnauthorizedError} from 'express-jwt' 8 | // import errorMiddleware from '../error-middleware' 9 | 10 | // 🐨 Write a test for the UnauthorizedError case 11 | // 💰 const error = new UnauthorizedError('some_error_code', {message: 'Some message'}) 12 | // 💰 const res = {json: jest.fn(() => res), status: jest.fn(() => res)} 13 | 14 | // 🐨 Write a test for the headersSent case 15 | 16 | // 🐨 Write a test for the else case (responds with a 500) 17 | -------------------------------------------------------------------------------- /src/utils/__tests__/error-middleware.final.extra-1.js: -------------------------------------------------------------------------------- 1 | // Testing Middleware 2 | // 💯 write a test object factory 3 | 4 | import {UnauthorizedError} from 'express-jwt' 5 | import errorMiddleware from '../error-middleware' 6 | 7 | function buildRes(overrides) { 8 | const res = { 9 | json: jest.fn(() => res), 10 | status: jest.fn(() => res), 11 | ...overrides, 12 | } 13 | return res 14 | } 15 | 16 | test('responds with 401 for express-jwt UnauthorizedError', () => { 17 | const req = {} 18 | const res = buildRes() 19 | const next = jest.fn() 20 | const code = 'fake_code' 21 | const message = 'Fake Error Message' 22 | const error = new UnauthorizedError(code, {message}) 23 | errorMiddleware(error, req, res, next) 24 | expect(next).not.toHaveBeenCalled() 25 | expect(res.status).toHaveBeenCalledWith(401) 26 | expect(res.status).toHaveBeenCalledTimes(1) 27 | expect(res.json).toHaveBeenCalledWith({ 28 | code: error.code, 29 | message: error.message, 30 | }) 31 | expect(res.json).toHaveBeenCalledTimes(1) 32 | }) 33 | 34 | test('calls next if headersSent is true', () => { 35 | const req = {} 36 | const res = buildRes({headersSent: true}) 37 | const next = jest.fn() 38 | const error = new Error('blah') 39 | errorMiddleware(error, req, res, next) 40 | expect(next).toHaveBeenCalledWith(error) 41 | expect(res.status).not.toHaveBeenCalled() 42 | expect(res.json).not.toHaveBeenCalled() 43 | }) 44 | 45 | test('responds with 500 and the error object', () => { 46 | const req = {} 47 | const res = buildRes() 48 | const next = jest.fn() 49 | const error = new Error('blah') 50 | errorMiddleware(error, req, res, next) 51 | expect(next).not.toHaveBeenCalled() 52 | expect(res.status).toHaveBeenCalledWith(500) 53 | expect(res.status).toHaveBeenCalledTimes(1) 54 | expect(res.json).toHaveBeenCalledWith({ 55 | message: error.message, 56 | stack: error.stack, 57 | }) 58 | expect(res.json).toHaveBeenCalledTimes(1) 59 | }) 60 | -------------------------------------------------------------------------------- /src/utils/__tests__/error-middleware.final.extra-2.js: -------------------------------------------------------------------------------- 1 | // Testing Middleware 2 | // 💯 use utils/generate 3 | 4 | import {buildRes, buildReq, buildNext} from 'utils/generate' 5 | import {UnauthorizedError} from 'express-jwt' 6 | import errorMiddleware from '../error-middleware' 7 | 8 | test('responds with 401 for express-jwt UnauthorizedError', () => { 9 | const req = buildReq() 10 | const res = buildRes() 11 | const next = buildNext() 12 | const code = 'fake_code' 13 | const message = 'Fake Error Message' 14 | const error = new UnauthorizedError(code, {message}) 15 | errorMiddleware(error, req, res, next) 16 | expect(next).not.toHaveBeenCalled() 17 | expect(res.status).toHaveBeenCalledWith(401) 18 | expect(res.status).toHaveBeenCalledTimes(1) 19 | expect(res.json).toHaveBeenCalledWith({ 20 | code: error.code, 21 | message: error.message, 22 | }) 23 | expect(res.json).toHaveBeenCalledTimes(1) 24 | }) 25 | 26 | test('calls next if headersSent is true', () => { 27 | const req = buildReq() 28 | const res = buildRes({headersSent: true}) 29 | const next = buildNext() 30 | const error = new Error('blah') 31 | errorMiddleware(error, req, res, next) 32 | expect(next).toHaveBeenCalledWith(error) 33 | expect(res.status).not.toHaveBeenCalled() 34 | expect(res.json).not.toHaveBeenCalled() 35 | }) 36 | 37 | test('responds with 500 and the error object', () => { 38 | const req = buildReq() 39 | const res = buildRes() 40 | const next = buildNext() 41 | const error = new Error('blah') 42 | errorMiddleware(error, req, res, next) 43 | expect(next).not.toHaveBeenCalled() 44 | expect(res.status).toHaveBeenCalledWith(500) 45 | expect(res.status).toHaveBeenCalledTimes(1) 46 | expect(res.json).toHaveBeenCalledWith({ 47 | message: error.message, 48 | stack: error.stack, 49 | }) 50 | expect(res.json).toHaveBeenCalledTimes(1) 51 | }) 52 | -------------------------------------------------------------------------------- /src/utils/__tests__/error-middleware.final.js: -------------------------------------------------------------------------------- 1 | // Testing Middleware 2 | 3 | import {UnauthorizedError} from 'express-jwt' 4 | import errorMiddleware from '../error-middleware' 5 | 6 | test('responds with 401 for express-jwt UnauthorizedError', () => { 7 | const req = {} 8 | const res = { 9 | json: jest.fn(() => res), 10 | status: jest.fn(() => res), 11 | } 12 | const next = jest.fn() 13 | const error = new UnauthorizedError('fake_code', { 14 | message: 'Fake Error Message', 15 | }) 16 | errorMiddleware(error, req, res, next) 17 | expect(next).not.toHaveBeenCalled() 18 | expect(res.status).toHaveBeenCalledWith(401) 19 | expect(res.status).toHaveBeenCalledTimes(1) 20 | expect(res.json).toHaveBeenCalledWith({ 21 | code: error.code, 22 | message: error.message, 23 | }) 24 | expect(res.json).toHaveBeenCalledTimes(1) 25 | }) 26 | 27 | test('calls next if headersSent is true', () => { 28 | const req = {} 29 | const res = { 30 | json: jest.fn(() => res), 31 | status: jest.fn(() => res), 32 | headersSent: true, 33 | } 34 | const next = jest.fn() 35 | const error = new Error('blah') 36 | errorMiddleware(error, req, res, next) 37 | expect(next).toHaveBeenCalledWith(error) 38 | expect(res.status).not.toHaveBeenCalled() 39 | expect(res.json).not.toHaveBeenCalled() 40 | }) 41 | 42 | test('responds with 500 and the error object', () => { 43 | const req = {} 44 | const res = { 45 | json: jest.fn(() => res), 46 | status: jest.fn(() => res), 47 | } 48 | const next = jest.fn() 49 | const error = new Error('blah') 50 | errorMiddleware(error, req, res, next) 51 | expect(next).not.toHaveBeenCalled() 52 | expect(res.status).toHaveBeenCalledWith(500) 53 | expect(res.status).toHaveBeenCalledTimes(1) 54 | expect(res.json).toHaveBeenCalledWith({ 55 | message: error.message, 56 | stack: error.stack, 57 | }) 58 | expect(res.json).toHaveBeenCalledTimes(1) 59 | }) 60 | -------------------------------------------------------------------------------- /src/utils/__tests__/error-middleware.md: -------------------------------------------------------------------------------- 1 | # Testing Middleware 2 | 3 | ## Background 4 | 5 | Whether you're using Express, Koa, Nest, Hapi.js or just about any other Node.js 6 | framework, you're going to have some concept of middleware/plugins. Our app is 7 | using Express and the tests you'll be writing in this exercise are specific to 8 | Express, but focus more on the concepts you're learning here and you should be 9 | able to apply them to whatever Node server framework you're using. 10 | 11 | Express has several different types of middleware 12 | ([📜 read using middleware here](https://expressjs.com/en/guide/using-middleware.html)). 13 | 14 | - Application-level middleware (our app isn't really using this kind) 15 | - Router-level middleware (all our routes use this strategy of middleware) 16 | - Error-handling middleware (this is what `error-middleware.js` is) 17 | - Built-in middleware (we're not using any of these) 18 | - Third-party middleware (we're using a few of these, like `cors`, 19 | `body-parser`, `express-jwt`, and `passport`). 20 | 21 | For each of these kinds of middleware, they accept arguments like `request`, 22 | `response`, and `next` and they're expected to either call a `response` method 23 | to send a response to the caller, or call the `next` method to continue the 24 | chain of middleware. Here's an example of a middleware function: 25 | 26 | ```javascript 27 | function timeLogger(req, res, next) { 28 | console.log('Time:', Date.now()) 29 | next() 30 | } 31 | ``` 32 | 33 | One special case is for error middleware which acts as an error handler. For 34 | that case, it also accepts an `error` argument. 35 | 36 | ```javascript 37 | function errorHandler(err, req, res, next) { 38 | console.error(err.stack) 39 | res.status(500).send('Something broke!') 40 | } 41 | ``` 42 | 43 | Middleware have different purposes. For example, the `setListItem` middleware in 44 | `src/routes/list-items-controller.js` is responsible for finding the requested 45 | `listItem` by its ID, determining whether the current user is able to access 46 | that `listItem`, add adding that `listItem` to the `req` so later middleware can 47 | access it. 48 | 49 | ## Exercise 50 | 51 | The purpose of our `errorMiddleware` is to catch errors which have happened 52 | throughout the app but haven't been caught, and responding with as good an error 53 | response as possible. Ours handles three distinct cases: 54 | 55 | 1. An error was thrown, but a response has already been sent (so we don't need 56 | to send another one). 57 | 2. An `UnauthorizedError` was thrown by the `express-jwt` middleware 58 | 3. An unknown error was thrown and no response has been sent yet 59 | 60 | We need a test for each of these cases. For each of these, you'll need to create 61 | your own `error`, `req`, `res`, and `next`. You'll need to know how to create 62 | mock functions. Here are a few quick examples of using mock functions: 63 | 64 | ```javascript 65 | const myFn = jest.fn(() => 42) 66 | const result = myFn({message: 'hello'}) 67 | 68 | expect(result).toBe(42) 69 | expect(myFn).toHaveBeenCalledWith({message: 'hello'}) 70 | expect(myFn).toHaveBeenCalledTimes(1) 71 | ``` 72 | 73 | That's all you need to know for this exercise, but there's a lot more to learn 74 | about them! 75 | 76 | > 📜 Learn more about mock functions: https://jestjs.io/docs/en/mock-functions 77 | > 📜 Mock Function API Docs: https://jestjs.io/docs/en/mock-function-api.html 78 | 79 | ## Extra Credit 80 | 81 | ### 💯 write a test object factory 82 | 83 | A Test Object Factory (also referred to as 84 | [an "Object Mother"](https://martinfowler.com/bliki/ObjectMother.html)). It's 85 | basically just changing this: 86 | 87 | ```javascript 88 | const myTestObject = {a: 'b', c: 'd'} 89 | ``` 90 | 91 | To this: 92 | 93 | ```javascript 94 | function getMyTestObject(overrides) { 95 | return {a: 'b', c: 'q', ...overrides} 96 | } 97 | 98 | const myTestObject = getMyTestObject({c: 'd'}) 99 | ``` 100 | 101 | It's a function which returns the standard version of some object you need to 102 | create in multiple tests and allows for simple overrides. 103 | 104 | One of the benefits to test object factories is code reuse/reducing duplication, 105 | but my favorite benefit to test object factories is how it communicates what's 106 | actually important to the people reading the test later. Especially in larger 107 | codebases where the `req` object needs to have a dozen properties just to be 108 | usable, it can be hard to determine what the important properties of that `req` 109 | object is. 110 | 111 | It's much easier to say: Hey, give me a normal `req` object, but I want it to 112 | have these specific properties, because _these are important to my test_. 113 | Through the code, you can communicate clearly what parts of the `req` object 114 | make an impact on the unit under test and reduce code duplication at the same 115 | time. Everyone wins. 116 | 117 | You can see an example of something like this in my blog post 118 | [AHA Testing](https://kentcdodds.com/blog/aha-testing). 119 | 120 | In this extra credit, try to create a test object factory for the things you see 121 | as common across these tests. 122 | 123 | > 🦉 One thing to be careful of is that you don't want your test object 124 | > factories to get overly complex. If they do, then you may actually be making 125 | > things worse. Keep them really simple and if you have to, make different 126 | > factories for creating different kinds of test objects. 127 | 128 | ### 💯 use `utils/generate` 129 | 130 | There are some test object factories that are useful throughout the testbase. 131 | The `req`, `res`, and `next` arguments definitely fall into this category. In 132 | fact we've already written test object factories for them! They're in the 133 | `test/utils/generate.js` file. 134 | 135 | For this extra credit, use those test object factories. 136 | 137 | 💰 tip, because the way Jest is configured in this project, you can import that 138 | directly like this: 139 | `import {buildRes, buildReq, buildNext} from 'utils/generate'` 140 | 141 | ## 🦉 Elaboration & Feedback 142 | 143 | After the instruction, copy the URL below into your browser: 144 | http://ws.kcd.im/?ws=Testing%20Node%20Apps&e=Testing%20Middleware&em= 145 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import expressJWT from 'express-jwt' 3 | import LocalStrategy from 'passport-local' 4 | import {omit} from 'lodash' 5 | import jwt from 'jsonwebtoken' 6 | import * as usersDB from '../db/users' 7 | 8 | // in a real app this would be set in an environment variable 9 | const secret = 'secret' 10 | 11 | // reducing the iterations to 1 in non-production environments to make it faster 12 | const iterations = process.env.NODE_ENV === 'production' ? 1000 : 1 13 | 14 | // seconds/minute * minutes/hour * hours/day * 60 days 15 | const sixtyDaysInSeconds = 60 * 60 * 24 * 60 16 | // to keep our tests reliable, we'll use the requireTime if we're not in production 17 | // and we'll use Date.now() if we are. 18 | const requireTime = Date.now() 19 | const now = () => 20 | process.env.NODE_ENV === 'production' ? Date.now() : requireTime 21 | 22 | function getSaltAndHash(password) { 23 | const salt = crypto.randomBytes(16).toString('hex') 24 | const hash = crypto 25 | .pbkdf2Sync(password, salt, iterations, 512, 'sha512') 26 | .toString('hex') 27 | return {salt, hash} 28 | } 29 | 30 | function isPasswordValid(password, {salt, hash}) { 31 | return ( 32 | hash === 33 | crypto.pbkdf2Sync(password, salt, iterations, 512, 'sha512').toString('hex') 34 | ) 35 | } 36 | 37 | function getUserToken({id, username}) { 38 | const issuedAt = Math.floor(now() / 1000) 39 | return jwt.sign( 40 | { 41 | id, 42 | username, 43 | iat: issuedAt, 44 | exp: issuedAt + sixtyDaysInSeconds, 45 | }, 46 | secret, 47 | ) 48 | } 49 | 50 | const authMiddleware = expressJWT({algorithms: ['HS256'], secret}) 51 | 52 | function getLocalStrategy() { 53 | return new LocalStrategy(async (username, password, done) => { 54 | let user 55 | try { 56 | user = await usersDB.readByUsername(username) 57 | } catch (error) { 58 | return done(error) 59 | } 60 | if (!user || !isPasswordValid(password, user)) { 61 | return done(null, false, { 62 | message: 'username or password is invalid', 63 | }) 64 | } 65 | return done(null, userToJSON(user)) 66 | }) 67 | } 68 | 69 | function userToJSON(user) { 70 | return omit(user, ['exp', 'iat', 'hash', 'salt']) 71 | } 72 | 73 | function isPasswordAllowed(password) { 74 | return ( 75 | password.length > 6 && 76 | // non-alphanumeric 77 | /\W/.test(password) && 78 | // digit 79 | /\d/.test(password) && 80 | // capital letter 81 | /[A-Z]/.test(password) && 82 | // lowercase letter 83 | /[a-z]/.test(password) 84 | ) 85 | } 86 | 87 | export { 88 | authMiddleware, 89 | getSaltAndHash, 90 | userToJSON, 91 | getLocalStrategy, 92 | getUserToken, 93 | isPasswordAllowed, 94 | } 95 | -------------------------------------------------------------------------------- /src/utils/error-middleware.js: -------------------------------------------------------------------------------- 1 | import {UnauthorizedError} from 'express-jwt' 2 | 3 | function errorMiddleware(error, req, res, next) { 4 | if (res.headersSent) { 5 | next(error) 6 | } else if (error instanceof UnauthorizedError) { 7 | res.status(401) 8 | res.json({code: error.code, message: error.message}) 9 | } else { 10 | res.status(500) 11 | res.json({ 12 | message: error.message, 13 | // we only add a `stack` property in non-production environments 14 | ...(process.env.NODE_ENV === 'production' ? null : {stack: error.stack}), 15 | }) 16 | } 17 | } 18 | 19 | export default errorMiddleware 20 | -------------------------------------------------------------------------------- /test/jest.config.exercises.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | displayName: 'exercise', 5 | roots: [path.join(__dirname, '../src')], 6 | rootDir: path.join(__dirname, '..'), 7 | testEnvironment: 'node', 8 | testMatch: ['**/__tests__/*.exercise.*'], 9 | moduleDirectories: [ 10 | 'node_modules', 11 | __dirname, 12 | path.join(__dirname, '../src'), 13 | ], 14 | coverageDirectory: path.join(__dirname, '../coverage-exercises'), 15 | coveragePathIgnorePatterns: ['.*/__tests__/.*'], 16 | setupFilesAfterEnv: [require.resolve('./setup-env')], 17 | watchPlugins: [ 18 | require.resolve('jest-watch-typeahead/filename'), 19 | require.resolve('jest-watch-typeahead/testname'), 20 | ], 21 | } 22 | -------------------------------------------------------------------------------- /test/jest.config.final.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | displayName: 'final', 5 | roots: [path.join(__dirname, '../src')], 6 | rootDir: path.join(__dirname, '..'), 7 | testEnvironment: 'node', 8 | testMatch: ['**/__tests__/*.final.*'], 9 | moduleDirectories: [ 10 | 'node_modules', 11 | __dirname, 12 | path.join(__dirname, '../src'), 13 | ], 14 | coverageDirectory: path.join(__dirname, '../coverage'), 15 | collectCoverageFrom: ['**/src/**/*.js'], 16 | coveragePathIgnorePatterns: ['.*/__tests__/.*'], 17 | setupFilesAfterEnv: [require.resolve('./setup-env')], 18 | watchPlugins: [ 19 | require.resolve('jest-watch-typeahead/filename'), 20 | require.resolve('jest-watch-typeahead/testname'), 21 | ], 22 | } 23 | -------------------------------------------------------------------------------- /test/jest.config.projects.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | roots: [path.join(__dirname, '../src')], 5 | rootDir: path.join(__dirname, '..'), 6 | testEnvironment: 'node', 7 | testMatch: ['**/__tests__/**'], 8 | moduleDirectories: [ 9 | 'node_modules', 10 | __dirname, 11 | path.join(__dirname, '../src'), 12 | ], 13 | coverageDirectory: path.join(__dirname, '../coverage/collective'), 14 | collectCoverageFrom: ['**/src/**/*.js'], 15 | coveragePathIgnorePatterns: ['.*/__tests__/.*'], 16 | projects: [ 17 | require.resolve('./jest.config.exercises'), 18 | require.resolve('./jest.config.final'), 19 | ], 20 | watchPlugins: [ 21 | require.resolve('jest-watch-select-projects'), 22 | require.resolve('jest-watch-typeahead/filename'), 23 | require.resolve('jest-watch-typeahead/testname'), 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /test/setup-env.js: -------------------------------------------------------------------------------- 1 | process.env.PORT = 0 2 | -------------------------------------------------------------------------------- /test/utils/async.js: -------------------------------------------------------------------------------- 1 | const getData = res => res.data 2 | const handleRequestFailure = ({response: {status, data}}) => { 3 | const error = new Error(`${status}: ${JSON.stringify(data)}`) 4 | // remove parts of the stack trace so the error message (codeframe) shows up 5 | // at the code where the actual problem is. 6 | error.stack = error.stack 7 | .split('\n') 8 | .filter( 9 | line => 10 | !line.includes('at handleRequestFailure') && 11 | !line.includes('at processTicksAndRejections'), 12 | ) 13 | .join('\n') 14 | error.status = status 15 | error.data = data 16 | return Promise.reject(error) 17 | } 18 | 19 | const resolve = e => e 20 | 21 | export {getData, handleRequestFailure, resolve} 22 | -------------------------------------------------------------------------------- /test/utils/db-utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import * as booksDB from '../../src/db/books' 3 | import * as usersDB from '../../src/db/users' 4 | import * as listItemsDB from '../../src/db/list-items' 5 | import {getUserToken} from '../../src/utils/auth' 6 | import * as generate from './generate' 7 | 8 | async function initDb({ 9 | books = Array.from({length: 100}, () => generate.buildBook()), 10 | users = Array.from({length: 10}, () => generate.buildUser()), 11 | listItems = _.flatten( 12 | users.map(u => 13 | Array.from({length: Math.floor(Math.random() * 4)}, () => 14 | generate.buildListItem({ownerId: u.id, bookId: random(books).id}), 15 | ), 16 | ), 17 | ), 18 | } = {}) { 19 | await Promise.all([ 20 | booksDB.insertMany(books), 21 | usersDB.insertMany(users), 22 | listItemsDB.insertMany(listItems), 23 | ]) 24 | return {books, users, listItems} 25 | } 26 | 27 | function random(array) { 28 | return array[Math.floor(Math.random() * array.length)] 29 | } 30 | 31 | async function insertTestUser( 32 | testUser = generate.buildUser({ 33 | username: 'joe', 34 | password: 'joe', 35 | }), 36 | ) { 37 | await usersDB.insert(testUser) 38 | return {...testUser, token: getUserToken(testUser)} 39 | } 40 | 41 | async function resetDb() { 42 | await listItemsDB.drop() 43 | await usersDB.drop() 44 | await booksDB.drop() 45 | } 46 | 47 | export {resetDb, initDb, insertTestUser, generate} 48 | -------------------------------------------------------------------------------- /test/utils/generate.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import {getUserToken, getSaltAndHash} from '../../src/utils/auth' 3 | 4 | // passwords must have at least these kinds of characters to be valid, so we'll 5 | // prefex all of the ones we generate with `!0_Oo` to ensure it's valid. 6 | const getPassword = (...args) => `!0_Oo${faker.internet.password(...args)}` 7 | const getUsername = faker.internet.userName 8 | const getId = faker.random.uuid 9 | const getSynopsis = faker.lorem.paragraph 10 | const getNotes = faker.lorem.paragraph 11 | 12 | function buildUser({password = getPassword(), ...overrides} = {}) { 13 | return { 14 | id: getId(), 15 | username: getUsername(), 16 | ...getSaltAndHash(password), 17 | ...overrides, 18 | } 19 | } 20 | 21 | function buildBook(overrides) { 22 | return { 23 | id: getId(), 24 | title: faker.lorem.words(), 25 | author: faker.name.findName(), 26 | coverImageUrl: faker.image.imageUrl(), 27 | pageCount: faker.random.number(400), 28 | publisher: faker.company.companyName(), 29 | synopsis: faker.lorem.paragraph(), 30 | ...overrides, 31 | } 32 | } 33 | 34 | function buildListItem(overrides = {}) { 35 | const { 36 | bookId = overrides.book ? overrides.book.id : getId(), 37 | startDate = faker.date.past(2), 38 | finishDate = faker.date.between(startDate, new Date()), 39 | owner = {ownerId: getId()}, 40 | } = overrides 41 | return { 42 | id: getId(), 43 | bookId, 44 | ownerId: owner.id, 45 | rating: faker.random.number(5), 46 | notes: faker.random.boolean() ? '' : getNotes(), 47 | finishDate, 48 | startDate, 49 | ...overrides, 50 | } 51 | } 52 | 53 | function token(user) { 54 | return getUserToken(buildUser(user)) 55 | } 56 | 57 | function loginForm(overrides) { 58 | return { 59 | username: getUsername(), 60 | password: getPassword(), 61 | ...overrides, 62 | } 63 | } 64 | 65 | function buildReq({user = buildUser(), ...overrides} = {}) { 66 | const req = {user, body: {}, params: {}, ...overrides} 67 | return req 68 | } 69 | 70 | function buildRes(overrides = {}) { 71 | const res = { 72 | json: jest.fn(() => res).mockName('json'), 73 | status: jest.fn(() => res).mockName('status'), 74 | ...overrides, 75 | } 76 | return res 77 | } 78 | 79 | function buildNext(impl) { 80 | return jest.fn(impl).mockName('next') 81 | } 82 | 83 | export { 84 | buildReq, 85 | buildRes, 86 | buildNext, 87 | buildUser, 88 | buildListItem, 89 | buildBook, 90 | token, 91 | loginForm, 92 | getPassword as password, 93 | getUsername as username, 94 | getId as id, 95 | getSynopsis as synopsis, 96 | getNotes as notes, 97 | } 98 | --------------------------------------------------------------------------------