├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.js ├── App.test.js ├── __mocks__ │ └── axios.js ├── __snapshots__ │ └── App.test.js.snap ├── actions │ ├── users.js │ └── users.test.js ├── components │ ├── User.css │ └── User.js ├── index.css ├── index.js ├── logo.svg ├── reducers │ ├── rootReducer.js │ ├── users.js │ └── users.test.js ├── serviceWorker.js ├── setupTests.js └── store.js └── yarn.lock /.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 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Testing in React 2 | 3 | This project is supportive to my blog post that I wrote talking about all the cases that you need to test in React. 4 | https://vaskort.com/how-to-test-your-react-app/ 5 | 6 | ## Installation 7 | 8 | ```bash 9 | yarn install 10 | yarn start 11 | ``` 12 | 13 | ## Run tests 14 | 15 | ```bash 16 | yarn test 17 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-testing", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.27.2", 7 | "react": "^16.14.0", 8 | "react-dom": "^16.14.0", 9 | "react-redux": "^8.0.2", 10 | "react-scripts": "5.0.1", 11 | "redux": "^4.2.0", 12 | "redux-logger": "^3.0.6", 13 | "redux-mock-store": "^1.5.4", 14 | "redux-promise-middleware": "^5.1.1", 15 | "redux-thunk": "^2.4.1" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ], 32 | "devDependencies": { 33 | "enzyme": "^3.11.0", 34 | "enzyme-adapter-react-16": "^1.15.6" 35 | }, 36 | "resolutions": { 37 | "minimist": "^1.2.6", 38 | "nth-check": "^2.0.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaskort/react-testing/dfda185b5188700906069efa50610d73da224d0d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | background-color: #282c34; 4 | min-height: 100vh; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | font-size: calc(10px + 2vmin); 10 | color: white; 11 | } 12 | 13 | .App-link { 14 | color: #61dafb; 15 | } 16 | 17 | .loadButton { 18 | font-size: 20px; 19 | border-radius: 5px; 20 | } 21 | 22 | ul { 23 | padding: 0; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { connect } from "react-redux"; 3 | import { getUsers } from "./actions/users"; 4 | import User from "./components/User"; 5 | import "./App.css"; 6 | 7 | export class App extends Component { 8 | render() { 9 | return ( 10 |
11 | 18 | {this.props.users.users.length > 0 && ( 19 | 24 | )} 25 | {this.props.users.error && ( 26 |
27 | A network error occured 28 |
29 | )} 30 |
31 | ); 32 | } 33 | } 34 | 35 | const mapStateToProps = state => ({ 36 | ...state 37 | }); 38 | 39 | const mapDispatchToProps = dispatch => ({ 40 | getUsers: () => dispatch(getUsers()) 41 | }); 42 | 43 | export default connect( 44 | mapStateToProps, 45 | mapDispatchToProps 46 | )(App); 47 | 48 | // export default App; 49 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { App } from './App'; 3 | import { shallow } from 'enzyme'; 4 | import renderer from 'react-test-renderer'; 5 | 6 | describe('App Component', () => { 7 | let props; 8 | 9 | beforeEach(() => { 10 | props = { 11 | users: { 12 | users: [], 13 | loading: false, 14 | error: false 15 | } 16 | }; 17 | }); 18 | 19 | it('renders without crashing', () => { 20 | const tree = renderer.create() 21 | 22 | expect(tree.toJSON()).toMatchSnapshot(); 23 | }); 24 | 25 | it('renders an error message when a network error occurs', () => { 26 | props.users.error = true; 27 | const tree = renderer.create() 28 | 29 | expect(tree.toJSON()).toMatchSnapshot(); 30 | }); 31 | 32 | it('calls the getUsers function when the button is clicked', () => { 33 | props.getUsers = jest.fn(); 34 | const wrapper = shallow(); 35 | const spy = jest.spyOn(wrapper.instance().props, 'getUsers'); 36 | 37 | wrapper.find('button').simulate('click'); 38 | expect(spy).toHaveBeenCalled(); 39 | }); 40 | 41 | it('renders the User component correctly', () => { 42 | props.users.users = [ 43 | { 44 | id: 1, 45 | name: 'foo' 46 | } 47 | ] 48 | const wrapper = shallow(); 49 | 50 | expect(wrapper.find('User').length).toBe(1); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/__mocks__/axios.js: -------------------------------------------------------------------------------- 1 | export default { 2 | get: jest.fn(() => Promise.resolve({ data: {} })) 3 | }; -------------------------------------------------------------------------------- /src/__snapshots__/App.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App Component renders an error message when a network error occurs 1`] = ` 4 |
7 | 13 |
14 | A network error occured 15 |
16 |
17 | `; 18 | 19 | exports[`App Component renders without crashing 1`] = ` 20 |
23 | 29 |
30 | `; 31 | -------------------------------------------------------------------------------- /src/actions/users.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const getUsers = () => dispatch => { 4 | return dispatch({ 5 | type: "GET_USERS", 6 | payload: axios.get("https://jsonplaceholder.typicode.com/users") 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /src/actions/users.test.js: -------------------------------------------------------------------------------- 1 | import mockAxios from "axios"; 2 | import configureMockStore from "redux-mock-store"; 3 | import thunk from "redux-thunk"; 4 | import promiseMiddleware from "redux-promise-middleware"; 5 | import { getUsers } from "./users"; 6 | 7 | const mockStore = configureMockStore([thunk, promiseMiddleware()]); 8 | 9 | describe("User Actions", () => { 10 | let store; 11 | 12 | beforeEach(() => { 13 | store = mockStore({ 14 | users: {} 15 | }); 16 | }); 17 | 18 | describe("getUsers action creator", () => { 19 | it("dispatches GET_USERS action and returns data on success", async () => { 20 | mockAxios.get.mockImplementationOnce(() => 21 | Promise.resolve({ 22 | data: [{ id: 1, name: "Vasilis" }] 23 | }) 24 | ); 25 | 26 | await store.dispatch(getUsers()); 27 | const actions = store.getActions(); 28 | // [ { type: 'GET_USERS_PENDING' }, 29 | // { type: 'GET_USERS_FULFILLED', payload: { data: [Array] } } 30 | // ] 31 | 32 | expect.assertions(3); 33 | expect(actions[0].type).toEqual("GET_USERS_PENDING"); 34 | expect(actions[1].type).toEqual("GET_USERS_FULFILLED"); 35 | expect(actions[1].payload.data[0].name).toEqual("Vasilis"); 36 | }); 37 | 38 | it("tests GET_USERS action and that returns an error", async () => { 39 | mockAxios.get.mockImplementationOnce(() => 40 | Promise.reject({ 41 | error: "Something bad happened :(" 42 | }) 43 | ); 44 | 45 | try { 46 | await store.dispatch(getUsers()); 47 | } catch { 48 | const actions = store.getActions(); 49 | 50 | expect.assertions(3); 51 | expect(actions[0].type).toEqual("GET_USERS_PENDING"); 52 | expect(actions[1].type).toEqual("GET_USERS_REJECTED"); 53 | expect(actions[1].payload.error).toEqual("Something bad happened :("); 54 | } 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/components/User.css: -------------------------------------------------------------------------------- 1 | .user { 2 | list-style: none; 3 | } -------------------------------------------------------------------------------- /src/components/User.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './User.css'; 3 | 4 | const User = ({user}) => { 5 | return ( 6 |
  • 7 | {user.name} 8 |
  • 9 | ) 10 | } 11 | 12 | export default User; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import configureStore from './store'; 5 | import './index.css'; 6 | import App from './App'; 7 | import * as serviceWorker from './serviceWorker'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById('root') 14 | ); 15 | 16 | // If you want your app to work offline and load faster, you can change 17 | // unregister() to register() below. Note this comes with some pitfalls. 18 | // Learn more about service workers: http://bit.ly/CRA-PWA 19 | serviceWorker.unregister(); 20 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/reducers/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import users from './users'; 3 | export default combineReducers({ 4 | users 5 | }); -------------------------------------------------------------------------------- /src/reducers/users.js: -------------------------------------------------------------------------------- 1 | export default (state = { 2 | users: [], 3 | loading: false, 4 | error: false 5 | }, action) => { 6 | switch (action.type) { 7 | case "GET_USERS_PENDING": 8 | return Object.assign({}, state, { 9 | loading: true, 10 | }); 11 | case "GET_USERS_FULFILLED": 12 | return Object.assign({}, state, { 13 | users: action.payload.data, 14 | loading: false, 15 | }); 16 | case "GET_USERS_REJECTED": 17 | return Object.assign({}, state, { 18 | loading: false, 19 | error: true 20 | }); 21 | default: 22 | return state; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/reducers/users.test.js: -------------------------------------------------------------------------------- 1 | import users from "./users"; 2 | 3 | describe("users Reducer", () => { 4 | const initialState = { 5 | users: [], 6 | loading: false, 7 | error: false 8 | }; 9 | 10 | it("returns the initial state correctly", () => { 11 | const reducer = users(undefined, {}); 12 | 13 | expect(reducer).toEqual(initialState); 14 | }); 15 | 16 | it("handles GET_USERS_PENDING as expected", () => { 17 | const reducer = users(initialState, { type: "GET_USERS_PENDING" }); 18 | 19 | expect(reducer).toEqual({ 20 | users: [], 21 | loading: true, 22 | error: false 23 | }); 24 | }); 25 | 26 | it("handles GET_USERS_FULFILLED as expected", () => { 27 | const reducer = users(initialState, { 28 | type: "GET_USERS_FULFILLED", 29 | payload: { 30 | data: [ 31 | { 32 | id: 1, 33 | name: "foo" 34 | } 35 | ] 36 | } 37 | }); 38 | 39 | expect(reducer).toEqual({ 40 | users: [ 41 | { 42 | id: 1, 43 | name: "foo" 44 | } 45 | ], 46 | loading: false, 47 | error: false 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import logger from 'redux-logger' 4 | import rootReducer from './reducers/rootReducer'; 5 | import promiseMiddleware from 'redux-promise-middleware'; 6 | export default function configureStore() { 7 | return createStore( 8 | rootReducer, 9 | applyMiddleware(thunk, logger, promiseMiddleware()) 10 | ); 11 | } --------------------------------------------------------------------------------