├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── package.json └── src ├── __tests__ └── index.spec.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": [ 4 | "transform-object-rest-spread" 5 | ] 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Paul Sherman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-router-test-context 2 | 3 | Create a pseudo `context` object that duplicates React Router's `context.router` structure. This is useful for shallow unit testing with Enzyme. 4 | 5 | **Note:** This package only works with React Router v4. 6 | 7 | ### Installation 8 | 9 | ``` 10 | npm install --save-dev react-router-test-context 11 | ``` 12 | 13 | ### Usage 14 | 15 | ```js 16 | import createRouterContext from 'react-router-test-context' 17 | import { shallow } from 'enzyme' 18 | 19 | describe('my test', () => { 20 | it('renders', () => { 21 | const context = createRouterContext() 22 | const wrapper = shallow(, { context }) 23 | // ... 24 | }) 25 | }) 26 | ``` 27 | 28 | ### enzyme 29 | 30 | There are a few things that you should be aware of if you plan to use `react-router-test-context` with `enzyme` to test your location-aware components. 31 | 32 | #### `mount` 33 | 34 | If your root component is not a native React Router component (``, ``), you may run into issues with unfound context properties. To deal with this, you have two options. 35 | 36 | 1. Define a `contextTypes` on the root component. 37 | 38 | ```js 39 | import MyComponent from '../component/MyComponent' 40 | 41 | describe('my component', () => { 42 | 43 | // ADD THIS 44 | MyComponent.contextTypes = { 45 | router: React.PropTypes.object 46 | } 47 | 48 | it('renders', () => { 49 | const context = createRouterContext() 50 | const wrapper = mount(, { context }) 51 | // ... 52 | }) 53 | } 54 | ``` 55 | 56 | 2. Pass a `childContextTypes` object to enzyme via the `options` object. 57 | 58 | ```js 59 | describe('my component', () => { 60 | // ... 61 | 62 | it('renders', () => { 63 | const context = createRouterContext() 64 | const childContextTypes = { 65 | router: React.PropTypes.object 66 | } 67 | const wrapper = mount(, { context, childContextTypes }) 68 | // ... 69 | }) 70 | ``` 71 | 72 | #### Limitations of `shallow` Renders 73 | 74 | If you are using this to test that a `` is matching as expected, a shallow render will probably not work as expected. 75 | 76 | For example, if you were to do the following shallow render, the `wrapper` node would be a ``. This _could_ work to verify that you rendered the correct `` by checking that the path of the returned node is the path that you expect to be matched. 77 | 78 | ```js 79 | const context = createRouterContext({ location: { pathname: '/two' }}) 80 | const wrapper = shallow(( 81 | 82 | 83 | 84 | 85 | ), { context }) 86 | const props = wrapper.props() 87 | expect(props.path).toBe('/two') 88 | ``` 89 | 90 | This breaks down, however, if you attempt to do this on a component that contains a ``. 91 | 92 | ```js 93 | const Switcheroo = () => ( 94 | 95 | 96 | 97 | 98 | ) 99 | 100 | const wrapper = shallow(, { context }) 101 | ``` 102 | 103 | The `wrapper` node returned by shallow rendering the `` will be a ``. The `` will not have rendered, so we are unable to derive any relevant route matching information from the shallow render. 104 | 105 | In cases like this, you will just need to do a full mount if you want to verify that the component renders the correct child component. 106 | 107 | ```js 108 | const context = createRouterContext({ location: { pathname: '/two' }}) 109 | const wrapper = mount(( 110 | 111 | 112 | 113 | 114 | ), { context }) 115 | 116 | expect(wrapper.find(Two).exists()).toBe(true) 117 | ``` 118 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 8 | 9 | var randomKey = function randomKey(keyLength) { 10 | return Math.random().toString(36).substr(2, keyLength); 11 | }; 12 | 13 | var createDefaultMatch = function createDefaultMatch() { 14 | return { path: '/', url: '/', isExact: true, params: {} }; 15 | }; 16 | 17 | var createDefaultLocation = function createDefaultLocation() { 18 | return { pathname: '/', search: '', hash: '', key: randomKey(6) }; 19 | }; 20 | 21 | var createDefaultHistory = function createDefaultHistory(location) { 22 | return { 23 | action: 'POP', 24 | location: location || createDefaultLocation(), 25 | _listeners: [], 26 | listen: function listen(fn) { 27 | var _this = this; 28 | 29 | this._listeners.push(fn); 30 | return function () { 31 | _this._listeners = _this._listeners.filter(function (func) { 32 | return func !== fn; 33 | }); 34 | }; 35 | }, 36 | push: function push(location) { 37 | this._notifyListeners(location); 38 | }, 39 | replace: function replace(location) { 40 | this._notifyListeners(location); 41 | }, 42 | _notifyListeners: function _notifyListeners(loc) { 43 | this._listeners.forEach(function (fn) { 44 | fn(loc); 45 | }); 46 | }, 47 | createHref: function createHref(loc) { 48 | if (typeof loc === 'string') { 49 | return loc; 50 | } else { 51 | return loc.pathname + (loc.search || '') + (loc.hash || ''); 52 | } 53 | } 54 | }; 55 | }; 56 | 57 | var createRouterContext = function createRouterContext() { 58 | var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 59 | var userHistory = options.history, 60 | userLocation = options.location, 61 | userMatch = options.match, 62 | staticContext = options.staticContext; 63 | 64 | 65 | var match = _extends({}, createDefaultMatch(), userMatch); 66 | 67 | var location = _extends({}, createDefaultLocation(), userLocation); 68 | 69 | var history = _extends({}, createDefaultHistory(location), userHistory); 70 | 71 | return { 72 | router: { 73 | history: history, 74 | route: { 75 | match: match, 76 | location: location 77 | }, 78 | staticContext: staticContext 79 | } 80 | }; 81 | }; 82 | 83 | exports.default = createRouterContext; 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-test-context", 3 | "version": "0.1.0", 4 | "description": "Create a pseudo context to assist in testing components that render React Router's location-aware components.", 5 | "main": "index.js", 6 | "files": [ 7 | "index.js", 8 | "LICENSE", 9 | "*.md" 10 | ], 11 | "scripts": { 12 | "build": "babel ./src -o index.js --ignore __tests__", 13 | "test": "jest" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/pshrmn/react-router-test-context.git" 18 | }, 19 | "keywords": [ 20 | "React", 21 | "React", 22 | "Router", 23 | "unit", 24 | "test" 25 | ], 26 | "author": "Paul Sherman", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/pshrmn/react-router-test-context/issues" 30 | }, 31 | "homepage": "https://github.com/pshrmn/react-router-test-context#readme", 32 | "devDependencies": { 33 | "babel-cli": "^6.24.0", 34 | "babel-core": "^6.24.0", 35 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 36 | "babel-preset-es2015": "^6.24.0", 37 | "jest": "^19.0.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/__tests__/index.spec.js: -------------------------------------------------------------------------------- 1 | import createContext from '../index' 2 | 3 | describe('createContext', () => { 4 | it('returns an object with a "router" property', () => { 5 | const ctx = createContext() 6 | expect(ctx).toHaveProperty('router') 7 | }) 8 | 9 | describe('context.router', () => { 10 | it('has expected properties', () => { 11 | const ctx = createContext() 12 | expect(ctx.router).toHaveProperty('history') 13 | expect(ctx.router).toHaveProperty('route') 14 | expect(ctx.router).toHaveProperty('staticContext') 15 | // context.router.route 16 | expect(ctx.router.route).toHaveProperty('match') 17 | expect(ctx.router.route).toHaveProperty('location') 18 | }) 19 | 20 | describe('match', () => { 21 | it('is default object if not provided', () => { 22 | const ctx = createContext() 23 | const { match } = ctx.router.route 24 | expect(match).toEqual({ 25 | path: '/', 26 | url: '/', 27 | isExact: true, 28 | params: {} 29 | }) 30 | }) 31 | 32 | it('is provided object', () => { 33 | const match = { 34 | path: '/:number', 35 | url: '/8', 36 | isExact: true, 37 | params: { number: '8' } 38 | } 39 | const ctx = createContext({ match }) 40 | expect(ctx.router.route.match).toEqual(match) 41 | }) 42 | 43 | it('merges partially provided match object', () => { 44 | const partialMatch = { isExact: false } 45 | const ctx = createContext({ match: partialMatch }) 46 | const { match } = ctx.router.route 47 | expect(match).toEqual({ 48 | path: '/', 49 | url: '/', 50 | isExact: false, 51 | params: {} 52 | }) 53 | }) 54 | }) 55 | 56 | describe('location', () => { 57 | it('is default object if not provided', () => { 58 | const ctx = createContext() 59 | const { location } = ctx.router.route 60 | expect(location.pathname).toBe('/') 61 | expect(location.search).toBe('') 62 | expect(location.hash).toBe('') 63 | expect(typeof location.key).toBe('string') 64 | expect(location.key.length).toBe(6) 65 | }) 66 | 67 | it('is provided object', () => { 68 | const location = { 69 | pathname: '/some-place', 70 | search: '?test=value', 71 | hash: '#hash', 72 | key: 'okdoke' 73 | } 74 | const ctx = createContext({ location }) 75 | expect(ctx.router.route.location).toEqual(location) 76 | }) 77 | 78 | it('merges partially provided location object', () => { 79 | const partialLocation = { pathname: '/somewhere' } 80 | const ctx = createContext({ location: partialLocation }) 81 | const { location } = ctx.router.route 82 | expect(location.pathname).toBe('/somewhere') 83 | expect(location.search).toBe('') 84 | expect(location.hash).toBe('') 85 | }) 86 | }) 87 | 88 | describe('history', () => { 89 | it('is default object if not provided', () => { 90 | const properties = { 91 | action: 'string', 92 | location: 'object', 93 | listen: 'function', 94 | push: 'function', 95 | replace: 'function', 96 | createHref: 'function' 97 | } 98 | const ctx = createContext() 99 | const { history } = ctx.router 100 | Object.keys(properties).forEach(key => { 101 | expect(history).toHaveProperty(key) 102 | expect(typeof history[key]).toBe(properties[key]) 103 | }) 104 | }) 105 | 106 | it('uses location option if provided', () => { 107 | const location = { pathname: '/in-history' } 108 | const ctx = createContext({ location }) 109 | const { history } = ctx.router 110 | expect(history.location.pathname).toBe(location.pathname) 111 | }) 112 | 113 | it('is provided object', () => { 114 | const fakeHistory = { 115 | action: 'FAKE_ACTION', 116 | location: {}, 117 | listen: () => {}, 118 | push: () => {}, 119 | replace: () => {}, 120 | createHref: () => {} 121 | } 122 | const ctx = createContext({ history: fakeHistory }) 123 | const { history } = ctx.router 124 | Object.keys(fakeHistory).forEach(key => { 125 | expect(history[key]).toEqual(fakeHistory[key]) 126 | }) 127 | }) 128 | 129 | it('merges partially provided history object', () => { 130 | const partialHistory = { 131 | action: 'FAKE_ACTION' 132 | } 133 | const ctx = createContext({ history: partialHistory }) 134 | const { history } = ctx.router 135 | 136 | // verify that the partial property is present 137 | expect(history.action).toEqual(partialHistory.action) 138 | // and that the default properties exist 139 | const properties = [ 'action', 'location', 'listen', 'push', 'replace', 'createHref' ] 140 | properties.forEach(p => { 141 | expect(history).toHaveProperty(p) 142 | }) 143 | }) 144 | 145 | }) 146 | 147 | describe('staticContext', () => { 148 | it('is undefined if not provided as an option', () => { 149 | const ctx = createContext() 150 | expect(ctx.router.staticContext).toBe(undefined) 151 | }) 152 | 153 | it('is the provided value', () => { 154 | const staticContext = {} 155 | const ctx = createContext({ staticContext }) 156 | expect(ctx.router.staticContext).toBe(staticContext) 157 | }) 158 | }) 159 | 160 | 161 | }) 162 | }) -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const randomKey = (keyLength) => { 2 | return Math.random().toString(36).substr(2, keyLength) 3 | } 4 | 5 | const createDefaultMatch = () => ({ path: '/', url: '/', isExact: true, params: {}}) 6 | 7 | const createDefaultLocation = () => ({ pathname: '/', search: '', hash: '', key: randomKey(6) }) 8 | 9 | const createDefaultHistory = (location) => ({ 10 | action: 'POP', 11 | location: location || createDefaultLocation(), 12 | _listeners: [], 13 | listen: function(fn) { 14 | this._listeners.push(fn) 15 | return () => { 16 | this._listeners = this._listeners.filter(func => func !== fn) 17 | } 18 | }, 19 | push: function(location) { this._notifyListeners(location) }, 20 | replace: function(location) { this._notifyListeners(location) }, 21 | _notifyListeners: function(loc) { 22 | this._listeners.forEach(fn => { fn(loc) }) 23 | }, 24 | createHref: (loc) => { 25 | if (typeof loc === 'string') { 26 | return loc 27 | } else { 28 | return loc.pathname + (loc.search || '') + (loc.hash || '') 29 | } 30 | } 31 | }) 32 | 33 | const createRouterContext = (options = {} ) => { 34 | 35 | const { 36 | history:userHistory, 37 | location:userLocation, 38 | match:userMatch, 39 | staticContext 40 | } = options 41 | 42 | const match = { 43 | ...createDefaultMatch(), 44 | ...userMatch 45 | } 46 | 47 | const location = { 48 | ...createDefaultLocation(), 49 | ...userLocation 50 | } 51 | 52 | const history = { 53 | ...createDefaultHistory(location), 54 | ...userHistory 55 | } 56 | 57 | return { 58 | router: { 59 | history, 60 | route: { 61 | match, 62 | location 63 | }, 64 | staticContext 65 | } 66 | } 67 | } 68 | 69 | export default createRouterContext 70 | --------------------------------------------------------------------------------