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