├── .env ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .storybook ├── main.ts ├── preview-head.html └── preview.tsx ├── README.md ├── assets ├── screenshot-checkout.png ├── screenshot-home.png └── screenshot-orders.png ├── cypress.json ├── cypress ├── fixtures │ ├── example.json │ ├── profile.json │ └── users.json ├── integration │ └── checkout.spec.ts ├── plugins │ └── index.js ├── support │ ├── commands.js │ └── index.js └── tsconfig.json ├── docs ├── avoid-mocking-by-using-mock-service-worker.md ├── checking-for-existence-of-an-element.md ├── difference-between-query-types.md ├── fireEvent-vs-userEvent.md ├── functional-vs-snapshot-vs-screenshot-testing.md ├── mocking-an-event-handler.md ├── overriding-msw-handlers.md ├── setting-up-react-testing-library.md ├── suppressing-console-errors.md ├── testing-page-navigation.md ├── waiting-for-removal-of-an-element.md └── waiting-for-something-to-happen.md ├── package.json ├── public ├── env.js ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── mockServiceWorker.js └── robots.txt ├── src ├── App.test.tsx ├── App.tsx ├── components │ ├── AddressForm │ │ ├── AddressForm.stories.tsx │ │ ├── AddressForm.test.tsx │ │ ├── AddressForm.tsx │ │ └── index.ts │ ├── AddressView │ │ ├── AddressView.stories.tsx │ │ ├── AddressView.test.tsx │ │ ├── AddressView.tsx │ │ ├── __snapshots__ │ │ │ └── AddressView.test.tsx.snap │ │ └── index.ts │ ├── Containers │ │ ├── Containers.stories.tsx │ │ ├── Containers.test.tsx │ │ ├── Containers.tsx │ │ ├── __snapshots__ │ │ │ └── Containers.test.tsx.snap │ │ └── index.ts │ ├── ErrorBoundary │ │ ├── ErrorBoundary.test.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── ErrorFallbackComponent.stories.tsx │ │ ├── ErrorFallbackComponent.tsx │ │ └── index.ts │ ├── Form │ │ ├── ErrorMessage.tsx │ │ ├── TextField.css │ │ ├── TextField.stories.tsx │ │ ├── TextField.test.tsx │ │ ├── TextField.tsx │ │ └── index.ts │ ├── Header │ │ ├── Header.test.tsx │ │ ├── Header.tsx │ │ ├── Navbar.css │ │ ├── Navbar.tsx │ │ ├── SimpleHeader.test.tsx │ │ ├── SimpleHeader.tsx │ │ ├── __snapshots__ │ │ │ ├── Header.test.tsx.snap │ │ │ └── SimpleHeader.test.tsx.snap │ │ └── index.ts │ ├── Loading │ │ ├── Loading.stories.tsx │ │ ├── Loading.test.tsx │ │ ├── Loading.tsx │ │ ├── __snapshots__ │ │ │ └── Loading.test.tsx.snap │ │ └── index.ts │ ├── OrderView │ │ ├── OrderItemList.css │ │ ├── OrderItemList.stories.tsx │ │ ├── OrderItemList.test.tsx │ │ ├── OrderItemList.tsx │ │ ├── OrderView.css │ │ ├── OrderView.stories.tsx │ │ ├── OrderView.test.tsx │ │ ├── OrderView.tsx │ │ ├── __snapshots__ │ │ │ └── OrderItemList.test.tsx.snap │ │ └── index.tsx │ ├── ProductView │ │ ├── ProductView.css │ │ ├── ProductView.stories.tsx │ │ ├── ProductView.test.tsx │ │ ├── ProductView.tsx │ │ ├── ProductViewStandalone.test.tsx │ │ ├── ProductViewStandalone.tsx │ │ ├── __snapshots__ │ │ │ └── ProductView.test.tsx.snap │ │ └── index.ts │ ├── README.md │ └── index.ts ├── contexts │ ├── EnvContext │ │ ├── EnvContext.test.tsx │ │ ├── EnvContext.tsx │ │ └── index.ts │ ├── README.md │ └── index.ts ├── index.tsx ├── mocks │ ├── browser.ts │ ├── constants.ts │ ├── handlers.ts │ ├── mockCatalog.ts │ ├── mockDb.ts │ ├── mockOrders.json │ └── server.ts ├── models │ ├── Address.ts │ ├── Cart.test.ts │ ├── Cart.ts │ ├── CheckoutInfo.ts │ ├── Env.test.ts │ ├── Env.ts │ ├── Order.ts │ ├── OrderItem.ts │ ├── Product.ts │ ├── README.md │ └── index.ts ├── pages │ ├── CheckoutPage │ │ ├── CartSummary │ │ │ ├── CartSummary.test.tsx │ │ │ ├── CartSummary.tsx │ │ │ └── index.ts │ │ ├── CheckoutForm │ │ │ ├── CheckoutForm.tsx │ │ │ └── index.ts │ │ ├── CheckoutPage.test.tsx │ │ ├── CheckoutPage.tsx │ │ └── index.ts │ ├── HomePage │ │ ├── CartView │ │ │ ├── CartView.css │ │ │ ├── CartView.stories.tsx │ │ │ ├── CartView.test.tsx │ │ │ ├── CartView.tsx │ │ │ └── index.ts │ │ ├── CatalogView │ │ │ ├── CatalogView.stories.tsx │ │ │ ├── CatalogView.test.tsx │ │ │ ├── CatalogView.tsx │ │ │ └── index.ts │ │ ├── HomePage.test.tsx │ │ ├── HomePage.tsx │ │ └── index.ts │ ├── NotFoundPage │ │ ├── NotFoundPage.test.tsx │ │ ├── NotFoundPage.tsx │ │ ├── __snapshots__ │ │ │ └── NotFoundPage.test.tsx.snap │ │ └── index.ts │ ├── OrdersPage │ │ ├── OrdersPage.test.tsx │ │ ├── OrdersPage.tsx │ │ └── index.ts │ ├── README.md │ └── index.ts ├── react-app-env.d.ts ├── reportWebVitals.ts ├── services │ ├── AxiosInterceptors.ts │ ├── CartService.ts │ ├── CatalogService.ts │ ├── OrderService.ts │ ├── README.md │ └── index.tsx ├── setupTests.ts ├── styles │ └── main.css ├── test │ └── test-utils.tsx └── utils │ ├── Constants.ts │ ├── DateUtils.ts │ ├── README.md │ ├── Storage.test.ts │ ├── Storage.ts │ ├── StringUtils.test.ts │ ├── StringUtils.ts │ ├── index.ts │ └── yupLocale.ts ├── tsconfig.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # editors 15 | /.idea 16 | /.vscode 17 | 18 | # misc 19 | .DS_Store 20 | .eslintcache 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | *.snap 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | proseWrap: 'always', 4 | }; 5 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | module.exports = { 3 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 4 | addons: [ 5 | '@storybook/addon-links', 6 | '@storybook/addon-essentials', 7 | '@storybook/preset-create-react-app', 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { QueryClient, QueryClientProvider } from 'react-query'; 3 | import { BrowserRouter as Router } from 'react-router-dom'; 4 | import { ErrorBoundary, Loading } from '../src/components'; 5 | import { EnvProvider } from '../src/contexts'; 6 | import '../src/services/AxiosInterceptors'; 7 | import '../src/styles/main.css'; 8 | 9 | export const parameters = { 10 | actions: { argTypesRegex: '^on[A-Z].*' }, 11 | options: { 12 | storySort: { 13 | order: ['Home'], 14 | }, 15 | }, 16 | }; 17 | 18 | // Start mock service worker 19 | const { worker } = require('../src/mocks/browser'); 20 | worker.start(); 21 | worker.printHandlers(); 22 | 23 | const queryClient = new QueryClient({ 24 | defaultOptions: { 25 | queries: { 26 | refetchOnWindowFocus: false, 27 | }, 28 | }, 29 | }); 30 | 31 | export const decorators = [ 32 | (Story: any) => ( 33 | }> 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ), 45 | ]; 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Testing Techniques 2 | 3 | This project demonstrates best practices in testing React applications by 4 | implementing a realistic online shopping application. It is also the repository 5 | for my Medium article titled 6 | [React Testing Techniques](https://medium.com/engineered-publicis-sapient/react-testing-techniques-d97e9dd8f081). 7 | 8 | P.S. If you find this project useful, please show your appreciation by starring 9 | this repository. 10 | 11 | ## Tools of the trade 12 | 13 | **Unit & Integration Testing** 14 | 15 | - [Jest](https://jestjs.io/) - a testing framework designed to ensure 16 | correctness of any JavaScript or TypeScript codebase 17 | 18 | - [React Testing Library](https://testing-library.com/) - a testing framework 19 | for React components that encourages better testing practices 20 | 21 | - [Mock Service Worker](https://mswjs.io/) - a framework to mock APIs by 22 | intercepting requests at the network level. It allows us to reuse the same 23 | mock definition for testing, development, and debugging. 24 | 25 | **End-to-End Testing** 26 | 27 | - [Cypress](https://www.cypress.io/) - a testing framework for fully built Web 28 | applications running in a browser 29 | 30 | **Manual Testing** 31 | 32 | - [Storybook](https://storybook.js.org/) - a tool that helps build components in 33 | isolation and record their states as stories. Stories make it easy to explore 34 | a component in all its permutations no matter how complex. They also serve as 35 | excellent visual test cases. Storybook testing can also be automated. For 36 | details, look at the 37 | [Storybook documentation](https://storybook.js.org/docs/react/workflows/testing-with-storybook). 38 | 39 | _This project was bootstrapped with 40 | [React Accelerate](https://github.com/PublicisSapient/cra-template-accelerate)._ 41 | 42 | ## Why do we write tests? 43 | 44 | For me, writing tests is about building confidence in what I am delivering. 45 | Tests provide a mechanism to verify the intent of my code by exercising it in 46 | various ways. Moreover, they give me the confidence that I have not broken 47 | anything when I refactor or extend the code to meet new requirements. The last 48 | thing I want is to get a call at 3:00 AM to fix a bug that has crashed my app! 49 | 50 | ## Guiding principles when writing tests 51 | 52 | The principles listed in this section are based on an article by Kent C. Dodds 53 | titled 54 | [Write tests. Not too many. Mostly integration.](https://kentcdodds.com/blog/write-tests) 55 | Kent is a testing guru with very good guidance on how to test effectively. I 56 | have listed several of his useful articles in the references below. 57 | 58 | So without further ado, let's jump into the guiding principles. 59 | 60 | ### Don't test implementation details 61 | 62 | If your test does something that your user doesn't, chances are that you are 63 | testing implementation details. For example, you may be exposing a private 64 | function just to test your component. This is a code smell – don't do it. A 65 | refactor can easily break your test. Another example is using certain APIs of a 66 | React testing tool called [Enzyme](https://enzymejs.github.io/enzyme/), e.g. its 67 | `instance()`, `state()` and `setState()` APIs. Stay away from such tools, 68 | instead use tools that make it harder to test implementation details (e.g. 69 | [React Testing Library](https://testing-library.com/)). 70 | 71 | ### Test your components as a user would 72 | 73 | The classic testing wisdom was to write a lot of unit tests to test individual 74 | "units" of code. We used to isolate our components from their environment using 75 | mocks. It was like testing a fish's swimming abilities out of the water. This 76 | approach still makes sense for pure functions. But for UI components, which 77 | depend on communications with surrounding components, mocking reduces our 78 | confidence in their integrations. 79 | 80 | > For this reason, the latest thinking is to test several units together to 81 | > recreate real interaction scenarios, hence the name "integration testing". 82 | 83 | This brings us to the guiding principle which is the foundation of the 84 | [React Testing Library](https://testing-library.com/docs/guiding-principles): 85 | 86 | > The more your tests resemble the way your software is used, the more 87 | > confidence they can give you. 88 | 89 | For example, drop a couple of components under a `` to test 90 | real user interactions. You could also use 91 | [Mock Service Worker](https://mswjs.io) to mock APIs at the network level rather 92 | than excessively mocking at the component or service layer. We will talk more 93 | about this in the testing techniques section below. 94 | 95 | ### Focus on use case coverage 96 | 97 | There is a tradeoff between time spent writing tests and code coverage. Some 98 | organizations put undue focus on code coverage. Unfortunately this sets the 99 | wrong goal for developers. You start seeing people gaming the system by writing 100 | meaningless tests. 101 | 102 | Instead, focus on _use case coverage_. Think of all the use cases (including 103 | corner cases) that you want to test to feel confident about your code. This 104 | approach by itself generally yields high code coverage. The tests in this 105 | project were written with use case coverage in mind and yet as a byproduct we 106 | have upwards of 90% code coverage! It is generally accepted that 80% coverage is 107 | a good goal to aim for. 108 | 109 | ### Push business logic into pure functions rather than UI components 110 | 111 | For example, a Shopping Cart UI component should not compute the cart total. 112 | This should be pushed to a 113 | [pure function](https://en.wikipedia.org/wiki/Pure_function) because it is 114 | easier to test. Even better, push it off to the back-end where more 115 | sophisticated calculations can be performed without complicating the UI. See 116 | [here](./src/models/Cart.ts) for examples for pure functions and the 117 | [related tests](./src/models/Cart.test.ts). 118 | 119 | ## Testing Techniques 120 | 121 | Now that we understand why we test the way we do, let's go over 12 techniques 122 | you can apply now. 123 | 124 | 1. [Setting up React Testing Library](./docs/setting-up-react-testing-library.md) 125 | 2. [Functional testing vs. snapshot testing vs. screenshot testing](./docs/functional-vs-snapshot-vs-screenshot-testing.md) 126 | 3. [Difference between queryBy, getBy and findBy queries](./docs/difference-between-query-types.md) 127 | 4. [Checking for existence of an element](./docs/checking-for-existence-of-an-element.md) 128 | 5. [Waiting for removal of an element](./docs/waiting-for-removal-of-an-element.md) 129 | 6. [Waiting for something to happen](./docs/waiting-for-something-to-happen.md) 130 | 7. [fireEvent() vs userEvent](./docs/fireEvent-vs-userEvent.md) 131 | 8. [Mocking an event handler](./docs/mocking-an-event-handler.md) 132 | 9. [Avoid mocking by using Mock Service Worker](./docs/avoid-mocking-by-using-mock-service-worker.md) 133 | 10. [Overriding MSW handlers](./docs/overriding-msw-handlers.md) 134 | 11. [Testing page navigation](./docs/testing-page-navigation.md) 135 | 12. [Suppressing console errors](./docs/suppressing-console-errors.md) 136 | 137 | ## Getting Started 138 | 139 | > Note: If you prefer to use npm, please feel free to replace the yarn commands 140 | > in this section with equivalent npm commands. 141 | 142 | Make sure your development machine is set up for building React apps. See the 143 | recommended setup procedure 144 | [here](https://github.com/nareshbhatia/react-learning-resources#developer-machine-setup). 145 | 146 | Execute the following commands to install dependencies: 147 | 148 | ```sh 149 | yarn install 150 | ``` 151 | 152 | Execute the following commands to run the app: 153 | 154 | ```sh 155 | yarn start 156 | ``` 157 | 158 | Now point your browser to http://localhost:3000/. 159 | 160 | ## Running Unit Tests 161 | 162 | Execute one of the following command to run unit tests. 163 | 164 | ```sh 165 | yarn test # interactive mode 166 | 167 | # OR 168 | 169 | yarn test:coverage # non-interactive mode with coverage information 170 | ``` 171 | 172 | ## Running End-to-End Tests 173 | 174 | ```sh 175 | yarn start # starts a local server hosting your react app 176 | 177 | # in a difference shell, run cypress 178 | yarn cypress:open 179 | ``` 180 | 181 | ## Running Storybook 182 | 183 | ```sh 184 | yarn storybook 185 | ``` 186 | 187 | ## Running In Production Mode 188 | 189 | Because MSW is disabled in production mode, you must first run an external API 190 | server. To do this, clone the 191 | [React Test Shop Server](https://github.com/nareshbhatia/react-test-shop-server) 192 | repository and follow the instructions there to start an API server on 193 | port 8080. 194 | 195 | Now build this project in production mode and start it using a web server like 196 | `serve`: 197 | 198 | ```sh 199 | yarn build 200 | serve -s build 201 | ``` 202 | 203 | ## Screenshots 204 | 205 | ### Home Page 206 | 207 | ![Home Page](assets/screenshot-home.png) 208 | 209 | ### Checkout Page 210 | 211 | ![Checkout Page](assets/screenshot-checkout.png) 212 | 213 | ### Orders Page 214 | 215 | ![Orders Page](assets/screenshot-orders.png) 216 | 217 | ## References 218 | 219 | ### Testing Best Practices 220 | 221 | - [How to know what to test](https://kentcdodds.com/blog/how-to-know-what-to-test) 222 | by Kent C. Dodds 223 | - [Write tests. Not too many. Mostly integration.](https://kentcdodds.com/blog/write-tests) 224 | by Kent C. Dodds 225 | - [Write fewer, longer tests](https://kentcdodds.com/blog/write-fewer-longer-tests) 226 | by Kent C. Dodds 227 | - [Making your UI tests resilient to change](https://kentcdodds.com/blog/making-your-ui-tests-resilient-to-change) 228 | by Kent C. Dodds 229 | - [Testing Implementation Details](https://kentcdodds.com/blog/testing-implementation-details) 230 | by Kent C. Dodds 231 | 232 | ### Jest 233 | 234 | - [Documentation](https://jestjs.io/docs/getting-started) 235 | 236 | ### React Testing Library 237 | 238 | - [Introduction](https://testing-library.com/docs/) 239 | - [Guiding Principles](https://testing-library.com/docs/guiding-principles) 240 | - [Example](https://testing-library.com/docs/react-testing-library/example-intro) 241 | - [Cheatsheet](https://testing-library.com/docs/react-testing-library/cheatsheet) 242 | - [Query Priority Guidelines](https://testing-library.com/docs/queries/about/#priority) 243 | - [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) 244 | 245 | ### Storybook 246 | 247 | - [Introduction to Storybook](https://storybook.js.org/docs/react/get-started/introduction) 248 | 249 | ### Mock Service Worker 250 | 251 | - [Documentation](https://mswjs.io/docs/) 252 | 253 | ### Cypress 254 | 255 | - [Documentation](https://docs.cypress.io/guides/overview/why-cypress) 256 | -------------------------------------------------------------------------------- /assets/screenshot-checkout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nareshbhatia/react-testing-techniques/22a9147fdfa0cf5079a9a110ee02202bcbe5bd37/assets/screenshot-checkout.png -------------------------------------------------------------------------------- /assets/screenshot-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nareshbhatia/react-testing-techniques/22a9147fdfa0cf5079a9a110ee02202bcbe5bd37/assets/screenshot-home.png -------------------------------------------------------------------------------- /assets/screenshot-orders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nareshbhatia/react-testing-techniques/22a9147fdfa0cf5079a9a110ee02202bcbe5bd37/assets/screenshot-orders.png -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000" 3 | } 4 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/fixtures/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 8739, 3 | "name": "Jane", 4 | "email": "jane@example.com" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Leanne Graham", 5 | "username": "Bret", 6 | "email": "Sincere@april.biz", 7 | "address": { 8 | "street": "Kulas Light", 9 | "suite": "Apt. 556", 10 | "city": "Gwenborough", 11 | "zipcode": "92998-3874", 12 | "geo": { 13 | "lat": "-37.3159", 14 | "lng": "81.1496" 15 | } 16 | }, 17 | "phone": "1-770-736-8031 x56442", 18 | "website": "hildegard.org", 19 | "company": { 20 | "name": "Romaguera-Crona", 21 | "catchPhrase": "Multi-layered client-server neural-net", 22 | "bs": "harness real-time e-markets" 23 | } 24 | }, 25 | { 26 | "id": 2, 27 | "name": "Ervin Howell", 28 | "username": "Antonette", 29 | "email": "Shanna@melissa.tv", 30 | "address": { 31 | "street": "Victor Plains", 32 | "suite": "Suite 879", 33 | "city": "Wisokyburgh", 34 | "zipcode": "90566-7771", 35 | "geo": { 36 | "lat": "-43.9509", 37 | "lng": "-34.4618" 38 | } 39 | }, 40 | "phone": "010-692-6593 x09125", 41 | "website": "anastasia.net", 42 | "company": { 43 | "name": "Deckow-Crist", 44 | "catchPhrase": "Proactive didactic contingency", 45 | "bs": "synergize scalable supply-chains" 46 | } 47 | }, 48 | { 49 | "id": 3, 50 | "name": "Clementine Bauch", 51 | "username": "Samantha", 52 | "email": "Nathan@yesenia.net", 53 | "address": { 54 | "street": "Douglas Extension", 55 | "suite": "Suite 847", 56 | "city": "McKenziehaven", 57 | "zipcode": "59590-4157", 58 | "geo": { 59 | "lat": "-68.6102", 60 | "lng": "-47.0653" 61 | } 62 | }, 63 | "phone": "1-463-123-4447", 64 | "website": "ramiro.info", 65 | "company": { 66 | "name": "Romaguera-Jacobson", 67 | "catchPhrase": "Face to face bifurcated interface", 68 | "bs": "e-enable strategic applications" 69 | } 70 | }, 71 | { 72 | "id": 4, 73 | "name": "Patricia Lebsack", 74 | "username": "Karianne", 75 | "email": "Julianne.OConner@kory.org", 76 | "address": { 77 | "street": "Hoeger Mall", 78 | "suite": "Apt. 692", 79 | "city": "South Elvis", 80 | "zipcode": "53919-4257", 81 | "geo": { 82 | "lat": "29.4572", 83 | "lng": "-164.2990" 84 | } 85 | }, 86 | "phone": "493-170-9623 x156", 87 | "website": "kale.biz", 88 | "company": { 89 | "name": "Robel-Corkery", 90 | "catchPhrase": "Multi-tiered zero tolerance productivity", 91 | "bs": "transition cutting-edge web services" 92 | } 93 | }, 94 | { 95 | "id": 5, 96 | "name": "Chelsey Dietrich", 97 | "username": "Kamren", 98 | "email": "Lucio_Hettinger@annie.ca", 99 | "address": { 100 | "street": "Skiles Walks", 101 | "suite": "Suite 351", 102 | "city": "Roscoeview", 103 | "zipcode": "33263", 104 | "geo": { 105 | "lat": "-31.8129", 106 | "lng": "62.5342" 107 | } 108 | }, 109 | "phone": "(254)954-1289", 110 | "website": "demarco.info", 111 | "company": { 112 | "name": "Keebler LLC", 113 | "catchPhrase": "User-centric fault-tolerant solution", 114 | "bs": "revolutionize end-to-end systems" 115 | } 116 | }, 117 | { 118 | "id": 6, 119 | "name": "Mrs. Dennis Schulist", 120 | "username": "Leopoldo_Corkery", 121 | "email": "Karley_Dach@jasper.info", 122 | "address": { 123 | "street": "Norberto Crossing", 124 | "suite": "Apt. 950", 125 | "city": "South Christy", 126 | "zipcode": "23505-1337", 127 | "geo": { 128 | "lat": "-71.4197", 129 | "lng": "71.7478" 130 | } 131 | }, 132 | "phone": "1-477-935-8478 x6430", 133 | "website": "ola.org", 134 | "company": { 135 | "name": "Considine-Lockman", 136 | "catchPhrase": "Synchronised bottom-line interface", 137 | "bs": "e-enable innovative applications" 138 | } 139 | }, 140 | { 141 | "id": 7, 142 | "name": "Kurtis Weissnat", 143 | "username": "Elwyn.Skiles", 144 | "email": "Telly.Hoeger@billy.biz", 145 | "address": { 146 | "street": "Rex Trail", 147 | "suite": "Suite 280", 148 | "city": "Howemouth", 149 | "zipcode": "58804-1099", 150 | "geo": { 151 | "lat": "24.8918", 152 | "lng": "21.8984" 153 | } 154 | }, 155 | "phone": "210.067.6132", 156 | "website": "elvis.io", 157 | "company": { 158 | "name": "Johns Group", 159 | "catchPhrase": "Configurable multimedia task-force", 160 | "bs": "generate enterprise e-tailers" 161 | } 162 | }, 163 | { 164 | "id": 8, 165 | "name": "Nicholas Runolfsdottir V", 166 | "username": "Maxime_Nienow", 167 | "email": "Sherwood@rosamond.me", 168 | "address": { 169 | "street": "Ellsworth Summit", 170 | "suite": "Suite 729", 171 | "city": "Aliyaview", 172 | "zipcode": "45169", 173 | "geo": { 174 | "lat": "-14.3990", 175 | "lng": "-120.7677" 176 | } 177 | }, 178 | "phone": "586.493.6943 x140", 179 | "website": "jacynthe.com", 180 | "company": { 181 | "name": "Abernathy Group", 182 | "catchPhrase": "Implemented secondary concept", 183 | "bs": "e-enable extensible e-tailers" 184 | } 185 | }, 186 | { 187 | "id": 9, 188 | "name": "Glenna Reichert", 189 | "username": "Delphine", 190 | "email": "Chaim_McDermott@dana.io", 191 | "address": { 192 | "street": "Dayna Park", 193 | "suite": "Suite 449", 194 | "city": "Bartholomebury", 195 | "zipcode": "76495-3109", 196 | "geo": { 197 | "lat": "24.6463", 198 | "lng": "-168.8889" 199 | } 200 | }, 201 | "phone": "(775)976-6794 x41206", 202 | "website": "conrad.com", 203 | "company": { 204 | "name": "Yost and Sons", 205 | "catchPhrase": "Switchable contextually-based project", 206 | "bs": "aggregate real-time technologies" 207 | } 208 | }, 209 | { 210 | "id": 10, 211 | "name": "Clementina DuBuque", 212 | "username": "Moriah.Stanton", 213 | "email": "Rey.Padberg@karina.biz", 214 | "address": { 215 | "street": "Kattie Turnpike", 216 | "suite": "Suite 198", 217 | "city": "Lebsackbury", 218 | "zipcode": "31428-2261", 219 | "geo": { 220 | "lat": "-38.2386", 221 | "lng": "57.2232" 222 | } 223 | }, 224 | "phone": "024-648-3804", 225 | "website": "ambrose.net", 226 | "company": { 227 | "name": "Hoeger LLC", 228 | "catchPhrase": "Centralized empowering task-force", 229 | "bs": "target end-to-end models" 230 | } 231 | } 232 | ] 233 | -------------------------------------------------------------------------------- /cypress/integration/checkout.spec.ts: -------------------------------------------------------------------------------- 1 | const address = { 2 | firstName: 'John', 3 | lastName: 'Smith', 4 | address: '100 Federal Street', 5 | city: 'Boston', 6 | state: 'MA', 7 | zip: '02110', 8 | }; 9 | 10 | describe('Checkout workflow', function () { 11 | it('allows user to place an order', function () { 12 | // Go to the home page 13 | cy.visit('/'); 14 | 15 | // Add 4 items to the cart 16 | cy.get('[data-testid="product"]').contains('iMac').click(); 17 | cy.get('[data-testid="product"]').contains('MacBook Pro').click(); 18 | cy.get('[data-testid="product"]').contains('iPad').click(); 19 | cy.get('[data-testid="product"]').contains('Google Home Mini').click(); 20 | 21 | // Verify that there are 4 items in the cart 22 | cy.get('[data-testid="order-items"] > tbody > tr').should('have.length', 4); 23 | 24 | // Delete "MacBook Pro" from the cart 25 | cy.get('[data-testid="order-items"]') 26 | .contains('MacBook Pro') 27 | .parent() 28 | .find('[data-testid="delete-button"]') 29 | .click(); 30 | 31 | // Verify that there are 3 items left 32 | cy.get('[data-testid="order-items"] > tbody > tr').should('have.length', 3); 33 | 34 | // Click on Checkout button 35 | cy.contains('Checkout').click(); 36 | 37 | // Verify that we are on the checkout page 38 | cy.contains('Place your order'); 39 | 40 | // Fill in the address 41 | cy.get('input[name="shippingAddress.firstName"]').type(address.firstName); 42 | cy.get('input[name="shippingAddress.lastName"]').type(address.lastName); 43 | cy.get('input[name="shippingAddress.address"]').type(address.address); 44 | cy.get('input[name="shippingAddress.city"]').type(address.city); 45 | cy.get('input[name="shippingAddress.state"]').type(address.state); 46 | cy.get('input[name="shippingAddress.zip"]').type(address.zip); 47 | 48 | // Place the order 49 | cy.contains('Place your order').click(); 50 | 51 | // Make sure order appears in the orders page, total should be 1,577.00 52 | cy.contains('John Smith'); 53 | cy.contains('1,577.00'); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | }; 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es5", "dom"], 5 | "types": ["cypress"] 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /docs/avoid-mocking-by-using-mock-service-worker.md: -------------------------------------------------------------------------------- 1 | # Avoid mocking by using Mock Service Worker 2 | 3 | Continuing to build upon the idea of "Test your components as a user would", a 4 | key strategy is to prefer integration testing over unit testing. By integration, 5 | we don't just mean integrating two or more UI components, but also integrating 6 | the service layer and API calls. As you know, many of our UI components depend 7 | on APIs for displaying and updating data, so exercising the entire chain of 8 | components up to the API calls is important. 9 | 10 | This is where [Mock Service Worker](https://mswjs.io) (MSW) comes in. It 11 | intercepts the API calls at the network level and returns the desired responses. 12 | This enables tests to exercise components in their natural environment without 13 | performing any surgery! Let's see how. 14 | 15 | Consider the [OrdersPage](../src/pages/OrdersPage/OrdersPage.tsx) as an example. 16 | This page fetches the orders from the server and displays them as a list. Here's 17 | a short snippet from this component. 18 | 19 | ```tsx 20 | import { useOrdersQuery } from '../../services'; 21 | 22 | export const OrdersPage = () => { 23 | const { isLoading, isError, error, data: orders } = useOrdersQuery(); 24 | ... 25 | 26 | return ( 27 |
28 | {orders.map((order) => ( 29 | 30 | ))} 31 |
32 | ); 33 | }; 34 | ``` 35 | 36 | To test this component, we could mock the `useOrdersQuery` hook and artificially 37 | provide some orders to display inside the component. Instead, we decided to 38 | leave all the application code intact and let the component make a real HTTP 39 | call from the test. We then intercept this call at the network level using Mock 40 | Service Worker and return some test data in the response. 41 | 42 | To intercept HTTP calls, you provide a file called 43 | [handlers.ts](../src/mocks/handlers.ts) in your project. Each request that you 44 | want to intercept should have an associated handler. Here's the 45 | [handler](../src/mocks/handlers.ts#L25-L27) that intercepts `GET /orders` and 46 | returns some orders in response: 47 | 48 | ```ts 49 | rest.get(`${MOCK_API_URL}/orders`, (req, res, ctx) => { 50 | return res(ctx.status(200), ctx.json(mockOrders)); 51 | }); 52 | ``` 53 | 54 | Finally, here's the test for the `OrdersPage`. Look how simple it is - no 55 | mocking whatsoever! 56 | 57 | ```tsx 58 | test('renders correctly', async () => { 59 | render(); 60 | expect(await screen.findAllByTestId('order-view')).toHaveLength(4); 61 | }); 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/checking-for-existence-of-an-element.md: -------------------------------------------------------------------------------- 1 | # Checking for existence of an element 2 | 3 | Use a `getBy` query combined with the `toBeInTheDocument` matcher. For example: 4 | 5 | ```ts 6 | expect(screen.getByText('iMac')).toBeInTheDocument(); 7 | ``` 8 | 9 | If the element may take some time to appear, then use the `findBy` query. Be 10 | sure to use `await` in this case. For example: 11 | 12 | ```ts 13 | expect(await screen.findByText('iMac')).toBeInTheDocument(); 14 | ``` 15 | 16 | If you expect the element to not exist in the document, use the `queryBy` query. 17 | For example: 18 | 19 | ```ts 20 | expect(screen.queryByText('iMac')).not.toBeInTheDocument(); 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/difference-between-query-types.md: -------------------------------------------------------------------------------- 1 | # Difference between queryBy, getBy and findBy queries 2 | 3 | The queries returned by the `render()` function in React Testing Library give us 4 | a way to find elements on a page. For example, in the test below, `getByRole` is 5 | used to find an input element and the submit button. 6 | 7 | ```tsx 8 | test('displays a validation error if validation fails', async () => { 9 | render(); 10 | 11 | // Submit form with lastName not filled 12 | userEvent.type(screen.getByRole('textbox', { name: /first/i }), 'John'); 13 | userEvent.click(screen.getByRole('button', { name: /submit/i })); 14 | 15 | // Expect to see a validation error 16 | expect(await screen.findByText('lastName is a required field')).toBeTruthy(); 17 | }); 18 | ``` 19 | 20 | However, React Testing Library offers several query types (`queryBy`, `getBy` 21 | and `findBy`) that are useful in different use cases. The difference is in their 22 | behavior for different number of matches found and whether they wait for the 23 | element to appear. The table below summarizes this behavior: 24 | 25 | | | No Match | 1 Match | 1+ Match | Await? | 26 | | ------- | -------- | ------- | -------- | ------ | 27 | | queryBy | null | return | throw | No | 28 | | getBy | throw | return | throw | No | 29 | | findBy | throw | return | throw | Yes | 30 | 31 | - `queryBy` is the most lenient query which returns a null if no match is found 32 | and returns the element when a match is found. The use of this query is 33 | recommended only to check the non-existence of an element. 34 | - `getBy` is a stricter query which throws if no match is found. So this one 35 | should be used when you expect the element to be available, otherwise you want 36 | the test to fail. 37 | - `findBy` is similar to `getBy` in the sense that it will throw if the element 38 | is not available, but it waits up to a timeout period (default is 1000ms) for 39 | the element to appear. Use this query type if you are expecting the element to 40 | appear after sometime, e.g. if your test is fetching data to show the element. 41 | `findBy` queries are asynchronous and should be always used with `await`. 42 | 43 | Finally, note that all these queries will throw if they find more than 1 44 | matches. If you are expecting this, use the "All" versions of the queries: 45 | `queryAllBy`, `getAllBy` and `findAllBy`. 46 | 47 | For more details, visit the following links: 48 | 49 | - [React Testing Library | Queries](https://testing-library.com/docs/react-testing-library/cheatsheet/#queries) 50 | 51 | - [DOM Testing Library | Queries](https://testing-library.com/docs/queries/about/) 52 | 53 | - [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) 54 | -------------------------------------------------------------------------------- /docs/fireEvent-vs-userEvent.md: -------------------------------------------------------------------------------- 1 | # fireEvent() vs userEvent 2 | 3 | React Testing Library provides a function called `fireEvent()` to fire an event. 4 | For example: 5 | 6 | ```ts 7 | fireEvent.change(input, { target: { value: 'hello world' } }); 8 | ``` 9 | 10 | However, you should prefer methods provided by the package 11 | `@testing-library/user-event` over `fireEvent()`. While `userEvent` is built on 12 | top of `fireEvent()`, it provides methods that simulate user interactions more 13 | closely. For example `userEvent.type()` in the example below will trigger 14 | `keyDown`, `keyPress`, and `keyUp` events for each character which is much 15 | closer to the user's actual interactions. 16 | 17 | ```ts 18 | userEvent.type(input, 'hello world'); 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/functional-vs-snapshot-vs-screenshot-testing.md: -------------------------------------------------------------------------------- 1 | # Functional testing vs. snapshot testing vs. screenshot testing 2 | 3 | ## Functional Tests 4 | 5 | Functional tests assert the correct behavior of components by interacting with 6 | them as a user would. This is what we have been talking about in this document. 7 | 8 | ## Snapshot Tests 9 | 10 | Snapshot tests take snapshots of serializable data, usually HTML markup, and 11 | compare it with previous snapshots to make sure that the output hasn't changed. 12 | In case the change is intentional, the previous snapshot must be replaced with 13 | the new one. 14 | 15 | Unfortunately, developers tend to be undisciplined about reviewing larger 16 | snapshots, resulting in buggy code being committed. Hence snapshot testing is 17 | not recommended. You could use it for very small components where the snapshot 18 | can be easily read by human beings and verified for correctness (usually 20-30 19 | lines max). Here's an 20 | [example](https://github.com/nareshbhatia/react-testing-techniques/blob/main/src/components/AddressView/__snapshots__/AddressView.test.tsx.snap). 21 | 22 | Also note that good tests encode the developer's intention. Snapshot tests lack 23 | the expression of this intent. So for anything beyond the simplest of 24 | components, prefer functional tests. 25 | 26 | If you decide to write a snapshot test, use React Testing Library because it 27 | generates cleaner snapshots. The other popular way is to use 28 | react-test-renderer, but its output contains component properties and other 29 | details that are not relevant. Here's an 30 | [example of a snapshot test](https://github.com/nareshbhatia/react-testing-techniques/blob/main/src/components/AddressView/AddressView.test.tsx) 31 | using React Testing Library. 32 | 33 | ## Screenshot Tests 34 | 35 | Screenshot tests (a.k.a. visual tests) are similar to snapshot tests except that 36 | they take screenshots of components (i.e. images as output) and compare them 37 | with previous screenshots. Screenshot tests pinpoint visual changes in 38 | components. Again, large screenshots are of limited value because they may not 39 | be able to isolate issues down to the component level. Also, remember that 40 | screenshot tests are not a replacement for functional tests. 41 | -------------------------------------------------------------------------------- /docs/mocking-an-event-handler.md: -------------------------------------------------------------------------------- 1 | # Mocking an event handler 2 | 3 | > Note: While this section shows some mocking techniques to test components, 4 | > mocking should be avoided if at all possible for reasons stated in the Guiding 5 | > Principles. It is better to combine two or more components in an integration 6 | > test and interact with the components as the user would. 7 | 8 | ## Scenario 1: Component accepts event handler as a prop 9 | 10 | Below is a short snippet from our 11 | [ProductView](../src/components/ProductView/ProductView.tsx) component. Note 12 | that it accepts an `onClick` prop that is called when the component is clicked. 13 | 14 | ```tsx 15 | export interface ProductViewProps { 16 | product: Product; 17 | onClick: (productId: string) => void; 18 | } 19 | 20 | export const ProductView = ({ product, onClick }: ProductViewProps) => { 21 | ... 22 | }; 23 | ``` 24 | 25 | How do we test that `onClick` is indeed called when the component is clicked? 26 | One technique is to mock the handler using 27 | [jest.fn](https://jestjs.io/docs/jest-object#jestfnimplementation) and test 28 | whether it is called when the component is clicked. Here's the 29 | [test](../src/components/ProductView/ProductView.test.tsx#L19-L28): 30 | 31 | ```tsx 32 | const handleClick = jest.fn(); 33 | 34 | test('when clicked, calls onClick with productId', async () => { 35 | render(); 36 | 37 | // click on the ProductView 38 | userEvent.click(screen.getByTestId('product')); 39 | 40 | // expect mock handler to be called 41 | expect(handleClick).toBeCalledTimes(1); 42 | expect(handleClick).toBeCalledWith(product.id); 43 | }); 44 | ``` 45 | 46 | Note that this is possible only because `onClick` is exposed as a prop. What if 47 | that was not the case and the component handled the click internally? 48 | 49 | ## Scenario 2: Component handles the event internally 50 | 51 | In the example below, 52 | [ProductViewStandalone](../src/components/ProductView/ProductViewStandalone.tsx) 53 | handles the click event internally and calls a service to add the product to the 54 | cart. 55 | 56 | ```tsx 57 | import { CartService } from '../../services'; 58 | 59 | export interface ProductViewStandaloneProps { 60 | product: Product; 61 | } 62 | 63 | export const ProductViewStandalone = ({ 64 | product, 65 | }: ProductViewStandaloneProps) => { 66 | const { id, name, description, price, photo } = product; 67 | 68 | const handleClick = async () => { 69 | await CartService.addProduct(id); 70 | }; 71 | 72 | return ( 73 |
74 | ... 75 |
76 | ); 77 | }; 78 | ``` 79 | 80 | In this situation, we cannot mock the event handler because it is internal to 81 | the component. However, we know that it calls CartService, which is an ES6 82 | module. 83 | 84 | ### Solution 1: Use jest.mock to mock the entire CartService module 85 | 86 | Instead of mocking the event handler, we can mock the entire CartService module. 87 | This can be done using 88 | [jest.mock](https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options) 89 | and then test that it's `addProduct()` method is being called. Here's the 90 | [test](../src/components/ProductView/ProductViewStandalone.test.tsx): 91 | 92 | ```tsx 93 | // import CartService module so that we can mock it 94 | import { CartService } from '../../services'; 95 | 96 | // automock the entire CartService module 97 | jest.mock('../../services/CartService'); 98 | 99 | test('when clicked, calls addProduct with productId', async () => { 100 | render(); 101 | 102 | // click on the ProductView 103 | userEvent.click(screen.getByTestId('product')); 104 | 105 | // expect addProduct to be called 106 | expect(CartService.addProduct).toBeCalledTimes(1); 107 | expect(CartService.addProduct).toBeCalledWith(product.id); 108 | }); 109 | ``` 110 | 111 | ### Solution 2: Use jest.mock with an explicit module implementation 112 | 113 | This is same as solution 1, except that we provide an explicit module 114 | implementation instead of automocking the module. For example: 115 | 116 | ```ts 117 | const mockAddProduct = jest.fn(); 118 | 119 | // mock the CartService module and provide an implementation 120 | jest.mock('../../services/CartService', () => ({ 121 | // module implementation returns a CartService with the addProduct() method 122 | CartService: { 123 | addProduct: async (productId: string): Promise => { 124 | // call mock function with productId to check later in test 125 | mockAddProduct(productId); 126 | return { 127 | items: [{ productId, productName: 'iMac', price: 1299, quantity: 1 }], 128 | }; 129 | }, 130 | }, 131 | })); 132 | 133 | test('when clicked, calls onClick with productId', async () => { 134 | render(); 135 | 136 | // click on the ProductView 137 | userEvent.click(screen.getByTestId('product')); 138 | 139 | // expect addProduct to be called 140 | expect(mockAddProduct).toBeCalledTimes(1); 141 | expect(mockAddProduct).toBeCalledWith(product.id); 142 | }); 143 | ``` 144 | 145 | ### Solution 3: Use jest.spyOn to mock an individual method 146 | 147 | In this approach we only mock one function in the CartService module. This is 148 | done using 149 | [jest.spyOn](https://jestjs.io/docs/jest-object#jestspyonobject-methodname). 150 | `jest.synOn` creates a mock function similar to `jest.fn` but also tracks calls 151 | to the specified method. For example: 152 | 153 | ```tsx 154 | // import CartService module so that we can mock it 155 | import { CartService } from '../../services'; 156 | 157 | test('when clicked, calls onClick with productId', async () => { 158 | const spyAddProduct = jest.spyOn(CartService, 'addProduct'); 159 | 160 | render(); 161 | 162 | // click on the ProductView 163 | userEvent.click(screen.getByTestId('product')); 164 | 165 | // expect spyAddProduct to be called 166 | expect(spyAddProduct).toBeCalledTimes(1); 167 | expect(spyAddProduct).toBeCalledWith(product.id); 168 | }); 169 | ``` 170 | -------------------------------------------------------------------------------- /docs/overriding-msw-handlers.md: -------------------------------------------------------------------------------- 1 | # Overriding MSW handlers 2 | 3 | As discussed earlier, Mock Service Worker (MSW) handlers intercept HTTP calls at 4 | the network level and return mock responses. Here's the 5 | [handler for returning the shopping cart](../src/mocks/handlers.ts#L20-L22). It 6 | essentially returns the cart that is saved in localStorage. 7 | 8 | ```ts 9 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 10 | return res(ctx.status(200), ctx.json(mockDb.getCart())); 11 | }); 12 | ``` 13 | 14 | However, we can't rely on this handler for testing our `CartView` because the 15 | contents of the cart will depend on whatever happens to be saved in the 16 | localStorage. To force a known number of items in the cart, we can override the 17 | above handler in our test using `server.use`. Here's how we do it in the 18 | [CartView test](../src/pages/HomePage/CartView/CartView.test.tsx#L52-L74). 19 | 20 | ```tsx 21 | const cart = { 22 | items: [ 23 | { 24 | productId: 'apple-imac', 25 | productName: 'iMac', 26 | price: 1299, 27 | quantity: 1, 28 | }, 29 | { 30 | productId: 'apple-macbook-pro', 31 | productName: 'MacBook Pro', 32 | price: 699, 33 | quantity: 1, 34 | }, 35 | ], 36 | }; 37 | 38 | test('renders correctly with one or more order items', async () => { 39 | // simulate 2 items in the cart 40 | server.use( 41 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 42 | return res(ctx.status(200), ctx.json(cart)); 43 | }) 44 | ); 45 | 46 | render(); 47 | 48 | // checkout button should exist 49 | const checkoutButton = await screen.findByText('Checkout'); 50 | expect(checkoutButton).not.toBeNull(); 51 | 52 | // start order message should not exist 53 | const startOrderMessage = screen.queryByText(START_ORDER); 54 | expect(startOrderMessage).toBeNull(); 55 | 56 | // 2 order items should exist 57 | const orderItemTable = await screen.findByTestId('order-items'); 58 | const orderItems = orderItemTable.querySelectorAll('tbody tr'); 59 | expect(orderItems.length).toBe(2); 60 | }); 61 | ``` 62 | 63 | Note that the overrides should be removed after the test so that they don't 64 | interfere with other tests. This is done in 65 | [setupTests.ts](../src/setupTests.ts#L19-L21). 66 | -------------------------------------------------------------------------------- /docs/setting-up-react-testing-library.md: -------------------------------------------------------------------------------- 1 | # Setting up React Testing Library 2 | 3 | It's useful to customize React Testing Library's `render` method to include 4 | things like global context providers, data stores, etc. which are used in your 5 | app. This enables tests to run in an environment closer to your real app. In 6 | addition, this approach avoids repeating wrappers in every test. To make this 7 | render method available globally, define a utility file that re-exports 8 | everything from React Testing Library while customizing the `render` method with 9 | your app-specific wrappers. Here's an example: 10 | [test-utils.tsx](../src/test/test-utils.tsx). 11 | 12 | ## Start importing test-utils.tsx 13 | 14 | Once you have a custom render method in `test-utils`, replace React Testing 15 | Library imports in your tests with this file. For example, replace 16 | 17 | ```ts 18 | import { render } from '@testing-library/react'; 19 | ``` 20 | 21 | with 22 | 23 | ```ts 24 | import { render } from '../../test/test-utils'; 25 | ``` 26 | 27 | ## Import userEvent from test-utils.tsx 28 | 29 | Note that `test-utils` re-exports `userEvent`. This allows us to reduce the 30 | number of imports in our tests. For example, we can replace 31 | 32 | ```ts 33 | import { render, screen } from '@testing-library/react'; 34 | import userEvent from '@testing-library/user-event'; 35 | ``` 36 | 37 | with 38 | 39 | ```ts 40 | import { render, screen, userEvent } from '../../test/test-utils'; 41 | ``` 42 | 43 | ## Easily set initial route using the custom render method 44 | 45 | If your test needs to start on a specific route, use the `initalRoute` option 46 | provided by the custom render method. For example: 47 | 48 | ```tsx 49 | render(, { initialRoute: '/signin' }); 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/suppressing-console-errors.md: -------------------------------------------------------------------------------- 1 | # Suppressing console errors 2 | 3 | Sometimes we want to test that errors are handled correctly. However, inducing 4 | an error in a test sometimes has an undesired side effect that we see annoying 5 | error logs in our console. In such cases it is possible to temporarily suppress 6 | console errors. Here's a 7 | [test](../src/pages/HomePage/CatalogView/CatalogView.test.tsx#L20-L37) that does 8 | this: 9 | 10 | ```tsx 11 | test('renders an error if fetching of the catalog fails', async () => { 12 | // simulate an error when fetching the catalog 13 | server.use( 14 | rest.get(`${MOCK_API_URL}/catalog`, (req, res, ctx) => { 15 | return res(ctx.status(404)); 16 | }) 17 | ); 18 | 19 | // suppress console errors 20 | jest.spyOn(console, 'error').mockImplementation(() => {}); 21 | 22 | render(); 23 | const errorMessage = await screen.findByText(/404/); 24 | expect(errorMessage).toBeInTheDocument(); 25 | 26 | // restore console errors 27 | jest.restoreAllMocks(); 28 | }); 29 | ``` 30 | 31 | We can also suppress console errors for an entire test suite by suppressing them 32 | in `beforeEach()`. Here's a 33 | [test suite](../src/components/ErrorBoundary/ErrorBoundary.test.tsx#L5-L14) that 34 | does that. 35 | -------------------------------------------------------------------------------- /docs/testing-page-navigation.md: -------------------------------------------------------------------------------- 1 | # Testing page navigation 2 | 3 | > Note: Applications should probably not check that navigation works, because 4 | > React Router has lots of tests to assure us that it works! However, if you 5 | > must, here's how to. 6 | 7 | Suppose we want to test that clicking on the Checkout button in the shopping 8 | cart navigates to the Checkout page. To test this, we can render the source and 9 | destination components inside ``. Then click the Checkout button and 10 | verify that the application indeed navigate to the Checkout page. Here's 11 | [the test](../src/pages/HomePage/CartView/CartView.test.tsx): 12 | 13 | ```tsx 14 | test('clicking on checkout button navigates to Checkout page', async () => { 15 | // simulate 2 items in the cart 16 | server.use( 17 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 18 | return res(ctx.status(200), ctx.json(cart)); 19 | }) 20 | ); 21 | 22 | render( 23 | 24 | } /> 25 | } /> 26 | 27 | ); 28 | 29 | // click on Checkout button 30 | const checkoutButton = await screen.findByText('Checkout'); 31 | userEvent.click(checkoutButton); 32 | 33 | // expect checkout page to be rendered 34 | const checkoutPageTitle = await screen.findByText('Checkout Page'); 35 | expect(checkoutPageTitle).toBeInTheDocument(); 36 | }); 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/waiting-for-removal-of-an-element.md: -------------------------------------------------------------------------------- 1 | # Waiting for removal of an element 2 | 3 | To wait for the removal of one or more elements from the DOM, use 4 | `waitForElementToBeRemoved`. Here's an example from 5 | [OrdersPage test](../src/pages/OrdersPage/OrdersPage.test.tsx): 6 | 7 | ```tsx 8 | test('renders correctly (using waitForElementToBeRemoved)', async () => { 9 | render(); 10 | await waitForElementToBeRemoved(() => screen.getByText('Loading...')); 11 | expect(screen.getAllByTestId('order-view')).toHaveLength(4); 12 | }); 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/waiting-for-something-to-happen.md: -------------------------------------------------------------------------------- 1 | # Waiting for something to happen 2 | 3 | When you need to wait for something to happen, use `waitFor`. In the example 4 | below, we enter information in a form and click the Submit button. After form 5 | validation, we expect the `handleSubmit` handler to be called. We use `waitFor` 6 | to wait for that call to happen. Then we test to make sure that call has been 7 | made with the right arguments. 8 | 9 | ```tsx 10 | const handleSubmit = jest.fn(); 11 | 12 | test('submits form information if all validations pass', async () => { 13 | render(); 14 | 15 | // Enter valid information and submit form 16 | userEvent.type(screen.getByRole('textbox', { name: /first name/i }), 'John'); 17 | userEvent.type(screen.getByRole('textbox', { name: /last name/i }), 'Smith'); 18 | userEvent.click(screen.getByRole('button', { name: /submit/i })); 19 | 20 | // Expect handleSubmit to be called with the entered information 21 | await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); 22 | expect(handleSubmit).toHaveBeenCalledWith( 23 | { 24 | firstName: 'John', 25 | lastName: 'Smith', 26 | }, 27 | // ignore the event that is sent to handleSubmit 28 | expect.anything() 29 | ); 30 | }); 31 | ``` 32 | 33 | For more information see 34 | [React Testing Library docs](https://testing-library.com/docs/dom-testing-library/api-async#waitfor). 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-testing-techniques", 3 | "description": "Best practices in testing React applications", 4 | "version": "0.1.0", 5 | "author": "Naresh Bhatia", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/nareshbhatia/react-testing-techniques" 10 | }, 11 | "scripts": { 12 | "build": "react-scripts build", 13 | "build-storybook": "build-storybook -s public", 14 | "cypress:open": "cypress open", 15 | "eject": "react-scripts eject", 16 | "format": "prettier --write README.md \"src/**/{*.md,*.json,*.css,*.ts*}\" \"cypress/integration/**/*\"", 17 | "lint": "eslint src", 18 | "start": "react-scripts start", 19 | "storybook": "start-storybook -p 6006 -s public", 20 | "test": "yarn lint && yarn test:coverage", 21 | "test:coverage": "react-scripts test --coverage --watchAll=false", 22 | "test:update": "react-scripts test --watchAll=false --updateSnapshot", 23 | "test:watch": "react-scripts test" 24 | }, 25 | "dependencies": { 26 | "@hookform/resolvers": "^2.8.3", 27 | "@http-utils/core": "^1.1.0", 28 | "@react-force/number-utils": "^2.1.0", 29 | "axios": "^0.24.0", 30 | "history": "^5.1.0", 31 | "react": "^17.0.2", 32 | "react-dom": "^17.0.2", 33 | "react-hook-form": "^7.18.1", 34 | "react-icons": "^4.3.1", 35 | "react-query": "^3.31.0", 36 | "react-router-dom": "^6.0.0", 37 | "uuid": "^8.3.2", 38 | "web-vitals": "^1.1.2", 39 | "yup": "^0.32.11" 40 | }, 41 | "devDependencies": { 42 | "@storybook/addon-actions": "^6.3.12", 43 | "@storybook/addon-essentials": "^6.3.12", 44 | "@storybook/addon-links": "^6.3.12", 45 | "@storybook/node-logger": "^6.3.12", 46 | "@storybook/preset-create-react-app": "^3.2.0", 47 | "@storybook/react": "^6.3.12", 48 | "@testing-library/dom": "^8.11.0", 49 | "@testing-library/jest-dom": "^5.15.0", 50 | "@testing-library/react": "^12.1.2", 51 | "@testing-library/user-event": "^13.5.0", 52 | "@types/jest": "^27.0.2", 53 | "@types/node": "^16.11.6", 54 | "@types/react": "^17.0.34", 55 | "@types/react-dom": "^17.0.11", 56 | "@types/uuid": "^8.3.1", 57 | "@types/yup": "^0.29.13", 58 | "cypress": "^8.7.0", 59 | "husky": "^4.3.8", 60 | "msw": "^0.35.0", 61 | "prettier": "^2.4.1", 62 | "pretty-quick": "^3.1.1", 63 | "react-scripts": "^4.0.3", 64 | "typescript": "^4.4.4" 65 | }, 66 | "eslintConfig": { 67 | "extends": [ 68 | "react-app", 69 | "react-app/jest" 70 | ], 71 | "rules": {}, 72 | "overrides": [ 73 | { 74 | "files": [ 75 | "**/*.ts?(x)" 76 | ], 77 | "rules": { 78 | "@typescript-eslint/no-unused-vars": "error" 79 | } 80 | } 81 | ] 82 | }, 83 | "husky": { 84 | "hooks": { 85 | "pre-commit": "pretty-quick --staged && npm run test:coverage" 86 | } 87 | }, 88 | "jest": { 89 | "collectCoverageFrom": [ 90 | "src/**/*.{js,jsx,ts,tsx}", 91 | "!src/**/*.stories.{js,jsx,ts,tsx}", 92 | "!src/mocks/**", 93 | "!src/stories/**", 94 | "!src/test/**", 95 | "!src/index.tsx", 96 | "!src/reportWebVitals.ts" 97 | ], 98 | "coverageThreshold": { 99 | "global": { 100 | "branches": 80, 101 | "functions": 80, 102 | "lines": 80, 103 | "statements": 80 104 | } 105 | } 106 | }, 107 | "browserslist": { 108 | "production": [ 109 | ">0.2%", 110 | "not dead", 111 | "not op_mini all" 112 | ], 113 | "development": [ 114 | "last 1 chrome version", 115 | "last 1 firefox version", 116 | "last 1 safari version" 117 | ] 118 | }, 119 | "msw": { 120 | "workerDirectory": "public" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /public/env.js: -------------------------------------------------------------------------------- 1 | window._env_ = { 2 | API_URL: 'http://localhost:8080', 3 | }; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nareshbhatia/react-testing-techniques/22a9147fdfa0cf5079a9a110ee02202bcbe5bd37/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 30 | 31 | React Test Shop 32 | 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nareshbhatia/react-testing-techniques/22a9147fdfa0cf5079a9a110ee02202bcbe5bd37/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nareshbhatia/react-testing-techniques/22a9147fdfa0cf5079a9a110ee02202bcbe5bd37/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React Test Shop", 3 | "name": "React Test Shop", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker (0.35.0). 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 = 'f0a916b13c8acc2b526a03a6d26df85f' 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 "master" 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 resolveMasterClient(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 resolveMasterClient(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 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { App } from './App'; 3 | import { HomePage, NotFoundPage } from './pages'; 4 | import { render, screen } from './test/test-utils'; 5 | 6 | jest.mock('./pages/HomePage/HomePage'); 7 | jest.mock('./pages/NotFoundPage/NotFoundPage'); 8 | 9 | describe('', () => { 10 | test('renders the Home page on default route', () => { 11 | // Arrange 12 | (HomePage as jest.Mock).mockImplementation(() =>
HomePageMock
); 13 | 14 | // Act 15 | render(); 16 | 17 | // Assert 18 | expect(screen.getByText('HomePageMock')).toBeTruthy(); 19 | }); 20 | 21 | test('renders the Not Found page for an invalid route', () => { 22 | // Arrange 23 | (NotFoundPage as jest.Mock).mockImplementation(() => ( 24 |
NotFoundMock
25 | )); 26 | 27 | // Act 28 | render(, { initialRoute: '/invalid/route' }); 29 | 30 | // Assert 31 | expect(screen.getByText('NotFoundMock')).toBeTruthy(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | import { CheckoutPage, HomePage, NotFoundPage, OrdersPage } from './pages'; 4 | 5 | export const App = () => { 6 | return ( 7 | 8 | } /> 9 | } /> 10 | } /> 11 | } /> 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/AddressForm/AddressForm.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { yupResolver } from '@hookform/resolvers/yup'; 3 | import { Meta } from '@storybook/react'; 4 | import { FormProvider, useForm } from 'react-hook-form'; 5 | import * as yup from 'yup'; 6 | import { Address, CheckoutInfo } from '../../models'; 7 | import { yupLocale } from '../../utils'; 8 | import { AddressForm, getAddressSchema } from './AddressForm'; 9 | import { AddressView } from '../AddressView'; 10 | 11 | // set up yup errors 12 | yup.setLocale(yupLocale); 13 | 14 | // ---------- TestForm ---------- 15 | interface TestFormProps { 16 | onSubmit: (checkoutInfo: CheckoutInfo) => void; 17 | } 18 | 19 | function TestForm({ onSubmit }: TestFormProps) { 20 | const schema = yup.object().shape({ 21 | shippingAddress: getAddressSchema(), 22 | }); 23 | 24 | const methods = useForm({ 25 | mode: 'onBlur', 26 | resolver: yupResolver(schema), 27 | }); 28 | 29 | return ( 30 | 31 |
32 | 33 | 36 | 37 |
38 | ); 39 | } 40 | 41 | export default { 42 | title: 'Components/AddressForm', 43 | component: AddressForm, 44 | } as Meta; 45 | 46 | export const AddressFormStory = () => { 47 | const [address, setAddress] = useState
(); 48 | 49 | const handleSubmit = (checkoutInfo: CheckoutInfo) => { 50 | setAddress(checkoutInfo.shippingAddress); 51 | }; 52 | 53 | return ( 54 |
55 | 56 | 57 |
58 |

Form value

59 | {address !== undefined ? : null} 60 |
61 |
62 | ); 63 | }; 64 | AddressFormStory.storyName = 'AddressForm'; 65 | -------------------------------------------------------------------------------- /src/components/AddressForm/AddressForm.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { yupResolver } from '@hookform/resolvers/yup'; 3 | import { FormProvider, useForm } from 'react-hook-form'; 4 | import * as yup from 'yup'; 5 | import mockOrders from '../../mocks/mockOrders.json'; 6 | import { CheckoutInfo } from '../../models'; 7 | import { render, screen, userEvent, waitFor } from '../../test/test-utils'; 8 | import { yupLocale } from '../../utils'; 9 | import { AddressForm, getAddressSchema } from './AddressForm'; 10 | 11 | // set up yup errors 12 | yup.setLocale(yupLocale); 13 | 14 | // ---------- TestForm ---------- 15 | interface TestFormProps { 16 | onSubmit: (checkoutInfo: CheckoutInfo) => void; 17 | } 18 | 19 | function TestForm({ onSubmit }: TestFormProps) { 20 | const schema = yup.object().shape({ 21 | shippingAddress: getAddressSchema(), 22 | }); 23 | 24 | const methods = useForm({ 25 | mode: 'onBlur', 26 | resolver: yupResolver(schema), 27 | }); 28 | 29 | return ( 30 | 31 |
32 | 33 | 36 | 37 |
38 | ); 39 | } 40 | 41 | // ---------- Tests ---------- 42 | const handleSubmit = jest.fn(); 43 | 44 | const address = mockOrders[0].shippingAddress; 45 | 46 | beforeEach(() => { 47 | jest.resetAllMocks(); 48 | }); 49 | 50 | describe('', () => { 51 | test('displays a validation error if validation fails', async () => { 52 | render(); 53 | 54 | // Submit form with only firstName filled 55 | userEvent.type( 56 | screen.getByRole('textbox', { name: /first name/i }), 57 | address.firstName 58 | ); 59 | userEvent.click(screen.getByRole('button', { name: /submit/i })); 60 | 61 | // Expect to see validation errors on all fields except first name 62 | expect(await screen.findAllByText('Field is required')).toHaveLength(5); 63 | }); 64 | 65 | test('submits form information if all validations pass', async () => { 66 | render(); 67 | 68 | // Enter valid information and submit form 69 | userEvent.type( 70 | screen.getByRole('textbox', { name: /first name/i }), 71 | address.firstName 72 | ); 73 | userEvent.type( 74 | screen.getByRole('textbox', { name: /last name/i }), 75 | address.lastName 76 | ); 77 | userEvent.type( 78 | screen.getByRole('textbox', { name: /company/i }), 79 | address.company 80 | ); 81 | userEvent.type( 82 | screen.getByRole('textbox', { name: /address/i }), 83 | address.address 84 | ); 85 | userEvent.type( 86 | screen.getByRole('textbox', { name: /city/i }), 87 | address.city 88 | ); 89 | userEvent.type( 90 | screen.getByRole('textbox', { name: /state/i }), 91 | address.state 92 | ); 93 | userEvent.type(screen.getByRole('textbox', { name: /zip/i }), address.zip); 94 | userEvent.click(screen.getByRole('button', { name: /submit/i })); 95 | 96 | // Expect handleSubmit to be called with the entered information 97 | await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); 98 | expect(handleSubmit).toHaveBeenCalledWith( 99 | { shippingAddress: address }, 100 | // ignore the event that is sent to handleSubmit 101 | expect.anything() 102 | ); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/components/AddressForm/AddressForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { useFormContext } from 'react-hook-form'; 3 | import * as yup from 'yup'; 4 | import { HorizontalContainer } from '../Containers'; 5 | import { TextField } from '../Form'; 6 | 7 | export const getAddressSchema = () => 8 | yup.object().shape({ 9 | firstName: yup.string().required().min(2), 10 | lastName: yup.string().required().min(2), 11 | company: yup.string(), 12 | address: yup.string().required(), 13 | city: yup.string().required(), 14 | state: yup.string().required(), 15 | zip: yup.string().required(), 16 | }); 17 | 18 | export interface AddressFormProps { 19 | title?: string; 20 | 21 | /** parent name e.g. "shippingAddress" */ 22 | parentName: string; 23 | } 24 | 25 | export const AddressForm = ({ title, parentName }: AddressFormProps) => { 26 | const { formState, register } = useFormContext(); 27 | const { errors } = formState; 28 | 29 | return ( 30 | 31 | {title !== undefined ?

{title}

: null} 32 |
33 | 39 |
40 |
41 | 47 |
48 | 49 |
50 | 56 |
57 | 58 |
59 | 65 |
66 | 67 | 68 |
69 | 75 |
76 |
77 | 83 |
84 |
85 | 91 |
92 |
93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/components/AddressForm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AddressForm'; 2 | -------------------------------------------------------------------------------- /src/components/AddressView/AddressView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import mockOrders from '../../mocks/mockOrders.json'; 4 | import { AddressView } from './AddressView'; 5 | 6 | export default { 7 | title: 'Components/AddressView', 8 | component: AddressView, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const AddressViewStory = Template.bind({}); 14 | AddressViewStory.storyName = 'AddressView'; 15 | AddressViewStory.args = { address: mockOrders[0].shippingAddress }; 16 | -------------------------------------------------------------------------------- /src/components/AddressView/AddressView.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import mockOrders from '../../mocks/mockOrders.json'; 3 | import { render } from '../../test/test-utils'; 4 | import { AddressView } from './AddressView'; 5 | 6 | describe('', () => { 7 | test('renders correctly', async () => { 8 | const { asFragment } = render( 9 | 10 | ); 11 | expect(asFragment()).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/AddressView/AddressView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Address } from '../../models'; 3 | 4 | export interface AddressViewProps { 5 | address: Address; 6 | } 7 | 8 | export const AddressView = ({ address }: AddressViewProps) => { 9 | const { 10 | firstName, 11 | lastName, 12 | company, 13 | address: street, 14 | city, 15 | state, 16 | zip, 17 | } = address; 18 | 19 | return ( 20 |
21 |

22 | {firstName} {lastName} 23 |

24 | {company !== undefined ?

{company}

: null} 25 |

{street}

26 |

27 | {city}, {state} {zip} 28 |

29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/AddressView/__snapshots__/AddressView.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 |
6 |

9 | Elon Musk 10 |

11 |

14 | Tesla, Inc. 15 |

16 |

19 | 3500 Deer Creek Road 20 |

21 |

24 | Palo Alto, CA 94304 25 |

26 |
27 |
28 | `; 29 | -------------------------------------------------------------------------------- /src/components/AddressView/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AddressView'; 2 | -------------------------------------------------------------------------------- /src/components/Containers/Containers.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta } from '@storybook/react'; 3 | import { SimpleHeader } from '../Header'; 4 | import { 5 | CenteredContainer, 6 | HorizontalContainer, 7 | ScrollingContainer, 8 | VerticalContainer, 9 | ViewCenteredContainer, 10 | ViewHorizontalContainer, 11 | ViewVerticalContainer, 12 | } from './Containers'; 13 | 14 | export default { 15 | title: 'Components/Containers', 16 | component: HorizontalContainer, 17 | parameters: { 18 | layout: 'fullscreen', 19 | }, 20 | } as Meta; 21 | 22 | export const CenteredContainerStory = () => { 23 | return ( 24 | 25 | 26 | 27 |

Centered Container

28 |
29 |
30 | ); 31 | }; 32 | CenteredContainerStory.storyName = 'CenteredContainer'; 33 | 34 | export const HorizontalContainerStory = () => { 35 | return ( 36 | 37 | 38 | 39 | 40 |

Left

41 |
42 | 43 |

Right

44 |
45 |
46 |
47 | ); 48 | }; 49 | HorizontalContainerStory.storyName = 'HorizontalContainer'; 50 | 51 | export const VerticalContainerStory = () => { 52 | return ( 53 | 54 | 55 | 56 | 57 |

Top

58 |
59 | 60 |

Bottom

61 |
62 |
63 |
64 | ); 65 | }; 66 | VerticalContainerStory.storyName = 'VerticalContainer'; 67 | 68 | export const ViewCenteredContainerStory = () => { 69 | return ( 70 | 71 |

View Centered Container

72 |
73 | ); 74 | }; 75 | ViewCenteredContainerStory.storyName = 'ViewCenteredContainer'; 76 | 77 | export const ViewHorizontalContainerStory = () => { 78 | return ( 79 | 80 | 81 |

Left

82 |
83 | 84 |

Right

85 |
86 |
87 | ); 88 | }; 89 | ViewHorizontalContainerStory.storyName = 'ViewHorizontalContainer'; 90 | 91 | export const ViewVerticalContainerStory = () => { 92 | return ( 93 | 94 | 95 |

Top

96 |
97 | 98 |

Bottom

99 |
100 |
101 | ); 102 | }; 103 | ViewVerticalContainerStory.storyName = 'ViewVerticalContainer'; 104 | 105 | // For scrolling to work correctly, the scrolling container must set overflow 106 | // to 'auto'. However, more importantly, the parent of the scrolling container 107 | // should have min-height set to 0. Without this, scrolling with not work. See 108 | // the two StackOverflow questions below: 109 | // https://stackoverflow.com/questions/55896508/nested-scrolling-containers-using-flexbox 110 | // https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size 111 | export const ScrollingContainerStory = () => { 112 | return ( 113 | 114 | 115 | 116 | 117 | {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((i) => ( 118 |
123 | ))} 124 | 125 | 126 | {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((i) => ( 127 |
132 | ))} 133 | 134 | 135 | 136 | ); 137 | }; 138 | ScrollingContainerStory.storyName = 'ScrollingContainer'; 139 | -------------------------------------------------------------------------------- /src/components/Containers/Containers.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '../../test/test-utils'; 3 | import { 4 | CenteredContainer, 5 | HorizontalContainer, 6 | ScrollingContainer, 7 | VerticalContainer, 8 | ViewCenteredContainer, 9 | ViewHorizontalContainer, 10 | ViewVerticalContainer, 11 | } from './Containers'; 12 | 13 | describe('Containers', () => { 14 | test('HorizontalContainer renders correctly', () => { 15 | const { asFragment } = render( 16 | Hello World! 17 | ); 18 | expect(asFragment()).toMatchSnapshot(); 19 | }); 20 | 21 | test('ViewHorizontalContainer renders correctly', () => { 22 | const { asFragment } = render( 23 | Hello World! 24 | ); 25 | expect(asFragment()).toMatchSnapshot(); 26 | }); 27 | 28 | test('VerticalContainer renders correctly', () => { 29 | const { asFragment } = render( 30 | Hello World! 31 | ); 32 | expect(asFragment()).toMatchSnapshot(); 33 | }); 34 | 35 | test('ViewVerticalContainer renders correctly', () => { 36 | const { asFragment } = render( 37 | Hello World! 38 | ); 39 | expect(asFragment()).toMatchSnapshot(); 40 | }); 41 | 42 | test('CenteredContainer renders correctly', () => { 43 | const { asFragment } = render( 44 | Hello World! 45 | ); 46 | expect(asFragment()).toMatchSnapshot(); 47 | }); 48 | 49 | test('ViewCenteredContainer renders correctly', () => { 50 | const { asFragment } = render( 51 | Hello World! 52 | ); 53 | expect(asFragment()).toMatchSnapshot(); 54 | }); 55 | 56 | test('ScrollingContainer renders correctly', () => { 57 | const { asFragment } = render( 58 | Hello World! 59 | ); 60 | expect(asFragment()).toMatchSnapshot(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/components/Containers/Containers.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from 'react'; 2 | 3 | export interface ContainerProps { 4 | testId?: string; 5 | className?: string; 6 | children: React.ReactNode; 7 | onClick?: MouseEventHandler; 8 | } 9 | 10 | /** 11 | * HorizontalContainer 12 | * - flex: 1 13 | * - flexDirection: row 14 | */ 15 | export const HorizontalContainer = ({ 16 | testId = 'horizontal-container', 17 | className = '', 18 | children, 19 | onClick, 20 | }: ContainerProps) => { 21 | return ( 22 |
27 | {children} 28 |
29 | ); 30 | }; 31 | 32 | /** 33 | * ViewHorizontalContainer 34 | * - height: 100vh 35 | * - flexDirection: row 36 | */ 37 | export const ViewHorizontalContainer = ({ 38 | testId = 'view-horizontal-container', 39 | className = '', 40 | children, 41 | onClick, 42 | }: ContainerProps) => { 43 | return ( 44 |
49 | {children} 50 |
51 | ); 52 | }; 53 | 54 | /** 55 | * VerticalContainer 56 | * - flex: 1 57 | * - flexDirection: column 58 | */ 59 | export const VerticalContainer = ({ 60 | testId = 'vertical-container', 61 | className = '', 62 | children, 63 | onClick, 64 | }: ContainerProps) => { 65 | return ( 66 |
71 | {children} 72 |
73 | ); 74 | }; 75 | 76 | /** 77 | * ViewVerticalContainer 78 | * - height: 100vh 79 | * - flexDirection: column 80 | */ 81 | export const ViewVerticalContainer = ({ 82 | testId = 'view-vertical-container', 83 | className = '', 84 | children, 85 | onClick, 86 | }: ContainerProps) => { 87 | return ( 88 |
93 | {children} 94 |
95 | ); 96 | }; 97 | 98 | /** 99 | * CenteredContainer 100 | * - Centers content inside a flex container 101 | * - flex: 1 102 | */ 103 | export const CenteredContainer = ({ 104 | testId = 'centered-container', 105 | className = '', 106 | children, 107 | onClick, 108 | }: ContainerProps) => { 109 | return ( 110 |
115 | {children} 116 |
117 | ); 118 | }; 119 | 120 | /** 121 | * ViewCenteredContainer 122 | * - Centers content in the entire view 123 | * - height: 100vh 124 | */ 125 | export const ViewCenteredContainer = ({ 126 | testId = 'centered-container', 127 | className = '', 128 | children, 129 | onClick, 130 | }: ContainerProps) => { 131 | return ( 132 |
137 | {children} 138 |
139 | ); 140 | }; 141 | 142 | /** 143 | * ScrollingContainer 144 | * - overflow: 'auto' 145 | */ 146 | export const ScrollingContainer = ({ 147 | testId = 'scrolling-container', 148 | className = '', 149 | children, 150 | onClick, 151 | }: ContainerProps) => { 152 | return ( 153 |
158 | {children} 159 |
160 | ); 161 | }; 162 | -------------------------------------------------------------------------------- /src/components/Containers/__snapshots__/Containers.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Containers CenteredContainer renders correctly 1`] = ` 4 | 5 |
9 | Hello World! 10 |
11 |
12 | `; 13 | 14 | exports[`Containers HorizontalContainer renders correctly 1`] = ` 15 | 16 |
20 | Hello World! 21 |
22 |
23 | `; 24 | 25 | exports[`Containers ScrollingContainer renders correctly 1`] = ` 26 | 27 |
31 | Hello World! 32 |
33 |
34 | `; 35 | 36 | exports[`Containers VerticalContainer renders correctly 1`] = ` 37 | 38 |
42 | Hello World! 43 |
44 |
45 | `; 46 | 47 | exports[`Containers ViewCenteredContainer renders correctly 1`] = ` 48 | 49 |
53 | Hello World! 54 |
55 |
56 | `; 57 | 58 | exports[`Containers ViewHorizontalContainer renders correctly 1`] = ` 59 | 60 |
64 | Hello World! 65 |
66 |
67 | `; 68 | 69 | exports[`Containers ViewVerticalContainer renders correctly 1`] = ` 70 | 71 |
75 | Hello World! 76 |
77 |
78 | `; 79 | -------------------------------------------------------------------------------- /src/components/Containers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Containers'; 2 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/ErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { ErrorBoundary } from './ErrorBoundary'; 4 | 5 | beforeEach(() => { 6 | // When an error is thrown a bunch of console.errors are called even though 7 | // the error boundary handles the error. This makes the test output noisy, 8 | // so we'll mock out console.error 9 | jest.spyOn(console, 'error').mockImplementation(() => {}); 10 | }); 11 | 12 | afterEach(() => { 13 | jest.restoreAllMocks(); 14 | }); 15 | 16 | const goodBoyText = 'I am a good boy'; 17 | const badBoyText = 'I am a bad boy'; 18 | 19 | interface ChildProps { 20 | shouldThrow?: boolean; 21 | } 22 | 23 | const Child = ({ shouldThrow }: ChildProps) => { 24 | if (shouldThrow) { 25 | throw new Error(badBoyText); 26 | } else { 27 | return
{goodBoyText}
; 28 | } 29 | }; 30 | 31 | describe('', () => { 32 | test('renders its child when there is no error', () => { 33 | render( 34 | 35 | 36 | 37 | ); 38 | expect(screen.queryByText(goodBoyText)).toBeInTheDocument(); 39 | expect(screen.queryByText(badBoyText)).not.toBeInTheDocument(); 40 | 41 | // By mocking out console.error we may inadvertently miss out on 42 | // logs due to real errors. Let's reduce that likelihood by adding 43 | // an assertion for how frequently console.error should be called. 44 | expect(console.error).toHaveBeenCalledTimes(0); 45 | }); 46 | 47 | test('renders the fallback UI when the child throws an error', () => { 48 | render( 49 | 50 | 51 | 52 | ); 53 | expect(screen.queryByText(goodBoyText)).not.toBeInTheDocument(); 54 | expect(screen.queryByText(badBoyText)).toBeInTheDocument(); 55 | expect(console.error).toHaveBeenCalledTimes(2); 56 | }); 57 | 58 | test('logs the error when the child throws an error', () => { 59 | const logError = jest.fn(); 60 | 61 | render( 62 | 63 | 64 | 65 | ); 66 | expect(screen.queryByText(goodBoyText)).not.toBeInTheDocument(); 67 | expect(screen.queryByText(badBoyText)).toBeInTheDocument(); 68 | expect(console.error).toHaveBeenCalledTimes(2); 69 | expect(logError).toHaveBeenCalledTimes(1); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on https://github.com/nareshbhatia/react-force 3 | */ 4 | import React from 'react'; 5 | import { 6 | ErrorFallbackComponent, 7 | ErrorFallbackComponentProps, 8 | } from './ErrorFallbackComponent'; 9 | 10 | export interface ErrorBoundaryProps { 11 | children: React.ReactNode; 12 | FallbackComponent: React.ComponentType; 13 | logError?: (error: Error, errorInfo: React.ErrorInfo) => void; 14 | } 15 | 16 | export interface ErrorBoundaryState { 17 | error: any; 18 | } 19 | 20 | export class ErrorBoundary extends React.Component< 21 | ErrorBoundaryProps, 22 | ErrorBoundaryState 23 | > { 24 | state: ErrorBoundaryState = { 25 | error: null, 26 | }; 27 | 28 | static defaultProps = { 29 | FallbackComponent: ErrorFallbackComponent, 30 | }; 31 | 32 | static getDerivedStateFromError(error: any) { 33 | // Update state so that the next render will show the fallback UI 34 | return { error }; 35 | } 36 | 37 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 38 | // Log the error to an error reporting service 39 | const { logError } = this.props; 40 | if (logError) { 41 | logError(error, errorInfo); 42 | } 43 | } 44 | 45 | render() { 46 | const { children, FallbackComponent } = this.props; 47 | const { error } = this.state; 48 | 49 | if (error) { 50 | return ; 51 | } 52 | 53 | return children; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/ErrorFallbackComponent.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { ErrorFallbackComponent } from './ErrorFallbackComponent'; 4 | 5 | export default { 6 | title: 'Components/ErrorFallbackComponent', 7 | component: ErrorFallbackComponent, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | } as Meta; 12 | 13 | /** 14 | * This story uses args to define how the ErrorFallbackComponent is rendered. 15 | * You can dynamically change these args in the Controls addon panel in 16 | * Storybook. See https://storybook.js.org/docs/react/essentials/controls. 17 | */ 18 | const Template: Story = (args) => ; 19 | 20 | export const ErrorFallbackComponentStory = Template.bind({}); 21 | ErrorFallbackComponentStory.storyName = 'ErrorFallbackComponent'; 22 | ErrorFallbackComponentStory.args = { error: 'Network Error' }; 23 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/ErrorFallbackComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StringUtils } from '../../utils'; 3 | import { ViewCenteredContainer } from '../Containers'; 4 | 5 | export interface ErrorFallbackComponentProps { 6 | error: any; 7 | } 8 | 9 | /** 10 | * On a production app this could be a more elaborate component 11 | */ 12 | export const ErrorFallbackComponent = ({ 13 | error, 14 | }: ErrorFallbackComponentProps) => { 15 | return ( 16 | 17 |

{StringUtils.errorToString(error)}

18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ErrorBoundary'; 2 | export * from './ErrorFallbackComponent'; 3 | -------------------------------------------------------------------------------- /src/components/Form/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface ErrorMessageProps { 4 | error?: string; 5 | } 6 | 7 | export const ErrorMessage = ({ error }: ErrorMessageProps) => 8 | error !== undefined ?
{error}
: null; 9 | -------------------------------------------------------------------------------- /src/components/Form/TextField.css: -------------------------------------------------------------------------------- 1 | .text-field__input { 2 | display: block; 3 | width: 100%; 4 | padding: var(--px-8); 5 | font-size: var(--font-size-base); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Form/TextField.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { yupResolver } from '@hookform/resolvers/yup'; 4 | import { useForm } from 'react-hook-form'; 5 | import * as yup from 'yup'; 6 | import { TextField } from './TextField'; 7 | 8 | // ---------- TestForm ---------- 9 | const schema = yup.object().shape({ 10 | firstName: yup.string().required(), 11 | lastName: yup.string().required(), 12 | }); 13 | 14 | interface Person { 15 | firstName: string; 16 | lastName: string; 17 | } 18 | 19 | interface TestFormProps { 20 | onSubmit: (person: Person) => void; 21 | } 22 | 23 | function TestForm({ onSubmit }: TestFormProps) { 24 | const { formState, register, handleSubmit } = useForm({ 25 | mode: 'onBlur', 26 | resolver: yupResolver(schema), 27 | }); 28 | const { errors } = formState; 29 | 30 | return ( 31 |
32 |
33 | 39 |
40 | 41 |
42 | 48 |
49 | 50 | 53 |
54 | ); 55 | } 56 | 57 | export default { 58 | title: 'Forms/TextField', 59 | component: TextField, 60 | } as Meta; 61 | 62 | const Template: Story = () => { 63 | const [person, setPerson] = useState(); 64 | 65 | return ( 66 |
67 | 68 |
69 |

Form value

70 |

71 | {person?.firstName} {person?.lastName} 72 |

73 |
74 |
75 | ); 76 | }; 77 | 78 | export const TextFieldStory = Template.bind({}); 79 | TextFieldStory.storyName = 'TextField'; 80 | TextFieldStory.args = {}; 81 | -------------------------------------------------------------------------------- /src/components/Form/TextField.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { yupResolver } from '@hookform/resolvers/yup'; 3 | import { useForm } from 'react-hook-form'; 4 | import * as yup from 'yup'; 5 | import { render, screen, userEvent, waitFor } from '../../test/test-utils'; 6 | import { TextField } from './TextField'; 7 | 8 | // ---------- TestForm ---------- 9 | const schema = yup.object().shape({ 10 | firstName: yup.string().required(), 11 | lastName: yup.string().required(), 12 | }); 13 | 14 | interface Person { 15 | firstName: string; 16 | lastName: string; 17 | } 18 | 19 | interface TestFormProps { 20 | onSubmit: (person: Person) => void; 21 | } 22 | 23 | function TestForm({ onSubmit }: TestFormProps) { 24 | const { formState, register, handleSubmit } = useForm({ 25 | mode: 'onBlur', 26 | resolver: yupResolver(schema), 27 | }); 28 | const { errors } = formState; 29 | 30 | return ( 31 |
32 | 38 | 39 | {/* test a field without a label */} 40 | 46 | 47 | 48 | 49 | ); 50 | } 51 | 52 | // ---------- Tests ---------- 53 | const handleSubmit = jest.fn(); 54 | 55 | beforeEach(() => { 56 | jest.resetAllMocks(); 57 | }); 58 | 59 | describe('', () => { 60 | test('displays a validation error if validation fails', async () => { 61 | render(); 62 | 63 | // Submit form with lastName not filled 64 | userEvent.type(screen.getByRole('textbox', { name: /first/i }), 'John'); 65 | userEvent.click(screen.getByRole('button', { name: /submit/i })); 66 | 67 | // Expect to see a validation error 68 | expect( 69 | await screen.findByText('lastName is a required field') 70 | ).toBeTruthy(); 71 | }); 72 | 73 | test('submits form information if all validations pass', async () => { 74 | render(); 75 | 76 | // Enter valid information and submit form 77 | userEvent.type( 78 | screen.getByRole('textbox', { name: /first name/i }), 79 | 'John' 80 | ); 81 | userEvent.type( 82 | screen.getByRole('textbox', { name: /last name/i }), 83 | 'Smith' 84 | ); 85 | userEvent.click(screen.getByRole('button', { name: /submit/i })); 86 | 87 | // Expect handleSubmit to be called with the entered information 88 | await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); 89 | expect(handleSubmit).toHaveBeenCalledWith( 90 | { 91 | firstName: 'John', 92 | lastName: 'Smith', 93 | }, 94 | // ignore the event that is sent to handleSubmit 95 | expect.anything() 96 | ); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/components/Form/TextField.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { ErrorMessage } from './ErrorMessage'; 3 | import './TextField.css'; 4 | 5 | export interface TextFieldProps { 6 | /** used to make label and errorText accessible for screen readers */ 7 | id?: string; 8 | 9 | /** used to create data-testid property on element for testing */ 10 | testId?: string; 11 | 12 | /** passed directly to the input element */ 13 | name?: string; 14 | 15 | /** the label content */ 16 | label?: React.ReactNode; 17 | 18 | /** the input type (defaults to text) */ 19 | type?: string; 20 | 21 | /** error text */ 22 | error?: string; 23 | 24 | onBlur?: (e: React.FocusEvent) => void; 25 | onChange?: (e: React.ChangeEvent) => void; 26 | } 27 | 28 | export const TextField = React.forwardRef( 29 | ( 30 | { id, testId, name, label, type = 'text', error, onBlur, onChange }, 31 | ref 32 | ) => { 33 | return ( 34 | 35 | {label !== undefined ? : null} 36 | 46 | 47 | 48 | ); 49 | } 50 | ); 51 | -------------------------------------------------------------------------------- /src/components/Form/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TextField'; 2 | -------------------------------------------------------------------------------- /src/components/Header/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '../../test/test-utils'; 3 | import { Header } from './Header'; 4 | 5 | describe('
', () => { 6 | test('renders correctly', () => { 7 | const { asFragment } = render(
); 8 | expect(asFragment()).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navbar } from './Navbar'; 3 | 4 | export const Header = () => { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Header/Navbar.css: -------------------------------------------------------------------------------- 1 | .navbar { 2 | display: flex; 3 | align-items: center; 4 | min-height: var(--px-56); 5 | } 6 | 7 | .navbar__brand { 8 | color: var(--text-primary-default); 9 | font-size: var(--font-size-xl); 10 | font-weight: 500; 11 | margin-right: var(--sp-2); 12 | } 13 | @media (min-width: 480px) { 14 | .navbar__brand { 15 | margin-right: var(--sp-3); 16 | } 17 | } 18 | 19 | .navbar ul { 20 | display: flex; 21 | list-style: none; 22 | margin: 0; 23 | padding: 0; 24 | } 25 | 26 | .navbar__link { 27 | color: var(--text-primary-muted); 28 | font-weight: 600; 29 | padding: var(--sp-1); 30 | } 31 | 32 | .navbar__link:hover { 33 | color: var(--neutral-100); 34 | text-decoration: none; 35 | } 36 | 37 | .navbar__link.active { 38 | color: var(--text-primary-default); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Header/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NavLink } from 'react-router-dom'; 3 | import './Navbar.css'; 4 | 5 | export const Navbar = () => { 6 | return ( 7 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/Header/SimpleHeader.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '../../test/test-utils'; 3 | import { SimpleHeader } from './SimpleHeader'; 4 | 5 | describe('', () => { 6 | test('renders correctly', () => { 7 | const { asFragment } = render(); 8 | expect(asFragment()).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/Header/SimpleHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const SimpleHeader = () => { 4 | return ( 5 |
6 | 9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/Header/__snapshots__/Header.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`
renders correctly 1`] = ` 4 | 5 |
8 |
11 | 41 |
42 |
43 |
44 | `; 45 | -------------------------------------------------------------------------------- /src/components/Header/__snapshots__/SimpleHeader.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 |
8 | 17 |
18 |
19 | `; 20 | -------------------------------------------------------------------------------- /src/components/Header/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Header'; 2 | export * from './SimpleHeader'; 3 | -------------------------------------------------------------------------------- /src/components/Loading/Loading.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta } from '@storybook/react'; 3 | import { Loading } from './Loading'; 4 | 5 | export default { 6 | title: 'Components/Loading', 7 | component: Loading, 8 | } as Meta; 9 | 10 | export const LoadingStory = () => { 11 | return ; 12 | }; 13 | LoadingStory.storyName = 'Loading'; 14 | -------------------------------------------------------------------------------- /src/components/Loading/Loading.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '../../test/test-utils'; 3 | import { Loading } from './Loading'; 4 | 5 | describe('', () => { 6 | test('renders correctly', () => { 7 | const { asFragment } = render(); 8 | expect(asFragment()).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * This is a placeholder for a loading component 5 | */ 6 | export const Loading = () => { 7 | return
Loading...
; 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/Loading/__snapshots__/Loading.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 |
6 | Loading... 7 |
8 |
9 | `; 10 | -------------------------------------------------------------------------------- /src/components/Loading/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Loading'; 2 | -------------------------------------------------------------------------------- /src/components/OrderView/OrderItemList.css: -------------------------------------------------------------------------------- 1 | .order-items__quantity { 2 | width: 36px; 3 | } 4 | 5 | .order-items__price { 6 | width: 110px; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/OrderView/OrderItemList.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import mockOrders from '../../mocks/mockOrders.json'; 4 | import { OrderItemList } from './OrderItemList'; 5 | 6 | export default { 7 | title: 'Components/OrderItemList', 8 | component: OrderItemList, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const OrderItemListStory = Template.bind({}); 14 | OrderItemListStory.storyName = 'OrderItemList'; 15 | OrderItemListStory.args = { items: mockOrders[0].items }; 16 | -------------------------------------------------------------------------------- /src/components/OrderView/OrderItemList.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import mockOrders from '../../mocks/mockOrders.json'; 3 | import { render } from '../../test/test-utils'; 4 | import { OrderItemList } from './OrderItemList'; 5 | 6 | describe('', () => { 7 | test('renders correctly', async () => { 8 | const { asFragment } = render( 9 | 10 | ); 11 | expect(asFragment()).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/OrderView/OrderItemList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NumberUtils } from '@react-force/number-utils'; 3 | import { OrderItemUtils } from '../../models'; 4 | import { OrderItem } from '../../models'; 5 | import './OrderItemList.css'; 6 | 7 | export interface OrderItemListProps { 8 | items: Array; 9 | className?: string; 10 | } 11 | 12 | export const OrderItemList = ({ items, className }: OrderItemListProps) => { 13 | return ( 14 | 15 | 16 | {items.map((item, index) => ( 17 | 18 | 19 | 22 | 28 | 29 | ))} 30 | 31 | 32 | 33 | 34 | 37 | 38 | 39 |
{item.productName} 20 | {item.quantity} 21 | 26 | {NumberUtils.formatAsMoney(OrderItemUtils.totalItem(item))} 27 |
Total 35 | {NumberUtils.formatAsMoney(OrderItemUtils.totalItems(items))} 36 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/OrderView/OrderView.css: -------------------------------------------------------------------------------- 1 | .order { 2 | margin-bottom: var(--sp-2); 3 | padding: var(--sp-2); 4 | border-radius: var(--px-4); 5 | cursor: pointer; 6 | user-select: none; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/OrderView/OrderView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import mockOrders from '../../mocks/mockOrders.json'; 4 | import { OrderView } from './OrderView'; 5 | 6 | export default { 7 | title: 'Components/OrderView', 8 | component: OrderView, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const OrderViewStory = Template.bind({}); 14 | OrderViewStory.storyName = 'OrderView'; 15 | OrderViewStory.args = { order: mockOrders[0] }; 16 | -------------------------------------------------------------------------------- /src/components/OrderView/OrderView.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import mockOrders from '../../mocks/mockOrders.json'; 3 | import { render, screen } from '../../test/test-utils'; 4 | import { OrderView } from './OrderView'; 5 | 6 | describe('', () => { 7 | test('renders correctly', async () => { 8 | render(); 9 | 10 | // since the child components are tested thoroughly, just look for their presence 11 | expect(screen.getByText('Elon Musk')).toBeInTheDocument(); 12 | expect(screen.getByText('01/01/2021')).toBeInTheDocument(); 13 | expect(screen.getByText('Total')).toBeInTheDocument(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/OrderView/OrderView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Order } from '../../models'; 3 | import { DateUtils } from '../../utils'; 4 | import { AddressView } from '../AddressView'; 5 | import { HorizontalContainer } from '../Containers'; 6 | import { OrderItemList } from './OrderItemList'; 7 | import './OrderView.css'; 8 | 9 | export interface OrderViewProps { 10 | order: Order; 11 | } 12 | 13 | export const OrderView = ({ order }: OrderViewProps) => { 14 | const { createdAt, items, shippingAddress } = order; 15 | 16 | return ( 17 |
18 | 19 | 20 |

{DateUtils.formatISODate(createdAt)}

21 |
22 | 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/OrderView/__snapshots__/OrderItemList.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 | 8 | 9 | 12 | 15 | 20 | 26 | 27 | 30 | 33 | 38 | 44 | 45 | 48 | 51 | 56 | 62 | 63 | 64 | 65 | 66 | 71 | 76 | 77 | 78 |
13 | iMac 14 | 18 | 1 19 | 24 | 1,299.00 25 |
31 | iPad 32 | 36 | 1 37 | 42 | 249.00 43 |
49 | Nest Learning Thermostat 50 | 54 | 1 55 | 60 | 249.00 61 |
69 | Total 70 | 74 | 1,797.00 75 |
79 |
80 | `; 81 | -------------------------------------------------------------------------------- /src/components/OrderView/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './OrderItemList'; 2 | export * from './OrderView'; 3 | -------------------------------------------------------------------------------- /src/components/ProductView/ProductView.css: -------------------------------------------------------------------------------- 1 | .product { 2 | margin-bottom: var(--sp-2); 3 | padding: var(--sp-2); 4 | border-radius: var(--px-4); 5 | cursor: pointer; 6 | user-select: none; 7 | } 8 | 9 | .product__photo { 10 | object-fit: cover; 11 | height: 100%; 12 | width: 10rem; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/ProductView/ProductView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { action } from '@storybook/addon-actions'; 3 | import { Meta, Story } from '@storybook/react'; 4 | import { mockCatalog } from '../../mocks/mockCatalog'; 5 | import { Product } from '../../models'; 6 | import { ProductView } from './ProductView'; 7 | 8 | const product: Product = mockCatalog['apple-imac']; 9 | 10 | export default { 11 | title: 'Components/ProductView', 12 | component: ProductView, 13 | } as Meta; 14 | 15 | const Template: Story = (args) => ( 16 | 17 | ); 18 | 19 | export const ProductViewStory = Template.bind({}); 20 | ProductViewStory.storyName = 'ProductView'; 21 | ProductViewStory.args = { product }; 22 | -------------------------------------------------------------------------------- /src/components/ProductView/ProductView.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, userEvent } from '../../test/test-utils'; 3 | import { mockCatalog } from '../../mocks/mockCatalog'; 4 | import { Product } from '../../models'; 5 | import { ProductView } from './ProductView'; 6 | 7 | const product: Product = mockCatalog['apple-imac']; 8 | const handleClick = jest.fn(); 9 | 10 | describe('', () => { 11 | test('renders correctly', async () => { 12 | const { asFragment } = render( 13 | 14 | ); 15 | expect(asFragment()).toMatchSnapshot(); 16 | }); 17 | 18 | test('when clicked, calls onClick with productId', async () => { 19 | render(); 20 | 21 | // click on the ProductView 22 | userEvent.click(screen.getByTestId('product')); 23 | 24 | // expect mock handler to be called 25 | expect(handleClick).toBeCalledTimes(1); 26 | expect(handleClick).toBeCalledWith(product.id); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/ProductView/ProductView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NumberUtils } from '@react-force/number-utils'; 3 | import { Product } from '../../models'; 4 | import { HorizontalContainer } from '../Containers'; 5 | import './ProductView.css'; 6 | 7 | export interface ProductViewProps { 8 | product: Product; 9 | onClick: (productId: string) => void; 10 | } 11 | 12 | export const ProductView = ({ product, onClick }: ProductViewProps) => { 13 | const { id, name, description, price, photo } = product; 14 | 15 | return ( 16 | onClick(id)} 20 | > 21 | {name} 22 |
23 |

{name}

24 |

{description}

25 |

${NumberUtils.formatAsMoney(price)}

26 |
27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/ProductView/ProductViewStandalone.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, userEvent } from '../../test/test-utils'; 3 | import { mockCatalog } from '../../mocks/mockCatalog'; 4 | import { CartService } from '../../services'; 5 | import { Product } from '../../models'; 6 | import { ProductViewStandalone } from './ProductViewStandalone'; 7 | 8 | const product: Product = mockCatalog['apple-imac']; 9 | 10 | // automock the entire CartService module 11 | jest.mock('../../services/CartService'); 12 | 13 | describe('', () => { 14 | test('when clicked, calls addProduct with productId', async () => { 15 | render(); 16 | 17 | // click on the ProductView 18 | userEvent.click(screen.getByTestId('product')); 19 | 20 | // expect addProduct to be called 21 | expect(CartService.addProduct).toBeCalledTimes(1); 22 | expect(CartService.addProduct).toBeCalledWith(product.id); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/ProductView/ProductViewStandalone.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NumberUtils } from '@react-force/number-utils'; 3 | import { Product } from '../../models'; 4 | import { CartService } from '../../services'; 5 | import { HorizontalContainer } from '../Containers'; 6 | import './ProductView.css'; 7 | 8 | export interface ProductViewStandaloneProps { 9 | product: Product; 10 | } 11 | 12 | /** Standalone version of ProductView which handles the onClick internally */ 13 | export const ProductViewStandalone = ({ 14 | product, 15 | }: ProductViewStandaloneProps) => { 16 | const { id, name, description, price, photo } = product; 17 | 18 | const handleClick = async () => { 19 | await CartService.addProduct(id); 20 | }; 21 | 22 | return ( 23 | 28 | {name} 29 |
30 |

{name}

31 |

{description}

32 |

${NumberUtils.formatAsMoney(price)}

33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/ProductView/__snapshots__/ProductView.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 |
9 | iMac 14 |
17 |

18 | iMac 19 |

20 |

23 | The 27‑inch iMac is packed with powerful tools and apps that let you take any idea to the next level. Its superfast processors and graphics, massive memory, and all-flash storage can tackle any workload with ease. And with its advanced audio and video capabilities and stunning 5K Retina display, everything you do is larger than life. 24 |

25 |

28 | $1,299.00 29 |

30 |
31 |
32 |
33 | `; 34 | -------------------------------------------------------------------------------- /src/components/ProductView/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ProductView'; 2 | -------------------------------------------------------------------------------- /src/components/README.md: -------------------------------------------------------------------------------- 1 | # components 2 | 3 | This folder contains React components that are reused across application 4 | features. 5 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AddressForm'; 2 | export * from './AddressView'; 3 | export * from './Containers'; 4 | export * from './ErrorBoundary'; 5 | export * from './Form'; 6 | export * from './Header'; 7 | export * from './Loading'; 8 | export * from './OrderView'; 9 | export * from './ProductView'; 10 | -------------------------------------------------------------------------------- /src/contexts/EnvContext/EnvContext.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { EnvProvider, useEnv } from './EnvContext'; 4 | 5 | // Set username in window environment 6 | (window as any)._env_ = { 7 | USERNAME: 'John Smith', 8 | }; 9 | 10 | const Child = () => { 11 | const env = useEnv(); 12 | const username = env.get('USERNAME'); 13 | return
Welcome {username}
; 14 | }; 15 | 16 | describe('EnvContext', () => { 17 | it('return value if environment variable exists', () => { 18 | render( 19 | 20 | 21 | 22 | ); 23 | expect(screen.getByText('Welcome John Smith')).toBeInTheDocument(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/contexts/EnvContext/EnvContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Env, WindowEnv } from '../../models'; 3 | 4 | export const EnvContext = React.createContext(undefined); 5 | 6 | function useEnv(): Env { 7 | const env = useContext(EnvContext); 8 | /* istanbul ignore next */ 9 | if (env === undefined) { 10 | throw new Error('useEnv must be used within a EnvProvider'); 11 | } 12 | return env; 13 | } 14 | 15 | /** 16 | * Provides an instance of WindowEnv 17 | */ 18 | const EnvProvider: React.FC = ({ children }) => { 19 | return ( 20 | 21 | {children} 22 | 23 | ); 24 | }; 25 | 26 | export { EnvProvider, useEnv }; 27 | -------------------------------------------------------------------------------- /src/contexts/EnvContext/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EnvContext'; 2 | -------------------------------------------------------------------------------- /src/contexts/README.md: -------------------------------------------------------------------------------- 1 | # contexts 2 | 3 | This folder contains React contexts that are reused across application features. 4 | 5 | Here are some useful tips regarding contexts from Kent Dodd's article 6 | [How to use React Context effectively](https://kentcdodds.com/blog/how-to-use-react-context-effectively) 7 | 8 | 1. You shouldn't be reaching for context to solve every state sharing problem 9 | that crosses your desk. 10 | 2. Context does NOT have to be global to the whole app, but can be applied to 11 | one part of your tree 12 | 3. You can (and probably should) have multiple logically separated contexts in 13 | your app. 14 | -------------------------------------------------------------------------------- /src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EnvContext'; 2 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { QueryClient, QueryClientProvider } from 'react-query'; 4 | import { BrowserRouter as Router } from 'react-router-dom'; 5 | import * as yup from 'yup'; 6 | import { App } from './App'; 7 | import { ErrorBoundary, Loading } from './components'; 8 | import { EnvProvider } from './contexts'; 9 | import reportWebVitals from './reportWebVitals'; 10 | import { yupLocale } from './utils'; 11 | import './services/AxiosInterceptors'; 12 | import './styles/main.css'; 13 | 14 | // Start mock service worker 15 | if (process.env.NODE_ENV === 'development') { 16 | const { worker } = require('./mocks/browser'); 17 | worker.start(); 18 | worker.printHandlers(); 19 | } 20 | 21 | const queryClient = new QueryClient({ 22 | defaultOptions: { 23 | queries: { 24 | refetchOnWindowFocus: false, 25 | }, 26 | }, 27 | }); 28 | 29 | // set up yup errors 30 | yup.setLocale(yupLocale); 31 | 32 | ReactDOM.render( 33 | 34 | }> 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | , 46 | document.getElementById('root') 47 | ); 48 | 49 | // If you want to start measuring performance in your app, pass a function 50 | // to log results (for example: reportWebVitals(console.log)) 51 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 52 | reportWebVitals(); 53 | -------------------------------------------------------------------------------- /src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw'; 2 | import { handlers } from './handlers'; 3 | 4 | // This configures a Service Worker with the given request handlers. 5 | export const worker = setupWorker(...handlers); 6 | -------------------------------------------------------------------------------- /src/mocks/constants.ts: -------------------------------------------------------------------------------- 1 | export const MOCK_API_URL = 'http://localhost:8080'; 2 | -------------------------------------------------------------------------------- /src/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { CartUtils, CheckoutInfo, Order } from '../models'; 4 | import { MOCK_API_URL } from './constants'; 5 | import { mockCatalog } from './mockCatalog'; 6 | import { mockDb } from './mockDb'; 7 | import mockOrders from './mockOrders.json'; 8 | 9 | interface AddProductInput { 10 | productId: string; 11 | } 12 | 13 | export const handlers = [ 14 | /** get catalog */ 15 | rest.get(`${MOCK_API_URL}/catalog`, (req, res, ctx) => { 16 | return res(ctx.status(200), ctx.json(mockCatalog)); 17 | }), 18 | 19 | /** get cart */ 20 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 21 | return res(ctx.status(200), ctx.json(mockDb.getCart())); 22 | }), 23 | 24 | /** get orders */ 25 | rest.get(`${MOCK_API_URL}/orders`, (req, res, ctx) => { 26 | return res(ctx.status(200), ctx.json(mockOrders)); 27 | }), 28 | 29 | /** add a product to the cart */ 30 | rest.post(`${MOCK_API_URL}/cart/items`, (req, res, ctx) => { 31 | const { productId } = req.body as AddProductInput; 32 | const cart = mockDb.getCart(); 33 | const newCart = CartUtils.addProduct(cart, mockCatalog[productId]); 34 | mockDb.setCart(newCart); 35 | return res(ctx.status(200), ctx.json(newCart)); 36 | }), 37 | 38 | /** delete an item from the cart */ 39 | rest.delete(`${MOCK_API_URL}/cart/items/:productId`, (req, res, ctx) => { 40 | const { productId } = req.params; 41 | const cart = mockDb.getCart(); 42 | const newCart = CartUtils.deleteItem(cart, productId); 43 | mockDb.setCart(newCart); 44 | return res(ctx.status(200), ctx.json(newCart)); 45 | }), 46 | 47 | /** set the quantity of an item in the cart **/ 48 | rest.patch(`${MOCK_API_URL}/cart/items/:productId`, (req, res, ctx) => { 49 | const { productId } = req.params; 50 | const { quantity } = req.body as any; 51 | const cart = mockDb.getCart(); 52 | const newCart = CartUtils.setItemQuantity(cart, productId, quantity); 53 | mockDb.setCart(newCart); 54 | return res(ctx.status(200), ctx.json(newCart)); 55 | }), 56 | 57 | /** create an order */ 58 | rest.post(`${MOCK_API_URL}/orders`, (req, res, ctx) => { 59 | const checkoutInfo = req.body as CheckoutInfo; 60 | 61 | // Move cart items into the order 62 | const order: Order = { 63 | id: uuidv4(), 64 | createdAt: new Date().toISOString(), 65 | items: mockDb.getCart().items, 66 | shippingAddress: checkoutInfo.shippingAddress, 67 | }; 68 | // @ts-ignore 69 | mockOrders.push(order); 70 | 71 | // clear the cart 72 | mockDb.clearCart(); 73 | 74 | return res(ctx.status(200), ctx.json(order)); 75 | }), 76 | ]; 77 | -------------------------------------------------------------------------------- /src/mocks/mockCatalog.ts: -------------------------------------------------------------------------------- 1 | import { Catalog } from '../models'; 2 | 3 | export const mockCatalog: Catalog = { 4 | 'apple-imac': { 5 | id: 'apple-imac', 6 | name: 'iMac', 7 | description: 8 | 'The 27‑inch iMac is packed with powerful tools and apps that let you take any idea to the next level. Its superfast processors and graphics, massive memory, and all-flash storage can tackle any workload with ease. And with its advanced audio and video capabilities and stunning 5K Retina display, everything you do is larger than life.', 9 | manufacturer: 'Apple', 10 | price: 1299, 11 | photo: 12 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/apple-imac.jpg?alt=media&token=69d03695-d57d-4b1a-952c-909a21af9099', 13 | }, 14 | 'apple-macbook-pro': { 15 | id: 'apple-macbook-pro', 16 | name: 'MacBook Pro', 17 | description: 18 | 'Designed for those who defy limits and change the world, the 16-inch MacBook Pro is by far the most powerful notebook Apple have ever made. With an immersive Retina display, superfast processors, advanced graphics, the largest battery capacity ever in a MacBook Pro, Magic Keyboard, and massive storage.', 19 | manufacturer: 'Apple', 20 | price: 699, 21 | photo: 22 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/apple-macbook-pro.jpg?alt=media&token=d2b1cf37-cfe0-4f20-adeb-eaeb472cedaa', 23 | }, 24 | 'apple-ipad': { 25 | id: 'apple-ipad', 26 | name: 'iPad', 27 | description: 28 | 'Powerful. Easy to use. Versatile. The new iPad is designed for all the things you love to do. Work, play, create, learn, stay connected, and more. All at an incredible value.', 29 | manufacturer: 'Apple', 30 | price: 249, 31 | photo: 32 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/apple-ipad.jpg?alt=media&token=17ba3b67-5e54-4bf0-8dfb-afb2538355d2', 33 | }, 34 | 'belkin-netcam': { 35 | id: 'belkin-netcam', 36 | name: 'Belkin NetCam HD+ Wi-Fi Camera', 37 | description: 38 | "The Belkin NetCam HD+ delivers 720p HD live streaming video to your smartphone or tablet, letting you watch the action in your home from wherever you go. Equipped with a wide-angle glass lens, night vision, and secure Wi-Fi connectivity, the NetCam HD+ ensures you'll always be a part of what's happening at home.", 39 | manufacturer: 'Belkin', 40 | price: 99, 41 | photo: 42 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/belkin-netcam-hd%2B-wi-fi-camera.jpg?alt=media&token=26babba0-3765-4402-8336-facf19c07ca7', 43 | }, 44 | 'fitbit-charge-hr': { 45 | id: 'fitbit-charge-hr', 46 | name: 'Fitbit Charge HR', 47 | description: 48 | 'Make every beat count with Charge HR—a high-performance wristband with automatic, continuous heart rate and activity tracking right on your wrist.', 49 | manufacturer: 'Fitbit', 50 | price: 136, 51 | photo: 52 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/fitbit-charge-hr.jpg?alt=media&token=ba217f84-6b73-4a45-8c41-eaf75242668f', 53 | }, 54 | 'ring-video-doorbell-pro': { 55 | id: 'ring-video-doorbell-pro', 56 | name: 'Ring Video Doorbell Pro', 57 | description: 58 | "Our wired doorbell camera with added security and style. Video Doorbell Pro brings you next-level features like enhanced dual-band wifi, color night vision, and built-in Alexa Greetings that answer the door for you when you're busy.", 59 | manufacturer: 'Ring', 60 | price: 249, 61 | photo: 62 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/ring-video-doorbell-pro.jpg?alt=media&token=91de25d4-813f-4341-9fec-cd8564ae4335', 63 | }, 64 | 'august-smart-lock-pro': { 65 | id: 'august-smart-lock-pro', 66 | name: 'August Smart Lock Pro', 67 | description: 68 | 'The ultimate smart door upgrade for your home. August Smart Lock Pro takes any worry out of getting into your home. Use our top-rated app to control your door to unlock/lock, grant guest access, see who came and left, and let anyone in from anywhere.', 69 | manufacturer: 'August', 70 | price: 249, 71 | photo: 72 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/august-smart-lock-pro.jpg?alt=media&token=026b30c5-6b4f-4432-9e8e-5c0830274bee', 73 | }, 74 | 'nest-learning-thermostat': { 75 | id: 'nest-learning-thermostat', 76 | name: 'Nest Learning Thermostat', 77 | description: 78 | 'The Nest Learning Thermostat automatically adapts as your life and the seasons change. Just use it for a week and it programs itself. With the Nest app, your thermostat lives on your wall and in your pocket. Once you connect your thermostat to Wi-Fi, you can control it from anywhere.', 79 | manufacturer: 'Nest', 80 | price: 249, 81 | photo: 82 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/nest-learning-thermostat.jpg?alt=media&token=afea31e6-611e-4ec4-a2fe-6cb9928ab997', 83 | }, 84 | 'nest-protect': { 85 | id: 'nest-protect', 86 | name: 'Nest Protect Smoke + Carbon Monoxide Alarm', 87 | description: 88 | 'There’s never been a smoke and CO alarm quite like this. Nest Protect looks for fast-burning fires, smoldering fires, and carbon monoxide. It speaks up when there’s a problem and can send a message to your phone if you’re not at home. And it helps keep you safe and sound for up to a decade.', 89 | manufacturer: 'Nest', 90 | price: 249, 91 | photo: 92 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/nest-protect.jpg?alt=media&token=c36ef284-bd4b-4cfb-90e2-1fd5e59417d3', 93 | }, 94 | 'google-chromecast-audio': { 95 | id: 'google-chromecast-audio', 96 | name: 'Google Chromecast Audio', 97 | description: 98 | "Stream your favorite music with Google Chromecast Audio. Simply plug into your speaker's auxiliary input and connect to your Wi-Fi network. Then just tap the Cast button from your favorite apps on your phone, tablet or computer to cast songs, radio stations and podcasts to your speaker.", 99 | manufacturer: 'Google', 100 | price: 35, 101 | photo: 102 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/google-chromecast-audio.jpg?alt=media&token=83800a2b-575a-4927-8c9c-2e16f25e87a9', 103 | }, 104 | 'nest-secure': { 105 | id: 'nest-secure', 106 | name: 'Nest Secure Alarm System Starter Pack', 107 | description: 108 | 'Tough on bad guys. Easy on you. The Nest secure alarm system is easy to live with every day. Just tap Nest tag on Nest guard to arm and disarm the alarm – no pass code needed. Nest detect sensors look out for doors, windows and rooms. And with the Nest app, you’ll always know what’s happening at home.', 109 | manufacturer: 'Nest', 110 | price: 499, 111 | photo: 112 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/nest-secure.jpg?alt=media&token=4dc8e77b-e94e-416c-ad6c-3182397264a6', 113 | }, 114 | 'sonos-play1': { 115 | id: 'sonos-play1', 116 | name: 'Sonos PLAY:1 Smart Wireless Speaker', 117 | description: 118 | 'Small yet powerful speaker for streaming music and more. Get rich, room-filling sound with Play:1, and control it with the Sonos app.', 119 | manufacturer: 'Sonos', 120 | price: 149, 121 | photo: 122 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/sonos-play1.jpg?alt=media&token=9e04f562-5d11-4b0d-a5d2-7c7c40137fc6', 123 | }, 124 | 'dyson-cordless-vacuum': { 125 | id: 'dyson-cordless-vacuum', 126 | name: 'Dyson Cordless Vacuum', 127 | description: 128 | 'For cord-free cleaning with twice the suction of any other cordless stick vacuum cleaner.', 129 | manufacturer: 'Dyson', 130 | price: 175, 131 | photo: 132 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/dyson-cordless-vacuum.jpg?alt=media&token=0ca780cf-475f-4d82-8486-5ae36696421f', 133 | }, 134 | 'gopro-hero': { 135 | id: 'gopro-hero', 136 | name: 'GoPro HERO Session Action Camera', 137 | description: 138 | 'HERO Session packs the power of GoPro into a convenient, grab-and-go, everyday camera. Perfect for the first-time GoPro user, or as a second camera, HERO Session is simple and easy to use. A single button powers on the camera and starts recording automatically, then when you stop recording, the camera powers itself off.', 139 | manufacturer: 'GoPro', 140 | price: 149, 141 | photo: 142 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/gopro-hero.jpg?alt=media&token=22e54972-b834-4319-be7c-f1d046e7487f', 143 | }, 144 | 'google-home-mini': { 145 | id: 'google-home-mini', 146 | name: 'Google Home Mini', 147 | description: 148 | 'Google Home Mini is a smart speaker powered by the Google Assistant, so you can ask it questions and tell it to do things. Just start with "Ok Google" to enjoy your entertainment, get answers from Google, and control your smart home.', 149 | manufacturer: 'Google', 150 | price: 29, 151 | photo: 152 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/google-home-mini.jpg?alt=media&token=af98464b-f994-4473-b96b-7549c375b6c1', 153 | }, 154 | 'canon-eos-rebel': { 155 | id: 'canon-eos-rebel', 156 | name: 'Canon EOS Rebel SL1', 157 | description: 158 | "As Canon's smallest and lightest digital SLR camera, the EOS Rebel SL1 is small in size but enormous in performance. With a newly-designed Canon 18.0 Megapixel CMOS (APS-C) sensor and speedy Canon DIGIC 5 Image Processor, it delivers images of extraordinary quality.", 159 | manufacturer: 'Canon', 160 | price: 403, 161 | photo: 162 | 'https://firebasestorage.googleapis.com/v0/b/mobx-shop.appspot.com/o/canon-eos-rebel.jpg?alt=media&token=aaab2139-a9ba-4ac1-a9bc-a4902a84926c', 163 | }, 164 | }; 165 | -------------------------------------------------------------------------------- /src/mocks/mockDb.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '../utils'; 2 | import { Cart } from '../models'; 3 | 4 | const CART_KEY = 'mockDbCart'; 5 | 6 | // The mock database keeps all data in memory. However it is backed by 7 | // localStorage. Anytime a value is written to the in-memory database, 8 | // it is also persisted to localStorage. 9 | 10 | // -------------------- Initialize in-memory database -------------------- 11 | // cart 12 | let cart: Cart = Storage.get(CART_KEY, { items: [] }); 13 | // ----------------------------------------------------------------------- 14 | 15 | function getCart(): Cart { 16 | return cart; 17 | } 18 | 19 | function setCart(newCart: Cart) { 20 | cart = newCart; 21 | Storage.set(CART_KEY, cart); 22 | } 23 | 24 | function clearCart() { 25 | cart = { items: [] }; 26 | Storage.set(CART_KEY, cart); 27 | } 28 | 29 | export const mockDb = { 30 | getCart, 31 | setCart, 32 | clearCart, 33 | }; 34 | -------------------------------------------------------------------------------- /src/mocks/mockOrders.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "3f9db14f-9d21-455a-b250-dbe28b05202a", 4 | "createdAt": "2021-01-01T14:00:00.000Z", 5 | "items": [ 6 | { 7 | "productId": "apple-imac", 8 | "productName": "iMac", 9 | "price": 1299, 10 | "quantity": 1 11 | }, 12 | { 13 | "productId": "apple-ipad", 14 | "productName": "iPad", 15 | "price": 249, 16 | "quantity": 1 17 | }, 18 | { 19 | "productId": "nest-learning-thermostat", 20 | "productName": "Nest Learning Thermostat", 21 | "price": 249, 22 | "quantity": 1 23 | } 24 | ], 25 | "shippingAddress": { 26 | "firstName": "Elon", 27 | "lastName": "Musk", 28 | "company": "Tesla, Inc.", 29 | "address": "3500 Deer Creek Road", 30 | "city": "Palo Alto", 31 | "state": "CA", 32 | "zip": "94304" 33 | } 34 | }, 35 | { 36 | "id": "68e19edd-8a5e-4af2-a432-2e7ab04e7dcd", 37 | "createdAt": "2021-07-09T14:00:00.000Z", 38 | "items": [ 39 | { 40 | "productId": "apple-imac", 41 | "productName": "iMac", 42 | "price": 1299, 43 | "quantity": 1 44 | }, 45 | { 46 | "productId": "apple-ipad", 47 | "productName": "iPad", 48 | "price": 249, 49 | "quantity": 1 50 | }, 51 | { 52 | "productId": "nest-learning-thermostat", 53 | "productName": "Nest Learning Thermostat", 54 | "price": 249, 55 | "quantity": 1 56 | }, 57 | { 58 | "productId": "gopro-hero", 59 | "productName": "GoPro HERO Session Action Camera", 60 | "price": 149, 61 | "quantity": 4 62 | } 63 | ], 64 | "shippingAddress": { 65 | "firstName": "Tim", 66 | "lastName": "Cook", 67 | "company": "Apple, Inc.", 68 | "address": "One Apple Park Way", 69 | "city": "Cupertino", 70 | "state": "CA", 71 | "zip": "95014" 72 | } 73 | }, 74 | { 75 | "id": "70871df2-7b8a-40fc-bb33-6d0d9af73dff", 76 | "createdAt": "2021-03-05T14:00:00.000Z", 77 | "items": [ 78 | { 79 | "productId": "canon-eos-rebel", 80 | "productName": "Canon EOS Rebel SL1", 81 | "price": 403, 82 | "quantity": 1 83 | } 84 | ], 85 | "shippingAddress": { 86 | "firstName": "Mark", 87 | "lastName": "Zuckerberg", 88 | "company": "Facebook", 89 | "address": "1 Hacker Way", 90 | "city": "Menlo Park", 91 | "state": "CA", 92 | "zip": "94025" 93 | } 94 | }, 95 | { 96 | "id": "7df4f6d1-d5a0-454c-9bed-a6284e84c372", 97 | "createdAt": "2021-02-05T14:00:00.000Z", 98 | "items": [ 99 | { 100 | "productId": "fitbit-charge-hr", 101 | "productName": "Fitbit Charge HR", 102 | "price": 136, 103 | "quantity": 1 104 | } 105 | ], 106 | "shippingAddress": { 107 | "firstName": "Jeff", 108 | "lastName": "Bezos", 109 | "company": "Amazon.com, Inc.", 110 | "address": "2111 7th Ave", 111 | "city": "Seattle", 112 | "state": "WA", 113 | "zip": "98121" 114 | } 115 | } 116 | ] 117 | -------------------------------------------------------------------------------- /src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { handlers } from './handlers'; 3 | 4 | // This configures a request mocking server with the given request handlers. 5 | export const server = setupServer(...handlers); 6 | -------------------------------------------------------------------------------- /src/models/Address.ts: -------------------------------------------------------------------------------- 1 | export interface Address { 2 | firstName: string; 3 | lastName: string; 4 | company?: string; 5 | address: string; 6 | city: string; 7 | state: string; 8 | zip: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/Cart.test.ts: -------------------------------------------------------------------------------- 1 | import { Cart, CartUtils } from './Cart'; 2 | import { Product } from './Product'; 3 | 4 | const product1: Product = { 5 | id: 'apple-macbook-pro', 6 | name: 'MacBook Pro', 7 | description: 'Defy limits and change the world', 8 | manufacturer: 'Apple', 9 | price: 100, 10 | photo: 'https://photos.example.com/1', 11 | }; 12 | 13 | const product2: Product = { 14 | id: 'apple-imac', 15 | name: 'iMac', 16 | description: 'Take any idea to the next level', 17 | manufacturer: 'Apple', 18 | price: 200, 19 | photo: 'https://photos.example.com/2', 20 | }; 21 | 22 | const cartQuantity0: Cart = { 23 | items: [], 24 | }; 25 | 26 | const cartQuantity1: Cart = { 27 | items: [ 28 | { 29 | productId: 'apple-macbook-pro', 30 | productName: 'MacBook Pro', 31 | price: 100, 32 | quantity: 1, 33 | }, 34 | ], 35 | }; 36 | 37 | const cartQuantity2: Cart = { 38 | items: [ 39 | { 40 | productId: 'apple-macbook-pro', 41 | productName: 'MacBook Pro', 42 | price: 100, 43 | quantity: 1, 44 | }, 45 | { 46 | productId: 'apple-imac', 47 | productName: 'iMac', 48 | price: 200, 49 | quantity: 1, 50 | }, 51 | ], 52 | }; 53 | 54 | const cartQuantity3: Cart = { 55 | items: [ 56 | { 57 | productId: 'apple-macbook-pro', 58 | productName: 'MacBook Pro', 59 | price: 100, 60 | quantity: 2, 61 | }, 62 | { 63 | productId: 'apple-imac', 64 | productName: 'iMac', 65 | price: 200, 66 | quantity: 1, 67 | }, 68 | ], 69 | }; 70 | 71 | const cartQuantity5: Cart = { 72 | items: [ 73 | { 74 | productId: 'apple-macbook-pro', 75 | productName: 'MacBook Pro', 76 | price: 100, 77 | quantity: 2, 78 | }, 79 | { 80 | productId: 'apple-imac', 81 | productName: 'iMac', 82 | price: 200, 83 | quantity: 3, 84 | }, 85 | ], 86 | }; 87 | 88 | describe('CartUtils', () => { 89 | describe('total()', () => { 90 | it('return 0 if cart is empty', () => { 91 | expect(CartUtils.total(cartQuantity0)).toBe(0); 92 | }); 93 | 94 | it('return the correct total if cart is populated', () => { 95 | expect(CartUtils.total(cartQuantity5)).toBe(800); 96 | }); 97 | }); 98 | 99 | describe('addProduct()', () => { 100 | it('adds new item if product does not exist in the cart', () => { 101 | let cart = CartUtils.addProduct(cartQuantity0, product1); 102 | expect(cart).toEqual(cartQuantity1); 103 | cart = CartUtils.addProduct(cart, product2); 104 | expect(cart).toEqual(cartQuantity2); 105 | }); 106 | 107 | it('increments the quantity if product already exists in the cart', () => { 108 | let cart = CartUtils.addProduct(cartQuantity0, product1); 109 | cart = CartUtils.addProduct(cart, product2); 110 | cart = CartUtils.addProduct(cart, product1); 111 | 112 | expect(cart).toEqual(cartQuantity3); 113 | }); 114 | }); 115 | 116 | describe('deleteItem()', () => { 117 | it('deletes the specified item from the cart', () => { 118 | let cart = CartUtils.addProduct(cartQuantity0, product1); 119 | cart = CartUtils.addProduct(cart, product2); 120 | expect(cart).toEqual(cartQuantity2); 121 | 122 | cart = CartUtils.deleteItem(cart, product2.id); 123 | expect(cart).toEqual(cartQuantity1); 124 | }); 125 | }); 126 | 127 | describe('setItemQuantity()', () => { 128 | it('sets quantity to the specified value', () => { 129 | let cart = CartUtils.addProduct(cartQuantity0, product1); 130 | cart = CartUtils.addProduct(cart, product2); 131 | cart = CartUtils.setItemQuantity(cart, product1.id, 2); 132 | cart = CartUtils.setItemQuantity(cart, product2.id, 3); 133 | 134 | expect(cart).toEqual(cartQuantity5); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/models/Cart.ts: -------------------------------------------------------------------------------- 1 | import { OrderItem, OrderItemUtils } from './OrderItem'; 2 | import { Product } from './Product'; 3 | 4 | // ---------------------------------------------------------------------------- 5 | // Cart contains a list of OrderItems. 6 | // ---------------------------------------------------------------------------- 7 | export interface Cart { 8 | items: Array; 9 | } 10 | 11 | // ---------------------------------------------------------------------------- 12 | // Cart utility functions 13 | // These are pure functions with a deterministic output. 14 | // If the cart needs to be modified, a new instance is returned. 15 | // In other words, the Cart is immutable. 16 | // ---------------------------------------------------------------------------- 17 | function total(cart: Cart): number { 18 | return OrderItemUtils.totalItems(cart.items); 19 | } 20 | 21 | function findItem(cart: Cart, productId: string): OrderItem | undefined { 22 | return cart.items.find((item) => item.productId === productId); 23 | } 24 | 25 | function addItem(cart: Cart, item: OrderItem): Cart { 26 | // make a copy of items and add a new one 27 | let newItems = cart.items.slice(); 28 | newItems.push(item); 29 | return { ...cart, items: newItems }; 30 | } 31 | 32 | function deleteItem(cart: Cart, productId: string): Cart { 33 | let newItems = cart.items.filter((item) => item.productId !== productId); 34 | return { ...cart, items: newItems }; 35 | } 36 | 37 | function setItemQuantity(cart: Cart, productId: string, quantity: number) { 38 | let newItems = cart.items.map((item) => 39 | item.productId !== productId 40 | ? item 41 | : { 42 | productId: item.productId, 43 | productName: item.productName, 44 | price: item.price, 45 | quantity, 46 | } 47 | ); 48 | return { ...cart, items: newItems }; 49 | } 50 | 51 | /** 52 | * If the product already exists in the cart, simply increments the quantity 53 | */ 54 | function addProduct(cart: Cart, product: Product): Cart { 55 | const { id, name, price } = product; 56 | const existingItem = findItem(cart, id); 57 | return existingItem 58 | ? setItemQuantity(cart, id, existingItem.quantity + 1) 59 | : addItem(cart, { 60 | productId: id, 61 | productName: name, 62 | price: price, 63 | quantity: 1, 64 | }); 65 | } 66 | 67 | export const CartUtils = { 68 | total, 69 | addProduct, 70 | deleteItem, 71 | setItemQuantity, 72 | }; 73 | -------------------------------------------------------------------------------- /src/models/CheckoutInfo.ts: -------------------------------------------------------------------------------- 1 | import { Address } from './Address'; 2 | 3 | /** 4 | * Information necessary to checkout, for example: 5 | * shippingAddress 6 | * shippingOptions 7 | * paymentMethod 8 | * 9 | * For simplicity, we ask for shippingAddress only 10 | */ 11 | export interface CheckoutInfo { 12 | shippingAddress: Address; 13 | } 14 | -------------------------------------------------------------------------------- /src/models/Env.test.ts: -------------------------------------------------------------------------------- 1 | import { Env, WindowEnv } from './Env'; 2 | 3 | // Set username in window environment 4 | (window as any)._env_ = { 5 | USERNAME: 'john', 6 | }; 7 | 8 | const env: Env = new WindowEnv(); 9 | 10 | describe('Env', () => { 11 | describe('get()', () => { 12 | it('return value if environment variable exists', () => { 13 | expect(env.get('USERNAME')).toEqual('john'); 14 | }); 15 | 16 | it('return default value if environment variable does not exists', () => { 17 | expect(env.get('SHOW_LOGO', 'Y')).toEqual('Y'); 18 | }); 19 | 20 | it('throws if environment variable does not exists and default value is not provided', () => { 21 | expect(() => env.get('SHOW_LOGO')).toThrow( 22 | 'Environment variable SHOW_LOGO not found' 23 | ); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/models/Env.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An interface representing an environment with environment variables 3 | */ 4 | export interface Env { 5 | get: (varName: string, defaultValue?: string) => string; 6 | } 7 | 8 | /** 9 | * Implementation that uses the global window object - window._env_ 10 | */ 11 | export class WindowEnv implements Env { 12 | get(varName: string, defaultValue?: string): string { 13 | const value = 14 | window && (window as any)._env_ && (window as any)._env_[varName]; 15 | if (value !== undefined) { 16 | return value; 17 | } else if (defaultValue !== undefined) { 18 | return defaultValue; 19 | } else { 20 | throw new Error(`Environment variable ${varName} not found`); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/models/Order.ts: -------------------------------------------------------------------------------- 1 | import { Address } from './Address'; 2 | import { OrderItem } from './OrderItem'; 3 | 4 | export interface Order { 5 | id: string; 6 | createdAt: string; // ISO 8601 formatted timestamp 7 | items: Array; 8 | shippingAddress: Address; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/OrderItem.ts: -------------------------------------------------------------------------------- 1 | export interface OrderItem { 2 | productId: string; 3 | productName: string; 4 | price: number; 5 | quantity: number; 6 | } 7 | 8 | function totalItem(item: OrderItem): number { 9 | return item.price * item.quantity; 10 | } 11 | 12 | function totalItems(items: Array): number { 13 | return items.reduce((accumulator, item) => { 14 | return accumulator + totalItem(item); 15 | }, 0); 16 | } 17 | 18 | export const OrderItemUtils = { 19 | totalItem, 20 | totalItems, 21 | }; 22 | -------------------------------------------------------------------------------- /src/models/Product.ts: -------------------------------------------------------------------------------- 1 | export interface Product { 2 | id: string; 3 | name: string; 4 | manufacturer: string; 5 | description: string; 6 | price: number; 7 | photo: string; 8 | } 9 | 10 | export type Catalog = { [id: string]: Product }; 11 | -------------------------------------------------------------------------------- /src/models/README.md: -------------------------------------------------------------------------------- 1 | # models 2 | 3 | This folder contains models (data structures) that are reused across application 4 | features. 5 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Address'; 2 | export * from './Env'; 3 | export * from './CheckoutInfo'; 4 | export * from './Cart'; 5 | export * from './Order'; 6 | export * from './OrderItem'; 7 | export * from './Product'; 8 | -------------------------------------------------------------------------------- /src/pages/CheckoutPage/CartSummary/CartSummary.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { rest } from 'msw'; 3 | import { MOCK_API_URL } from '../../../mocks/constants'; 4 | import { server } from '../../../mocks/server'; 5 | import { render, screen } from '../../../test/test-utils'; 6 | import { CartSummary } from './CartSummary'; 7 | 8 | const cart = { 9 | items: [ 10 | { 11 | productId: 'apple-imac', 12 | productName: 'iMac', 13 | price: 1299, 14 | quantity: 1, 15 | }, 16 | { 17 | productId: 'apple-macbook-pro', 18 | productName: 'MacBook Pro', 19 | price: 699, 20 | quantity: 1, 21 | }, 22 | ], 23 | }; 24 | 25 | describe('', () => { 26 | test('renders correctly', async () => { 27 | // simulate 2 items in the cart 28 | server.use( 29 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 30 | return res(ctx.status(200), ctx.json(cart)); 31 | }) 32 | ); 33 | 34 | render(); 35 | 36 | // expect 2 items 37 | expect(await screen.findAllByTestId('order-item')).toHaveLength(2); 38 | }); 39 | 40 | test('renders an error if fetching of the cart fails', async () => { 41 | // simulate an error when fetching the cart 42 | server.use( 43 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 44 | return res(ctx.status(404)); 45 | }) 46 | ); 47 | 48 | // Suppress console errors 49 | jest.spyOn(console, 'error').mockImplementation(() => {}); 50 | 51 | render(); 52 | const errorMessage = await screen.findByText(/404/); 53 | expect(errorMessage).toBeInTheDocument(); 54 | 55 | jest.restoreAllMocks(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/pages/CheckoutPage/CartSummary/CartSummary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { Loading, OrderItemList } from '../../../components'; 3 | import { useCartQuery } from '../../../services'; 4 | import { StringUtils } from '../../../utils'; 5 | 6 | export const CartSummary = () => { 7 | const { isLoading, isError, error, data: cart } = useCartQuery(); 8 | 9 | if (isLoading) { 10 | return ; 11 | } 12 | 13 | if (isError) { 14 | return ( 15 |
16 |

{StringUtils.errorToString(error)}

17 |
18 | ); 19 | } 20 | 21 | if (cart === undefined) { 22 | return ( 23 |
24 |

Could not fetch the cart

25 |
26 | ); 27 | } 28 | 29 | return ( 30 | 31 |

Shopping Cart

32 | {cart.items.length === 0 ? ( 33 |

Your cart is empty.

34 | ) : ( 35 | 36 | )} 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/pages/CheckoutPage/CartSummary/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CartSummary'; 2 | -------------------------------------------------------------------------------- /src/pages/CheckoutPage/CheckoutForm/CheckoutForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { yupResolver } from '@hookform/resolvers/yup'; 3 | import { FormProvider, useForm } from 'react-hook-form'; 4 | import * as yup from 'yup'; 5 | import { CheckoutInfo } from '../../../models'; 6 | import { AddressForm, getAddressSchema } from '../../../components'; 7 | 8 | export interface CheckoutFormProps { 9 | onSubmit: (checkoutInfo: CheckoutInfo) => void; 10 | } 11 | 12 | export const CheckoutForm = ({ onSubmit }: CheckoutFormProps) => { 13 | const schema = yup.object().shape({ 14 | shippingAddress: getAddressSchema(), 15 | }); 16 | 17 | const methods = useForm({ 18 | mode: 'onBlur', 19 | resolver: yupResolver(schema), 20 | }); 21 | 22 | return ( 23 | 24 |
25 |

Checkout

26 | 27 | 28 | 31 | 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/pages/CheckoutPage/CheckoutForm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CheckoutForm'; 2 | -------------------------------------------------------------------------------- /src/pages/CheckoutPage/CheckoutPage.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { rest } from 'msw'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import { MOCK_API_URL } from '../../mocks/constants'; 5 | import mockOrders from '../../mocks/mockOrders.json'; 6 | import { server } from '../../mocks/server'; 7 | import { render, screen, userEvent, waitFor } from '../../test/test-utils'; 8 | import { CheckoutPage } from './CheckoutPage'; 9 | import { CheckoutInfo, Order } from '../../models'; 10 | 11 | const address = mockOrders[0].shippingAddress; 12 | 13 | const handleShippingAddress = jest.fn(); 14 | 15 | describe('', () => { 16 | test('allows to place an order', async () => { 17 | // mock create order API 18 | server.use( 19 | rest.post(`${MOCK_API_URL}/orders`, (req, res, ctx) => { 20 | const checkoutInfo = req.body as CheckoutInfo; 21 | 22 | handleShippingAddress(checkoutInfo.shippingAddress); 23 | 24 | // Create a dummy order 25 | const order: Order = { 26 | id: uuidv4(), 27 | createdAt: new Date().toISOString(), 28 | items: [], 29 | shippingAddress: checkoutInfo.shippingAddress, 30 | }; 31 | 32 | return res(ctx.status(200), ctx.json(order)); 33 | }) 34 | ); 35 | 36 | render(); 37 | 38 | // Enter shipping address and place order 39 | userEvent.type( 40 | screen.getByRole('textbox', { name: /first name/i }), 41 | address.firstName 42 | ); 43 | userEvent.type( 44 | screen.getByRole('textbox', { name: /last name/i }), 45 | address.lastName 46 | ); 47 | userEvent.type( 48 | screen.getByRole('textbox', { name: /company/i }), 49 | address.company 50 | ); 51 | userEvent.type( 52 | screen.getByRole('textbox', { name: /address/i }), 53 | address.address 54 | ); 55 | userEvent.type( 56 | screen.getByRole('textbox', { name: /city/i }), 57 | address.city 58 | ); 59 | userEvent.type( 60 | screen.getByRole('textbox', { name: /state/i }), 61 | address.state 62 | ); 63 | userEvent.type(screen.getByRole('textbox', { name: /zip/i }), address.zip); 64 | userEvent.click(screen.getByRole('button', { name: /place your order/i })); 65 | 66 | // expect create order API to be called 67 | await waitFor(() => expect(handleShippingAddress).toBeCalledTimes(1)); 68 | expect(handleShippingAddress).toBeCalledWith(address); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/pages/CheckoutPage/CheckoutPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { 4 | Header, 5 | HorizontalContainer, 6 | ScrollingContainer, 7 | ViewVerticalContainer, 8 | } from '../../components'; 9 | import { useCreateOrder } from '../../services'; 10 | import { CheckoutInfo } from '../../models'; 11 | import { CheckoutForm } from './CheckoutForm'; 12 | import { CartSummary } from './CartSummary'; 13 | 14 | export const CheckoutPage = () => { 15 | const createOrderMutation = useCreateOrder(); 16 | const navigate = useNavigate(); 17 | 18 | const handleSubmit = (checkoutInfo: CheckoutInfo) => { 19 | createOrderMutation.mutate(checkoutInfo); 20 | navigate('/orders'); 21 | }; 22 | 23 | return ( 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/pages/CheckoutPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CheckoutPage'; 2 | -------------------------------------------------------------------------------- /src/pages/HomePage/CartView/CartView.css: -------------------------------------------------------------------------------- 1 | .cart__qty { 2 | width: var(--sp-5); 3 | text-align: right; 4 | } 5 | 6 | .cart__qty-col { 7 | width: 36px; 8 | } 9 | 10 | .cart__price-col { 11 | width: 110px; 12 | } 13 | 14 | .cart__del-col { 15 | width: 30px; 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/HomePage/CartView/CartView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta } from '@storybook/react'; 3 | import { CartView } from './CartView'; 4 | 5 | export default { 6 | title: 'Pages/HomePage/CartView', 7 | component: CartView, 8 | } as Meta; 9 | 10 | export const CartViewStory = () => { 11 | return ( 12 |
13 |

14 | Add items here from the CatalogView. 15 |

16 | 17 |
18 | ); 19 | }; 20 | CartViewStory.storyName = 'CartView'; 21 | -------------------------------------------------------------------------------- /src/pages/HomePage/CartView/CartView.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { rest } from 'msw'; 3 | import { Route, Routes } from 'react-router-dom'; 4 | import { MOCK_API_URL } from '../../../mocks/constants'; 5 | import { server } from '../../../mocks/server'; 6 | import { CartUtils } from '../../../models'; 7 | import { 8 | render, 9 | screen, 10 | userEvent, 11 | waitFor, 12 | waitForElementToBeRemoved, 13 | } from '../../../test/test-utils'; 14 | import { CartView } from './CartView'; 15 | 16 | const START_ORDER = 'Please click on a product to start your order.'; 17 | 18 | const cart = { 19 | items: [ 20 | { 21 | productId: 'apple-imac', 22 | productName: 'iMac', 23 | price: 1299, 24 | quantity: 1, 25 | }, 26 | { 27 | productId: 'apple-macbook-pro', 28 | productName: 'MacBook Pro', 29 | price: 699, 30 | quantity: 1, 31 | }, 32 | ], 33 | }; 34 | 35 | const MockCheckoutPage = () => { 36 | return

Checkout Page

; 37 | }; 38 | 39 | describe('', () => { 40 | test('renders correctly with no order items', async () => { 41 | render(); 42 | 43 | // start order message should exist 44 | const startOrderMessage = await screen.findByText(START_ORDER); 45 | expect(startOrderMessage).toBeInTheDocument(); 46 | 47 | // checkout button should not exist 48 | const checkoutButton = screen.queryByText('Checkout'); 49 | expect(checkoutButton).toBeNull(); 50 | }); 51 | 52 | test('renders correctly with one or more order items', async () => { 53 | // simulate 2 items in the cart 54 | server.use( 55 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 56 | return res(ctx.status(200), ctx.json(cart)); 57 | }) 58 | ); 59 | 60 | render(); 61 | 62 | // checkout button should exist 63 | const checkoutButton = await screen.findByText('Checkout'); 64 | expect(checkoutButton).not.toBeNull(); 65 | 66 | // start order message should not exist 67 | const startOrderMessage = screen.queryByText(START_ORDER); 68 | expect(startOrderMessage).toBeNull(); 69 | 70 | // 2 order items should exist 71 | const orderItemTable = await screen.findByTestId('order-items'); 72 | const orderItems = orderItemTable.querySelectorAll('tbody tr'); 73 | expect(orderItems.length).toBe(2); 74 | }); 75 | 76 | test('renders an error if fetching of the cart fails', async () => { 77 | // simulate an error when fetching the cart 78 | server.use( 79 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 80 | return res(ctx.status(404)); 81 | }) 82 | ); 83 | 84 | // Suppress console errors 85 | jest.spyOn(console, 'error').mockImplementation(() => {}); 86 | 87 | render(); 88 | const errorMessage = await screen.findByText(/404/); 89 | expect(errorMessage).toBeInTheDocument(); 90 | 91 | jest.restoreAllMocks(); 92 | }); 93 | 94 | test('clicking on delete button deletes the item from the order', async () => { 95 | // simulate 2 items in the cart 96 | server.use( 97 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 98 | return res(ctx.status(200), ctx.json(cart)); 99 | }) 100 | ); 101 | 102 | // simulate delete from 2 items in the cart 103 | server.use( 104 | rest.delete(`${MOCK_API_URL}/cart/items/:productId`, (req, res, ctx) => { 105 | const { productId } = req.params; 106 | const newCart = CartUtils.deleteItem(cart, productId); 107 | return res(ctx.status(200), ctx.json(newCart)); 108 | }) 109 | ); 110 | 111 | render(); 112 | 113 | // wait for Checkout button to render 114 | await screen.findByText('Checkout'); 115 | 116 | // delete the first item from the order 117 | const deleteButtons = await screen.findAllByTestId('delete-button'); 118 | expect(deleteButtons.length).toBe(2); 119 | userEvent.click(deleteButtons[0]); 120 | 121 | // wait for 'iMac' to disappear 122 | await waitForElementToBeRemoved(() => screen.getByText('iMac')); 123 | 124 | // only 1 item should remain 125 | const orderItemTable = await screen.findByTestId('order-items'); 126 | expect(orderItemTable.querySelectorAll('tbody tr').length).toBe(1); 127 | 128 | // the remaining item should be 'MacBook Pro' 129 | const macbookPro = screen.getByText('MacBook Pro'); 130 | expect(macbookPro).toBeInTheDocument(); 131 | }); 132 | 133 | test('item quantity can be changed by typing into the quantity field', async () => { 134 | // simulate 2 items in the cart 135 | server.use( 136 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 137 | return res(ctx.status(200), ctx.json(cart)); 138 | }) 139 | ); 140 | 141 | // simulate set item quantity 142 | server.use( 143 | rest.patch(`${MOCK_API_URL}/cart/items/:productId`, (req, res, ctx) => { 144 | const { productId } = req.params; 145 | const { quantity } = req.body as any; 146 | const newCart = CartUtils.setItemQuantity(cart, productId, quantity); 147 | return res(ctx.status(200), ctx.json(newCart)); 148 | }) 149 | ); 150 | 151 | render(); 152 | 153 | // wait for 2 items to render 154 | const quantityInputs = await screen.findAllByTestId('quantity-input'); 155 | expect(quantityInputs).toHaveLength(2); 156 | 157 | // change iMac quantity to 2 158 | // Note the use of {selectall} - without this 2 will be simply 159 | // appended to the existing quantity, resulting in 12. 160 | userEvent.type(quantityInputs[0], '{selectall}2'); 161 | 162 | // wait for price of iMac line item to change 163 | const priceCells = screen.getAllByTestId('price-cell'); 164 | await waitFor(() => expect(priceCells[0].textContent).toBe('2,598.00')); 165 | }); 166 | 167 | // Note: Applications should probably not check that navigation works, 168 | // because React Router has lots of tests to assure us that it works! 169 | // However, if you must, here's how to. 170 | test('clicking on checkout button navigates to Checkout page', async () => { 171 | // simulate 2 items in the cart 172 | server.use( 173 | rest.get(`${MOCK_API_URL}/cart`, (req, res, ctx) => { 174 | return res(ctx.status(200), ctx.json(cart)); 175 | }) 176 | ); 177 | 178 | render( 179 | 180 | } /> 181 | } /> 182 | 183 | ); 184 | 185 | // click on Checkout button 186 | const checkoutButton = await screen.findByText('Checkout'); 187 | userEvent.click(checkoutButton); 188 | 189 | // expect checkout page to be rendered 190 | const checkoutPageTitle = await screen.findByText('Checkout Page'); 191 | expect(checkoutPageTitle).toBeInTheDocument(); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /src/pages/HomePage/CartView/CartView.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { NumberUtils } from '@react-force/number-utils'; 3 | import { AiTwotoneDelete } from 'react-icons/ai'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { CartUtils } from '../../../models'; 6 | import { HorizontalContainer, Loading } from '../../../components'; 7 | import { 8 | useCartQuery, 9 | useDeleteItem, 10 | useSetItemQuantity, 11 | } from '../../../services'; 12 | import { StringUtils } from '../../../utils'; 13 | import './CartView.css'; 14 | 15 | export const CartView = () => { 16 | const { isLoading, isError, error, data: cart } = useCartQuery(); 17 | const deleteItemMutation = useDeleteItem(); 18 | const setItemQuantityMutation = useSetItemQuantity(); 19 | const navigate = useNavigate(); 20 | 21 | const handleQuantityChange = (productId: string, quantity: string) => { 22 | setItemQuantityMutation.mutate({ 23 | productId, 24 | quantity: parseInt(quantity, 10), 25 | }); 26 | }; 27 | 28 | const handleDelete = (productId: string) => { 29 | deleteItemMutation.mutate(productId); 30 | }; 31 | 32 | const handleCheckout = () => { 33 | navigate('/checkout'); 34 | }; 35 | 36 | if (isLoading) { 37 | return ; 38 | } 39 | 40 | if (isError) { 41 | return ( 42 |
43 |

{StringUtils.errorToString(error)}

44 |
45 | ); 46 | } 47 | 48 | if (cart === undefined) { 49 | return ( 50 |
51 |

Could not fetch the cart

52 |
53 | ); 54 | } 55 | 56 | return ( 57 | 58 | 59 |

Shopping Cart

60 | {cart.items.length > 0 ? ( 61 | 64 | ) : null} 65 |
66 | {cart.items.length === 0 ? ( 67 |

Please click on a product to start your order.

68 | ) : ( 69 | 70 | 71 | {cart.items.map((item, index) => ( 72 | 73 | 74 | 86 | 92 | 99 | 100 | ))} 101 | 102 | 103 | 104 | 105 | 108 | 109 | 110 |
{item.productName} 75 | 82 | handleQuantityChange(item.productId, e.target.value) 83 | } 84 | /> 85 | 90 | {NumberUtils.formatAsMoney(item.price * item.quantity)} 91 | 93 | handleDelete(item.productId)} 97 | /> 98 |
Total 106 | {NumberUtils.formatAsMoney(CartUtils.total(cart))} 107 |
111 | )} 112 |
113 | ); 114 | }; 115 | -------------------------------------------------------------------------------- /src/pages/HomePage/CartView/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CartView'; 2 | -------------------------------------------------------------------------------- /src/pages/HomePage/CatalogView/CatalogView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta } from '@storybook/react'; 3 | import { CatalogView } from './CatalogView'; 4 | 5 | export default { 6 | title: 'Pages/HomePage/CatalogView', 7 | component: CatalogView, 8 | } as Meta; 9 | 10 | export const CatalogViewStory = () => ; 11 | CatalogViewStory.storyName = 'CatalogView'; 12 | -------------------------------------------------------------------------------- /src/pages/HomePage/CatalogView/CatalogView.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { rest } from 'msw'; 3 | import { MOCK_API_URL } from '../../../mocks/constants'; 4 | import { server } from '../../../mocks/server'; 5 | import { render, screen, userEvent, waitFor } from '../../../test/test-utils'; 6 | import { CatalogView } from './CatalogView'; 7 | 8 | const addProduct = jest.fn(); 9 | 10 | describe('', () => { 11 | test('renders correctly', async () => { 12 | render(); 13 | 14 | // expect 16 products 15 | const products = await screen.findAllByTestId('product'); 16 | expect(products.length).toBe(16); 17 | }); 18 | 19 | test('renders an error if fetching of the catalog fails', async () => { 20 | // simulate an error when fetching the catalog 21 | server.use( 22 | rest.get(`${MOCK_API_URL}/catalog`, (req, res, ctx) => { 23 | return res(ctx.status(404)); 24 | }) 25 | ); 26 | 27 | // suppress console errors 28 | jest.spyOn(console, 'error').mockImplementation(() => {}); 29 | 30 | render(); 31 | const errorMessage = await screen.findByText(/404/); 32 | expect(errorMessage).toBeInTheDocument(); 33 | 34 | // restore console errors 35 | jest.restoreAllMocks(); 36 | }); 37 | 38 | test('when a product is clicked, it is added to the cart', async () => { 39 | // mock add product to cart 40 | server.use( 41 | rest.post(`${MOCK_API_URL}/cart/items`, (req, res, ctx) => { 42 | const { productId } = req.body as { productId: string }; 43 | addProduct(productId); 44 | return res(ctx.status(200), ctx.json({ items: [] })); 45 | }) 46 | ); 47 | 48 | render(); 49 | 50 | // click on the first product 51 | const products = await screen.findAllByTestId('product'); 52 | userEvent.click(products[0]); 53 | 54 | // expect product to be added to the cart 55 | await waitFor(() => expect(addProduct).toBeCalledTimes(1)); 56 | expect(addProduct).toBeCalledWith('apple-imac'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/pages/HomePage/CatalogView/CatalogView.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { Loading, ProductView } from '../../../components'; 3 | import { useCatalogQuery, useAddProduct } from '../../../services'; 4 | import { StringUtils } from '../../../utils'; 5 | 6 | export const CatalogView = () => { 7 | const { isLoading, isError, error, data: catalog } = useCatalogQuery(); 8 | const addProductMutation = useAddProduct(); 9 | 10 | const handleProductClicked = (productId: string) => { 11 | addProductMutation.mutate(productId); 12 | }; 13 | 14 | if (isLoading) { 15 | return ; 16 | } 17 | 18 | if (isError) { 19 | return ( 20 |
21 |

{StringUtils.errorToString(error)}

22 |
23 | ); 24 | } 25 | 26 | if (catalog === undefined) { 27 | return ( 28 |
29 |

Could not fetch catalog

30 |
31 | ); 32 | } 33 | 34 | return ( 35 | 36 | {Object.values(catalog).map((product) => ( 37 | 42 | ))} 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/pages/HomePage/CatalogView/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CatalogView'; 2 | -------------------------------------------------------------------------------- /src/pages/HomePage/HomePage.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '../../test/test-utils'; 3 | import { HomePage } from './HomePage'; 4 | 5 | describe('', () => { 6 | test('renders correctly', async () => { 7 | render(); 8 | expect(await screen.findByText('iMac')).toBeInTheDocument(); 9 | expect(await screen.findByText('Shopping Cart')).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/pages/HomePage/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Header, 4 | HorizontalContainer, 5 | ScrollingContainer, 6 | ViewVerticalContainer, 7 | } from '../../components'; 8 | import { CatalogView } from './CatalogView'; 9 | import { CartView } from './CartView'; 10 | 11 | export const HomePage = () => { 12 | return ( 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/pages/HomePage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HomePage'; 2 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage/NotFoundPage.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '../../test/test-utils'; 3 | import { NotFoundPage } from './NotFoundPage'; 4 | 5 | describe('', () => { 6 | test('renders correctly', () => { 7 | // ----------------------------------------------------------------- 8 | // WARNING 9 | // ----------------------------------------------------------------- 10 | // This is an example of a snapshot test. In general, snapshot tests 11 | // are not recommended because: 12 | // 13 | // 1. Good tests encode the developer's intention. Snapshot tests lack 14 | // expressing the developer's intent as to what the code does as 15 | // evident from this test. 16 | // 2. Developers tend to be undisciplined about scrutinizing snapshots 17 | // before committing them, resulting in buggy code to be checked in. 18 | // 19 | // If you really must, use snapshot testing only for small focused 20 | // components. For further details, see 21 | // https://kentcdodds.com/blog/effective-snapshot-testing 22 | // 23 | // Note: We recommend using react-testing-library because it generates 24 | // cleaner snapshots. The other popular way of generating snapshots is 25 | // using react-test-renderer, but its output contains component 26 | // properties and other details that are not relevant. 27 | // ----------------------------------------------------------------- 28 | 29 | const { asFragment } = render(); 30 | expect(asFragment()).toMatchSnapshot(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * This is a placeholder for the NotFound page 5 | */ 6 | export const NotFoundPage = () => { 7 | return ( 8 |
9 |

Page Not Found

10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage/__snapshots__/NotFoundPage.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders correctly 1`] = ` 4 | 5 |
6 |

7 | Page Not Found 8 |

9 |
10 |
11 | `; 12 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './NotFoundPage'; 2 | -------------------------------------------------------------------------------- /src/pages/OrdersPage/OrdersPage.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { rest } from 'msw'; 3 | import { MOCK_API_URL } from '../../mocks/constants'; 4 | import { server } from '../../mocks/server'; 5 | import { 6 | render, 7 | screen, 8 | waitForElementToBeRemoved, 9 | } from '../../test/test-utils'; 10 | import { OrdersPage } from './OrdersPage'; 11 | 12 | describe('', () => { 13 | test('renders correctly', async () => { 14 | render(); 15 | expect(await screen.findAllByTestId('order-view')).toHaveLength(4); 16 | }); 17 | 18 | test('renders correctly (using waitForElementToBeRemoved)', async () => { 19 | render(); 20 | await waitForElementToBeRemoved(() => screen.getByText('Loading...')); 21 | expect(screen.getAllByTestId('order-view')).toHaveLength(4); 22 | }); 23 | 24 | test('renders an error if fetching of the orders fails', async () => { 25 | // simulate an error when fetching orders 26 | server.use( 27 | rest.get(`${MOCK_API_URL}/orders`, (req, res, ctx) => { 28 | return res(ctx.status(404)); 29 | }) 30 | ); 31 | 32 | // Suppress console errors 33 | jest.spyOn(console, 'error').mockImplementation(() => {}); 34 | 35 | render(); 36 | const errorMessage = await screen.findByText(/404/); 37 | expect(errorMessage).toBeInTheDocument(); 38 | 39 | jest.restoreAllMocks(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/pages/OrdersPage/OrdersPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Header, 4 | Loading, 5 | OrderView, 6 | ScrollingContainer, 7 | ViewVerticalContainer, 8 | } from '../../components'; 9 | import { Order } from '../../models'; 10 | import { useOrdersQuery } from '../../services'; 11 | import { StringUtils } from '../../utils'; 12 | 13 | export const OrdersPage = () => { 14 | const { isLoading, isError, error, data: orders } = useOrdersQuery(); 15 | 16 | if (isLoading) { 17 | return ; 18 | } 19 | 20 | if (isError) { 21 | return ( 22 |
23 |

{StringUtils.errorToString(error)}

24 |
25 | ); 26 | } 27 | 28 | if (orders === undefined) { 29 | return ( 30 |
31 |

Could not fetch the orders

32 |
33 | ); 34 | } 35 | 36 | // sort the orders by creation date descending 37 | const sortedOrders = orders.sort((order1: Order, order2: Order) => { 38 | const date1 = new Date(order1.createdAt); 39 | const date2 = new Date(order2.createdAt); 40 | if (date1 < date2) return 1; 41 | if (date1 > date2) return -1; 42 | return 0; 43 | }); 44 | 45 | return ( 46 | 47 |
48 | 49 | {sortedOrders.length === 0 ? ( 50 |

Your have no orders.

51 | ) : ( 52 | sortedOrders.map((order) => ( 53 |
54 | 55 |
56 | )) 57 | )} 58 |
59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/pages/OrdersPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './OrdersPage'; 2 | -------------------------------------------------------------------------------- /src/pages/README.md: -------------------------------------------------------------------------------- 1 | # pages 2 | 3 | This folder contains the top-level pages offered by the app. Pages are generally 4 | associated with high-level business features. 5 | -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CheckoutPage'; 2 | export * from './HomePage'; 3 | export * from './NotFoundPage'; 4 | export * from './OrdersPage'; 5 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/services/AxiosInterceptors.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { WindowEnv } from '../models'; 3 | import { EnvVar } from '../utils'; 4 | 5 | // ----- Axios interceptor to configure all requests ----- 6 | axios.interceptors.request.use(async (config) => { 7 | // configure baseURL 8 | const env = new WindowEnv(); 9 | config.baseURL = env.get(EnvVar.API_URL); 10 | 11 | return config; 12 | }); 13 | -------------------------------------------------------------------------------- /src/services/CartService.ts: -------------------------------------------------------------------------------- 1 | import { formatHttpError } from '@http-utils/core'; 2 | import axios from 'axios'; 3 | import { useMutation, useQuery, useQueryClient } from 'react-query'; 4 | import { Cart } from '../models'; 5 | 6 | // ---------- fetchCart ---------- 7 | export const fetchCart = async (): Promise => { 8 | try { 9 | const resp = await axios.get('/cart'); 10 | return resp.data; 11 | } catch (e) { 12 | throw new Error(formatHttpError(e)); 13 | } 14 | }; 15 | 16 | export const useCartQuery = () => { 17 | return useQuery('cart', fetchCart); 18 | }; 19 | 20 | // ---------- addProduct ---------- 21 | export const addProduct = async (productId: string) => { 22 | try { 23 | const resp = await axios.post('/cart/items', { productId }); 24 | return resp.data; 25 | } catch (e) { 26 | throw new Error(formatHttpError(e)); 27 | } 28 | }; 29 | 30 | export const useAddProduct = () => { 31 | const queryClient = useQueryClient(); 32 | 33 | return useMutation(addProduct, { 34 | onSuccess: (data) => { 35 | // update cart from the response 36 | queryClient.setQueryData('cart', data); 37 | }, 38 | }); 39 | }; 40 | 41 | // ---------- deleteItem ---------- 42 | export const deleteItem = async (productId: string) => { 43 | try { 44 | const resp = await axios.delete(`/cart/items/${productId}`); 45 | return resp.data; 46 | } catch (e) { 47 | throw new Error(formatHttpError(e)); 48 | } 49 | }; 50 | 51 | export const useDeleteItem = () => { 52 | const queryClient = useQueryClient(); 53 | 54 | return useMutation(deleteItem, { 55 | onSuccess: (data) => { 56 | // update cart from the response 57 | queryClient.setQueryData('cart', data); 58 | }, 59 | }); 60 | }; 61 | 62 | // ---------- setItemQuantity ---------- 63 | type ItemQuantityInput = { productId: string; quantity: number }; 64 | 65 | export const setItemQuantity = async ({ 66 | productId, 67 | quantity, 68 | }: ItemQuantityInput) => { 69 | try { 70 | const resp = await axios.patch(`/cart/items/${productId}`, { quantity }); 71 | return resp.data; 72 | } catch (e) { 73 | throw new Error(formatHttpError(e)); 74 | } 75 | }; 76 | 77 | export const useSetItemQuantity = () => { 78 | const queryClient = useQueryClient(); 79 | 80 | return useMutation(setItemQuantity, { 81 | onSuccess: (data) => { 82 | // update cart from the response 83 | queryClient.setQueryData('cart', data); 84 | }, 85 | }); 86 | }; 87 | 88 | export const CartService = { 89 | addProduct, 90 | }; 91 | -------------------------------------------------------------------------------- /src/services/CatalogService.ts: -------------------------------------------------------------------------------- 1 | import { formatHttpError } from '@http-utils/core'; 2 | import axios from 'axios'; 3 | import { useQuery } from 'react-query'; 4 | import { Catalog } from '../models'; 5 | 6 | // ---------- fetchCatalog ---------- 7 | export const fetchCatalog = async (): Promise => { 8 | try { 9 | const resp = await axios.get('/catalog'); 10 | return resp.data; 11 | } catch (e) { 12 | throw new Error(formatHttpError(e)); 13 | } 14 | }; 15 | 16 | export const useCatalogQuery = () => { 17 | return useQuery('catalog', fetchCatalog); 18 | }; 19 | -------------------------------------------------------------------------------- /src/services/OrderService.ts: -------------------------------------------------------------------------------- 1 | import { formatHttpError } from '@http-utils/core'; 2 | import axios from 'axios'; 3 | import { useMutation, useQuery, useQueryClient } from 'react-query'; 4 | import { CheckoutInfo, Order } from '../models'; 5 | 6 | // ---------- fetchOrders ---------- 7 | export const fetchOrders = async (): Promise> => { 8 | try { 9 | const resp = await axios.get('/orders'); 10 | return resp.data; 11 | } catch (e) { 12 | throw new Error(formatHttpError(e)); 13 | } 14 | }; 15 | 16 | export const useOrdersQuery = () => { 17 | return useQuery('orders', fetchOrders); 18 | }; 19 | 20 | // ---------- createOrder ---------- 21 | export const createOrder = async (checkoutInfo: CheckoutInfo) => { 22 | try { 23 | const resp = await axios.post('/orders', checkoutInfo); 24 | return resp.data; 25 | } catch (e) { 26 | throw new Error(formatHttpError(e)); 27 | } 28 | }; 29 | 30 | export const useCreateOrder = () => { 31 | const queryClient = useQueryClient(); 32 | 33 | return useMutation(createOrder, { 34 | onSuccess: (data) => { 35 | queryClient.invalidateQueries('cart'); 36 | queryClient.invalidateQueries('orders'); 37 | }, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /src/services/README.md: -------------------------------------------------------------------------------- 1 | # services 2 | 3 | This folder contains service functions (api calls, hooks etc.) that are reused 4 | across application features. 5 | -------------------------------------------------------------------------------- /src/services/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './CartService'; 2 | export * from './CatalogService'; 3 | export * from './OrderService'; 4 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | import { MOCK_API_URL } from './mocks/constants'; 7 | import { server } from './mocks/server'; 8 | 9 | // import AxiosInterceptors, otherwise service calls will fail 10 | import './services/AxiosInterceptors'; 11 | 12 | // ----- Set API_URL in window environment ----- 13 | (window as any)._env_ = { API_URL: MOCK_API_URL }; 14 | 15 | // ----- Set up Mock Service Worker ----- 16 | // Establish API mocking before all tests. 17 | beforeAll(() => server.listen()); 18 | 19 | // Reset any request handlers that we may add during the tests, 20 | // so they don't affect other tests. 21 | afterEach(() => server.resetHandlers()); 22 | 23 | // Clean up after the tests are finished. 24 | afterAll(() => server.close()); 25 | -------------------------------------------------------------------------------- /src/test/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, Suspense } from 'react'; 2 | import { render, RenderOptions } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { BrowserRouter as Router } from 'react-router-dom'; 5 | import { QueryClient, QueryClientProvider } from 'react-query'; 6 | import { ErrorBoundary, Loading } from '../components'; 7 | import { EnvProvider } from '../contexts'; 8 | 9 | // ----------------------------------------------------------------------------- 10 | // This file re-exports everything from React Testing Library and then overrides 11 | // its render method. In tests that require global context providers, import 12 | // this file instead of React Testing Library. 13 | // 14 | // For further details, see: 15 | // https://testing-library.com/docs/react-testing-library/setup/#custom-render 16 | // ----------------------------------------------------------------------------- 17 | 18 | const AllProviders: React.FC = ({ children }) => { 19 | // Create a new QueryClient for each test. QueryClient holds its own 20 | // instance of QueryCache. This way, tests are completely isolated 21 | // from each other. 22 | // 23 | // Another approach might be to clear the QueryCache after each test, 24 | // but that could be a little risky in case some state is inadvertently 25 | // shared, e.g., if the tests are run in parallel. 26 | const queryClient = new QueryClient({ 27 | defaultOptions: { 28 | queries: { 29 | // force queries to fail fast during tests, otherwise jest and 30 | // React Testing Library will hit their timeouts 31 | retry: false, 32 | }, 33 | }, 34 | }); 35 | 36 | return ( 37 | }> 38 | 39 | 40 | 41 | {children} 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | /** 50 | * Custom render method that includes global context providers 51 | */ 52 | type CustomRenderOptions = { 53 | initialRoute?: string; 54 | renderOptions?: Omit; 55 | }; 56 | 57 | function customRender(ui: ReactElement, options?: CustomRenderOptions) { 58 | const opts = options || {}; 59 | const { initialRoute, renderOptions } = opts; 60 | 61 | if (initialRoute) { 62 | window.history.pushState({}, 'Initial Route', initialRoute); 63 | } 64 | 65 | return render(ui, { wrapper: AllProviders, ...renderOptions }); 66 | } 67 | 68 | export * from '@testing-library/react'; 69 | export { customRender as render, userEvent }; 70 | -------------------------------------------------------------------------------- /src/utils/Constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Environment variables 3 | */ 4 | export const EnvVar = { 5 | API_URL: 'API_URL', 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/DateUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * formats an ISO 8601 formatted date to local date and time 3 | * 4 | * Examples: 5 | * formatISODate('2021-01-01T14:00:00.000Z') => 6 | */ 7 | const formatISODate = (isoDate: string): string => { 8 | return new Date(isoDate).toLocaleDateString('en-US', { 9 | day: '2-digit', 10 | month: '2-digit', 11 | year: 'numeric', 12 | }); 13 | }; 14 | 15 | export const DateUtils = { 16 | formatISODate, 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/README.md: -------------------------------------------------------------------------------- 1 | # utils 2 | 3 | This folder contains utilities and constants that are reused across application 4 | features. 5 | -------------------------------------------------------------------------------- /src/utils/Storage.test.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from './Storage'; 2 | 3 | describe('Storage', () => { 4 | it('allows storing and retrieving of key-value pairs', () => { 5 | const key = 'favoriteFlavor'; 6 | const value = 'Coffee Almond Fudge'; 7 | Storage.set(key, value); 8 | const flavor = Storage.get(key); 9 | expect(flavor).toBe(value); 10 | }); 11 | 12 | it('allows returning default values for keys', () => { 13 | const key = 'favoriteFruit'; 14 | const defaultValue = 'Mango'; 15 | const fruit = Storage.get(key, defaultValue); 16 | expect(fruit).toBe(defaultValue); 17 | }); 18 | 19 | it('allows removal of keys', () => { 20 | const key = 'favoriteCar'; 21 | const value = 'Tesla'; 22 | const defaultValue = 'Lexus'; 23 | Storage.set(key, value); 24 | 25 | let car = Storage.get(key, defaultValue); 26 | expect(car).toBe(value); 27 | 28 | Storage.remove(key); 29 | 30 | car = Storage.get(key, defaultValue); 31 | expect(car).toBe(defaultValue); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/utils/Storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A thin wrapper on top of localStorage 3 | */ 4 | 5 | const isEnabled = checkLocalStorageEnabled(); 6 | 7 | // Based on https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API#Feature-detecting_localStorage 8 | function checkLocalStorageEnabled() { 9 | try { 10 | const x = '__storage_test__'; 11 | localStorage.setItem(x, x); 12 | localStorage.removeItem(x); 13 | return true; 14 | } catch (e) { 15 | /* istanbul ignore next */ 16 | return false; 17 | } 18 | } 19 | 20 | /** 21 | * Adds or updates the specified key to storage 22 | * @param {string} key - name of the key to create/update 23 | * @param {any} value - value to write (undefined written as empty string) 24 | */ 25 | function set(key: string, value: any): void { 26 | if (isEnabled) { 27 | localStorage.setItem(key, value !== undefined ? JSON.stringify(value) : ''); 28 | } 29 | } 30 | 31 | /** 32 | * Returns the value of the specified key 33 | * @param {string} key - name of the key to read 34 | * @param {any} defaultValue - value to return if the key is not defined 35 | */ 36 | function get(key: string, defaultValue?: any): any { 37 | if (isEnabled) { 38 | const item = localStorage.getItem(key); 39 | return item !== null ? JSON.parse(item) : defaultValue; 40 | } else { 41 | /* istanbul ignore next */ 42 | return defaultValue; 43 | } 44 | } 45 | 46 | /** 47 | * Removes the specified key from storage 48 | * @param {string} key - name of the key to remove 49 | */ 50 | function remove(key: string) { 51 | if (isEnabled) { 52 | localStorage.removeItem(key); 53 | } 54 | } 55 | 56 | export const Storage = { 57 | isEnabled, 58 | set, 59 | get, 60 | remove, 61 | }; 62 | -------------------------------------------------------------------------------- /src/utils/StringUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { StringUtils } from './StringUtils'; 2 | 3 | const { errorToString, isBlank, isEmpty } = StringUtils; 4 | 5 | describe('errorToString()', () => { 6 | it('returns error.message for Error objects', () => { 7 | const errorMessage = 'Network Error'; 8 | expect(errorToString(new Error(errorMessage))).toBe(errorMessage); 9 | }); 10 | 11 | it('returns strings as is', () => { 12 | const errorMessage = 'Network Error'; 13 | expect(errorToString(errorMessage)).toBe(errorMessage); 14 | }); 15 | 16 | it('returns "Something went wrong" for all other types', () => { 17 | const errorMessage = 'Something went wrong'; 18 | expect(errorToString({ code: 404, message: 'Not Found' })).toBe( 19 | errorMessage 20 | ); 21 | }); 22 | }); 23 | 24 | describe('isBlank()', () => { 25 | it('returns true for undefined', () => { 26 | expect(isBlank(undefined)).toBe(true); 27 | }); 28 | 29 | it('returns true for null', () => { 30 | expect(isBlank(null)).toBe(true); 31 | }); 32 | 33 | it('returns true for zero length string', () => { 34 | expect(isBlank('')).toBe(true); 35 | }); 36 | 37 | it('returns true for whitespace-only string', () => { 38 | expect(isBlank(' ')).toBe(true); 39 | }); 40 | 41 | it('returns false for strings with trimmed length > 0', () => { 42 | expect(isBlank('bob')).toBe(false); 43 | expect(isBlank(' bob ')).toBe(false); 44 | }); 45 | }); 46 | 47 | describe('isEmpty()', () => { 48 | it('returns true for undefined', () => { 49 | expect(isEmpty(undefined)).toBe(true); 50 | }); 51 | 52 | it('returns true for null', () => { 53 | expect(isEmpty(null)).toBe(true); 54 | }); 55 | 56 | it('returns true for zero length string', () => { 57 | expect(isEmpty('')).toBe(true); 58 | }); 59 | 60 | it('returns false for whitespace-only string', () => { 61 | expect(isEmpty(' ')).toBe(false); 62 | }); 63 | 64 | it('returns false for strings with trimmed length > 0', () => { 65 | expect(isEmpty('bob')).toBe(false); 66 | expect(isEmpty(' bob ')).toBe(false); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/utils/StringUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on https://github.com/nareshbhatia/react-force 3 | */ 4 | 5 | /** 6 | * Returns an error message from any passed object 7 | */ 8 | const errorToString = (error: any) => { 9 | let result = 'Something went wrong'; 10 | 11 | if (error instanceof Error) { 12 | result = error.message; 13 | } else if (typeof error === 'string') { 14 | result = error; 15 | } 16 | 17 | return result; 18 | }; 19 | 20 | /** 21 | * isBlank 22 | * ------- 23 | * Returns true if string is undefined or null or it's trimmed length is 0. 24 | * So whitespace-only strings will return true. 25 | * 26 | * Examples: 27 | * isBlank(undefined) // => true 28 | * isBlank(null) // => true 29 | * isBlank('') // => true 30 | * isBlank(' ') // => true 31 | * isBlank('bob') // => false 32 | * isBlank(' bob ') // => false 33 | */ 34 | const isBlank = (str: string | undefined | null): boolean => { 35 | return !str || str.trim().length === 0; 36 | }; 37 | 38 | /** 39 | * isEmpty 40 | * ------- 41 | * Returns true if string is undefined or null or it's length is 0 42 | * So whitespace-only strings will return false. 43 | * 44 | * Examples: 45 | * isEmpty(undefined) // => true 46 | * isEmpty(null) // => true 47 | * isEmpty('') // => true 48 | * isEmpty(' ') // => false 49 | * isEmpty('bob') // => false 50 | * isEmpty(' bob ') // => false 51 | */ 52 | const isEmpty = (str: string | undefined | null): boolean => { 53 | return !str || str.length === 0; 54 | }; 55 | 56 | export const StringUtils = { 57 | errorToString, 58 | isBlank, 59 | isEmpty, 60 | }; 61 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Constants'; 2 | export * from './DateUtils'; 3 | export * from './Storage'; 4 | export * from './StringUtils'; 5 | export * from './yupLocale'; 6 | -------------------------------------------------------------------------------- /src/utils/yupLocale.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck (to ignore typechecking on validation function parameters) 2 | /* eslint-disable no-template-curly-in-string */ 3 | export const yupLocale = { 4 | mixed: { 5 | default: 'Field is invalid', 6 | required: 'Field is required', 7 | notType: 'Must be a ${type}', 8 | }, 9 | string: { 10 | email: 'Email is invalid', 11 | min: 'Must be at least ${min} characters', 12 | max: 'Must be at least ${max} characters', 13 | }, 14 | number: {}, 15 | boolean: {}, 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | --------------------------------------------------------------------------------