├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------