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