Skip to content

Commit 51333f2

Browse files
author
mcibique
committed
Added section about mocking actions, mutations and getters
1 parent ec70d34 commit 51333f2

File tree

6 files changed

+201
-14
lines changed

6 files changed

+201
-14
lines changed

README.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,116 @@ export default new VueRouter({
12021202
});
12031203
```
12041204

1205+
## Mocking actions, mutations and getters
1206+
There still can be a scenario when using real store might be a problem and you would like to mock some parts. Mocking an action can be achieved by stubbing the dispatch function provided by the store:
1207+
```js
1208+
beforeEach(function () {
1209+
let dispatchStub = sinon.stub(this.store, ['dispatch']);
1210+
dispatchStub.callThrough(); // allow other actions to be executed
1211+
dispatchStub.withArgs('auth/login').resolves(42); // only if dispatch has been invoked for 'auth/login' then return resolved Promise with custom result
1212+
});
1213+
1214+
it('should do something', function () {
1215+
let result = this.store.dispatch('something');
1216+
expect(result).to.become(/* original call result */);
1217+
1218+
result = this.store.dispatch('auth/login');
1219+
expect(result).to.become(42);
1220+
});
1221+
```
1222+
1223+
Mutations are very similar to actions, but instead of dispatch, we are going to stub commit:
1224+
```js
1225+
beforeEach(function () {
1226+
let commitStub = sinon.stub(this.store, ['commit']);
1227+
commitStub.callThrough(); // allow other mutations to be executed
1228+
commitStub.withArgs('setToken').callsFake(x => x);
1229+
});
1230+
1231+
it('should do something', function () {
1232+
let result = this.store.commit('something');
1233+
expect(result).to.become(/* original call result */);
1234+
1235+
result = this.store.commit('setToken', 'random_token');
1236+
expect(result).to.equal('random_token');
1237+
});
1238+
```
1239+
Getters are little bit tougher and they completely resist any attempt to override them. Getters are unfortunately configured as non-configurable when store is created. That means attempts like these won't help us:
1240+
```js
1241+
beforeEach(function () {
1242+
this.store.getters.isAuthenticated = sinon.stub(); // throws an Error
1243+
1244+
Object.defineProperty(this.store.getters, 'isAuthenticated', {
1245+
get() {
1246+
return sinon.stub();
1247+
}
1248+
}); // throws an Error
1249+
1250+
Reflect.defineProperty(this.store.getters, 'isAuthenticated', {
1251+
get() {
1252+
return sinon.stub();
1253+
}
1254+
}); // returns false, which means it wasn't successful
1255+
});
1256+
```
1257+
It's not over yet, the `getters` property is not protected, that means we can replaced whole `getters` with mock which we can control over. We can use [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) as a man-in-the-middle which will be returning mocked getters or original ones based on the configuration:
1258+
```js
1259+
beforeEach(function () {
1260+
let isAuthenticatedStub = sinon.stub().returns(true);
1261+
1262+
let proxy = new Proxy(this.store.getters, {
1263+
get(getters, key) {
1264+
if (key === 'isAuthenticated') {
1265+
return isAuthenticatedStub();
1266+
} else {
1267+
return getters[key];
1268+
}
1269+
}
1270+
});
1271+
1272+
Object.defineProperty(this.store, 'getters', {
1273+
get() {
1274+
return proxy;
1275+
}
1276+
});
1277+
});
1278+
```
1279+
That's a lot of code for mocking only one getter so it would be better to extract it as a helper method or attach it to the `Store.prototype`. We can also make the name and the stub function's parameters so the function becomes more generic:
1280+
```js
1281+
import { Store } from 'vuex';
1282+
1283+
Store.prototype.mockGetter = function (name, stub) {
1284+
let store = this;
1285+
let mockedGetters = store.__mockedGetters = store.__mockedGetters || new Map();
1286+
1287+
mockedGetters.set(name, stub);
1288+
1289+
let gettersProxy = new Proxy(store.getters, {
1290+
get (getters, propName) {
1291+
if (mockedGetters.has(propName)) {
1292+
return mockedGetters.get(propName).call(store);
1293+
} else {
1294+
return getters[propName];
1295+
}
1296+
}
1297+
});
1298+
1299+
Object.defineProperty(store, 'getters', {
1300+
get () {
1301+
return gettersProxy;
1302+
}
1303+
});
1304+
};
1305+
```
1306+
Then we can call in the test:
1307+
```js
1308+
beforeEach(function () {
1309+
let isAuthenticatedStub = sinon.stub().returns(true);
1310+
this.store.mockGetter('isAuthenticated', isAuthenticatedStub);
1311+
});
1312+
```
1313+
You can see full implementation (including restoring mock back to original functionality) in [test/unit/utils/store.js](./test/unit/utils/store.js) file.
1314+
12051315
# Using flush-promises vs Vue.nextTick()
12061316

12071317
This topic has been fully covered by [the official documentation](https://vue-test-utils.vuejs.org/en/guides/testing-async-components.html).

src/router.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function createRouter (/* istanbul ignore next */ vueInstance = Vue, stor
4141
path: '/',
4242
name: 'root',
4343
redirect () {
44-
if (store.state.auth.token) {
44+
if (store.getters['auth/isAuthenticated']) {
4545
return '/welcome';
4646
} else {
4747
return '/login';
@@ -57,7 +57,7 @@ export function createRouter (/* istanbul ignore next */ vueInstance = Vue, stor
5757

5858
router.beforeEach(function (to, from, next) {
5959
if (to.matched.some(record => record.meta.requiresAuth)) {
60-
if (!store.state.auth.token) {
60+
if (!store.getters['auth/isAuthenticated']) {
6161
return void next({ name: 'login' });
6262
}
6363
}

src/router.spec.js

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@ import sinon from 'sinon';
55
import flushPromises from 'flush-promises';
66

77
import { createRouter } from '@/router';
8+
import { createStore } from '@/store';
89

910
describe('Router', function () {
1011
beforeEach(function () {
1112
this.localVue = createLocalVue();
1213

13-
this.store = {
14-
state: {},
15-
dispatch: sinon.stub().returnsPromise()
16-
};
14+
this.store = createStore(this.localVue);
15+
sinon.stub(this.store, ['dispatch']).returnsPromise();
1716

1817
this.router = createRouter(this.localVue, this.store);
1918
});
@@ -58,9 +57,10 @@ describe('Router', function () {
5857
});
5958

6059
describe('root route', function () {
61-
describe('when user is not logged in', function () {
60+
describe('when user is already logged in', function () {
6261
beforeEach(function () {
63-
this.store.state.auth = { token: 'random_token' };
62+
this.isAuthenticatedStub = sinon.stub().returns(true);
63+
this.store.mockGetter('auth/isAuthenticated', this.isAuthenticatedStub);
6464
});
6565

6666
it('should redirect user to welcome view', async function () {
@@ -70,9 +70,10 @@ describe('Router', function () {
7070
});
7171
});
7272

73-
describe('when user is already logged in', function () {
73+
describe('when user is not logged in', function () {
7474
beforeEach(function () {
75-
this.store.state.auth = { token: null };
75+
this.isAuthenticatedStub = sinon.stub().returns(false);
76+
this.store.mockGetter('auth/isAuthenticated', this.isAuthenticatedStub);
7677
});
7778

7879
it('should redirect user to login view', async function () {
@@ -85,7 +86,9 @@ describe('Router', function () {
8586

8687
describe('auth navigation guard', function () {
8788
beforeEach(function () {
88-
this.store.state.auth = { token: null };
89+
this.isAuthenticatedStub = sinon.stub();
90+
this.store.mockGetter('auth/isAuthenticated', this.isAuthenticatedStub);
91+
8992
this.router.addRoutes([
9093
{ name: 'protected-route', path: '/protected-route', meta: { requiresAuth: true } },
9194
{ name: 'unprotected-route', path: '/unprotected-route', meta: { requiresAuth: false } }
@@ -95,7 +98,7 @@ describe('Router', function () {
9598
describe('when user navigates to route which requires authentication', function () {
9699
describe('and user is not authenticated', function () {
97100
beforeEach(function () {
98-
this.store.state.auth.token = null;
101+
this.isAuthenticatedStub.returns(false);
99102
this.router.push('protected-route');
100103
return flushPromises();
101104
});
@@ -107,7 +110,7 @@ describe('Router', function () {
107110

108111
describe('and user has already been authenticated', function () {
109112
beforeEach(function () {
110-
this.store.state.auth.token = 'auth_token';
113+
this.isAuthenticatedStub.returns(true);
111114
this.router.push('protected-route');
112115
return flushPromises();
113116
});

src/views/Welcome.vue.spec.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,17 @@ describe('Welcome view', function () {
4444
};
4545

4646
// set proper route
47-
this.store.commit('auth/setToken', 'random_token');
47+
let isAuthenticatedStub = sinon.stub().returns(true);
48+
this.store.mockGetter('auth/isAuthenticated', isAuthenticatedStub);
4849
this.router.push({ name: 'welcome' });
4950

5051
return flushPromises();
5152
});
5253

54+
afterEach(function () {
55+
this.store.restore();
56+
});
57+
5358
it('should enter route', function () {
5459
expect(this.router.currentRoute.name).to.equal('welcome');
5560
});

test/unit/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import chai from 'chai';
22
import sinon from 'sinon';
33
import sinonChai from 'sinon-chai';
44
import sinonStubPromise from 'sinon-stub-promise';
5+
56
import './utils/axios';
7+
import './utils/store';
68
import './utils/tid';
79

810
chai.use(sinonChai);

test/unit/utils/store.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Store } from 'vuex';
2+
3+
Store.prototype.mockGetter = function (name, stub) {
4+
if (!name) {
5+
throw new Error('Getter name must be specified');
6+
}
7+
8+
if (!stub) {
9+
throw new Error('Missing stub function');
10+
}
11+
12+
if (typeof stub !== 'function') {
13+
throw new Error('Stub must be a function');
14+
}
15+
16+
let store = this;
17+
let mockedGetters = store.__mockedGetters = store.__mockedGetters || new Map();
18+
19+
if (mockedGetters.has(name)) {
20+
throw new Error(`Cannot mock getter with the name '${name}' twice. Restore the getter or call stub.reset() instead.`);
21+
}
22+
23+
mockedGetters.set(name, stub);
24+
25+
let gettersProxy = store.__gettersProxy;
26+
if (!gettersProxy) {
27+
store.__gettersProxy = gettersProxy = new Proxy(store.getters, {
28+
get (getters, propName) {
29+
if (mockedGetters.has(propName)) {
30+
return mockedGetters.get(propName).call(store);
31+
} else {
32+
return getters[propName];
33+
}
34+
}
35+
});
36+
37+
store.__originalGetters = store.getters;
38+
Object.defineProperty(store, 'getters', {
39+
get () {
40+
return gettersProxy;
41+
}
42+
});
43+
}
44+
45+
return {
46+
restore () {
47+
mockedGetters.delete(name);
48+
}
49+
};
50+
};
51+
52+
Store.prototype.restore = function () {
53+
let store = this;
54+
55+
if (store.__originalGetters) {
56+
let getters = store.__originalGetters;
57+
Object.defineProperty(store, 'getters', {
58+
get () {
59+
return getters;
60+
}
61+
});
62+
}
63+
64+
delete store.__mockedGetters;
65+
delete store.__gettersProxy;
66+
delete store.__originalGetters;
67+
};

0 commit comments

Comments
 (0)